Skip to content

Add freemium model with 30-day trial, Google Play IAP, and write-access gating#630

Open
tobihagemann wants to merge 60 commits intodevelopfrom
feature/freemium
Open

Add freemium model with 30-day trial, Google Play IAP, and write-access gating#630
tobihagemann wants to merge 60 commits intodevelopfrom
feature/freemium

Conversation

@tobihagemann
Copy link
Copy Markdown
Member

@tobihagemann tobihagemann commented Mar 31, 2026

Summary

Introduces a freemium distribution model via a new playstoreiap build flavor. Free users get read-only vault access with a 30-day full-featured trial on first install. Write operations (create vault, upload, create folder/text file, rename, move, delete) are gated behind LicenseEnforcer and redirect to a purchase screen when the trial expires.

Key changes:

  • New playstoreiap flavor across all modules, with a real IapBillingService backed by Google Play Billing Library 8.2.1. Other flavors get a no-op stub.
  • LicenseEnforcer evaluates write access from three sources: paid license token, active subscription, or active trial. Once trial expiry is observed, a sticky trialExpired flag latches in SharedPreferences to block clock-rollback reactivation. Hub vaults use their own subscription status.
  • PurchaseManager handles purchase verification, token persistence, and acknowledgement with TOCTOU-safe state transitions.
  • LicenseStateOrchestrator drives reactive UI updates via SharedPreferences change listeners, used by both WelcomeActivity and LicenseCheckActivity.
  • LicenseContentViewBinder extracts shared purchase/trial UI into a reusable binder for the onboarding welcome flow and the standalone IAP screen.
  • Trial entry on non-IAP welcome flow adds the 30-day trial button to the welcome License page for apkstore/fdroid/lite alongside license-key entry. LicenseCheckActivity (upsell) keeps its scope guard and never shows the trial row.
  • New WelcomeActivity with a ViewPager2-based onboarding flow (intro, license, notifications, and screen lock when the device isn't already secured) that replaces the old dialog-based first-launch experience for IAP builds.
  • Write-access guards in BrowseFilesPresenter, SharedFilesPresenter, VaultListPresenter, and TextEditorActivity, redirecting blocked write actions to the IAP screen with a locked-action header.
  • DB migration 13 to 14 adds Hub subscription columns; domain Vault model extended with hubSubscriptionState.
  • 359 lines of LicenseEnforcerTest covering trial lifecycle, paid license, subscription, Hub vaults, and flavor-based bypass. Additional tests for PurchaseManager, ProductInfo, DoLicenseCheck, UnlockHubVault, and vault list freemium behavior.
  • Stale locale translations for repurposed strings cleaned up across 30+ locale files.
  • UI aligned with iOS: IAP screen layout, logo sizing, trial status labels, settings subscription management.
  • Generated intent interfaces (LicenseCheckIntent, TextEditorIntent) for type-safe intent extras, replacing raw companion constants.
  • Accessibility: decorative icons marked for TalkBack skip, live region on screen-lock status, wrap_content fix for trial-expired dialog scroll.

State Machine

stateDiagram-v2
    [*] --> ReadOnly: fresh install (non-premium flavors)
    [*] --> FullAccess: premium flavor
    ReadOnly --> Trial: startTrial()
    Trial --> FullAccess: purchase / subscribe
    Trial --> ReadOnly: trial expires
    ReadOnly --> FullAccess: purchase / subscribe
    FullAccess --> ReadOnly: subscription lapses (no lifetime license)
Loading

Test Plan

  • Unit tests pass: :domain:testDebugUnitTest, :presentation:testPlaystoreiapDebugUnitTest
  • Build all flavors: assemblePlaystoreDebug, assemblePlaystoreiapDebug, assembleApkstoreDebug, assembleFdroidDebug, assembleLiteDebug
  • Verify premium flavors bypass all license checks (no trial, no gating)
  • Fresh install on playstoreiap: onboarding flow shows license step, trial starts, write ops work
  • After trial expiry: write ops redirect to IAP screen with the action-specific header
  • After trial expiry, rolling the device clock back keeps the trial expired (sticky latch)
  • Fresh install on apkstore: welcome License page shows trial button alongside license entry; tapping starts trial and auto-advances
  • Purchase flow (lifetime and subscription) via Play Billing sandbox
  • Hub vault write access respects Hub subscription state independently
  • DB migration 13→14 on existing installs

SailReal and others added 30 commits October 22, 2025 14:57
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseManagerTest.kt (1)

20-20: Nit: acknowledgedTokens can be val.

The field is only reassigned via .clear() / .add() on the list itself, never re-bound, so it can be declared val to match acknowledgePurchase below.

♻️ Proposed change
-	private var acknowledgedTokens = mutableListOf<String>()
+	private val acknowledgedTokens = mutableListOf<String>()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseManagerTest.kt`
at line 20, The field acknowledgedTokens is only mutated via its methods
(clear/add) and never re-assigned, so change its declaration from var to val to
make it immutable; update the declaration of acknowledgedTokens in
PurchaseManagerTest (referencing the acknowledgedTokens symbol) to use val to
match acknowledgePurchase and ensure no other code rebinds it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@presentation/src/main/res/values/strings.xml`:
- Around line 110-112: Remove the three unused string resources from the XML:
delete the <string> entries named screen_settings_license_unlock_prompt,
screen_settings_license_unlock_summary, and screen_settings_license_unlocked_iap
from strings.xml so they are no longer shipped or translated; ensure no
references remain by searching for these exact resource names before committing.

---

Nitpick comments:
In
`@presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseManagerTest.kt`:
- Line 20: The field acknowledgedTokens is only mutated via its methods
(clear/add) and never re-assigned, so change its declaration from var to val to
make it immutable; update the declaration of acknowledgedTokens in
PurchaseManagerTest (referencing the acknowledgedTokens symbol) to use val to
match acknowledgePurchase and ensure no other code rebinds it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7d63b504-fbbf-4c67-9200-55305f074c72

📥 Commits

Reviewing files that changed from the base of the PR and between 897194e and aba415d.

📒 Files selected for processing (11)
  • domain/build.gradle
  • presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt
  • presentation/src/main/res/values/strings.xml
  • presentation/src/main/res/xml/preferences.xml
  • presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseManager.kt
  • presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseManagerTest.kt
  • util/src/main/java/org/cryptomator/util/FlavorConfig.kt
✅ Files skipped from review due to trivial changes (3)
  • presentation/src/main/res/xml/preferences.xml
  • util/src/main/java/org/cryptomator/util/FlavorConfig.kt
  • presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseManager.kt
🚧 Files skipped from review as they are similar to previous changes (3)
  • domain/build.gradle
  • presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt

Comment thread presentation/src/main/res/values/strings.xml Outdated
Comment on lines +218 to +219
setResult(RESULT_OK)
finish()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would recommend to adjust this to

Suggested change
setResult(RESULT_OK)
finish()
welcomePresenter.finishWithResult(null)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you clarify the intent here? A literal welcomePresenter.finishWithResult(null) would change behavior. Presenter.finishWithResult(null) sets RESULT_CANCELED (Presenter.kt:81), while the existing code sets RESULT_OK. VaultListPresenter.welcomeFlowCompleted() is a @Callback with dispatchResultOkOnly = true (the default), so switching to RESULT_CANCELED would suppress the welcome-complete callback and skip the follow-up prepareView() on return.

Two ways forward:

  1. Keep the current setResult(RESULT_OK); finish() in openVaultList() (no change).
  2. Add a WelcomePresenter.completeWelcomeFlow() helper that does activity().setResult(RESULT_OK); finish() internally and route openVaultList() through it, preserving callback semantics while matching the presenter-style.

Which do you prefer?

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt (1)

169-176: Naming nit: singular form would match the sibling API.

Each call adds/removes a single Consumer<String>, but the methods are named addLicenseChangedListener**s** / removeLicenseChangedListener**s**. The analogous lock-timeout API on line 55 uses singular addLockTimeoutChangedListener. Consider renaming for consistency.

♻️ Proposed rename
-	fun addLicenseChangedListeners(listener: Consumer<String>) {
+	fun addLicenseChangedListener(listener: Consumer<String>) {
 		licenseChangedListeners[listener] = null
 		listener.accept(licenseToken())
 	}
 
-	fun removeLicenseChangedListeners(listener: Consumer<String>) {
+	fun removeLicenseChangedListener(listener: Consumer<String>) {
 		licenseChangedListeners.remove(listener)
 	}

Call sites in LicenseStateOrchestrator.onResume() / onPause() would need the matching update.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt` around
lines 169 - 176, Rename the plural methods addLicenseChangedListeners and
removeLicenseChangedListeners to the singular forms addLicenseChangedListener
and removeLicenseChangedListener to match the sibling API; update the function
declarations in SharedPreferencesHandler (keeping behavior unchanged:
licenseChangedListeners[listener] = null and
licenseChangedListeners.remove(listener)) and then update all call sites such as
LicenseStateOrchestrator.onResume() and LicenseStateOrchestrator.onPause() to
use the new method names.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt`:
- Around line 169-176: Rename the plural methods addLicenseChangedListeners and
removeLicenseChangedListeners to the singular forms addLicenseChangedListener
and removeLicenseChangedListener to match the sibling API; update the function
declarations in SharedPreferencesHandler (keeping behavior unchanged:
licenseChangedListeners[listener] = null and
licenseChangedListeners.remove(listener)) and then update all call sites such as
LicenseStateOrchestrator.onResume() and LicenseStateOrchestrator.onPause() to
use the new method names.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3b47d3e9-1f0c-492b-bf45-521ea480bf7d

📥 Commits

Reviewing files that changed from the base of the PR and between aba415d and 2d06892.

📒 Files selected for processing (3)
  • presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt
  • presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt
  • util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt
✅ Files skipped from review due to trivial changes (1)
  • presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
presentation/src/main/res/values/strings.xml (1)

96-100: Use positional format specifiers for translator clarity.

screen_license_check_trial_expiration (%s) and screen_license_check_trial_active_info (%s) should use %1$s for consistency with the rest of the file and to make translation unambiguous — the same reason notification_auto_upload_message was converted to %1$d/%2$d in this same change (line 693). Android Studio's resource lint flags non-positional %s when the string is formatted.

Proposed diff
-	<string name="screen_license_check_trial_expiration">Expiration Date: %s</string>
+	<string name="screen_license_check_trial_expiration">Expiration Date: %1$s</string>
 	<string name="screen_license_check_trial_status_active">Active</string>
 	<string name="screen_license_check_trial_status_expired">Expired</string>
-	<string name="screen_license_check_trial_active_info">Trial expires: %s</string>
+	<string name="screen_license_check_trial_active_info">Trial expires: %1$s</string>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@presentation/src/main/res/values/strings.xml` around lines 96 - 100, Update
the two string resources so their format specifiers are positional: replace the
non-positional "%s" with "%1$s" in screen_license_check_trial_expiration and
screen_license_check_trial_active_info so translations remain unambiguous and
lint warnings are resolved.
presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseManagerTest.kt (1)

17-254: LGTM.

Good coverage across PENDING/PURCHASED/UNSPECIFIED_STATE/unknown states, mixed lists, acknowledgement, clearIfNotFound semantics, and the negative assertion that PurchaseManager does not arm purchase-revoked state (that's the coordinator's responsibility).

One small gap worth considering: no positive assertion that acknowledgePurchase is not invoked when purchase.isAcknowledged == true. Easy to add alongside the existing acknowledges unacknowledged purchase test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseManagerTest.kt`
around lines 17 - 254, Add a test that asserts acknowledgePurchase is NOT
invoked when a purchase already has isAcknowledged == true: create a purchase
via mockPurchase(ProductInfo.PRODUCT_FULL_VERSION,
Purchase.PurchaseState.PURCHASED, "token-1", isAcknowledged = true), call
purchaseManager.handleInAppPurchases(listOf(purchase), acknowledgePurchase =
acknowledgePurchase) and assert acknowledgedTokens remains empty (or verify no
acknowledgment happened); place it alongside the existing `handleInAppPurchases
acknowledges unacknowledged purchase` test to cover the negative case.
presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcome.kt (1)

3-7: Nit: object/class names should be PascalCase.

Kotlin style guide recommends PascalCase for object and class names (Restored, NothingToRestore, Failed) rather than SCREAMING_SNAKE_CASE, which reads like an enum constant. Purely a style call; no functional impact.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcome.kt`
around lines 3 - 7, The sealed interface RestoreOutcome uses
SCREAMING_SNAKE_CASE for its implementations (RESTORED, NOTHING_TO_RESTORE,
FAILED); rename them to PascalCase (Restored, NothingToRestore, Failed) and
update any references/usages accordingly; adjust the declarations in
RestoreOutcome (data object Restored : RestoreOutcome, data object
NothingToRestore : RestoreOutcome, data class Failed(val cause: Throwable? =
null) : RestoreOutcome) and run a project-wide refactor to fix imports/usages to
the new symbols.
util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt (1)

178-192: Nit: inconsistent indentation in onSharedPreferenceChanged.

The function signature and when block are indented one level shallower than the rest of the class, and the inner LOCK_TIMEOUT -> branch uses a different indent depth than the LICENSE_TOKEN, … branch. Reformat for consistency.

Proposed reformatting
-override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
-	when (key) {
-		LOCK_TIMEOUT -> {
-			val lockTimeout = lockTimeout
-			lockTimeoutChangedListeners.keys.forEach { listener ->
-					listener.accept(lockTimeout)
-				}
-			}
-			LICENSE_TOKEN, TRIAL_EXPIRATION_DATE, HAS_RUNNING_SUBSCRIPTION -> {
-				licenseChangedListeners.keys.forEach { listener ->
-					listener.accept(licenseToken())
-			}
-		}
-	}
-}
+	override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
+		when (key) {
+			LOCK_TIMEOUT -> {
+				val lockTimeout = lockTimeout
+				lockTimeoutChangedListeners.keys.forEach { listener ->
+					listener.accept(lockTimeout)
+				}
+			}
+			LICENSE_TOKEN, TRIAL_EXPIRATION_DATE, HAS_RUNNING_SUBSCRIPTION -> {
+				licenseChangedListeners.keys.forEach { listener ->
+					listener.accept(licenseToken())
+				}
+			}
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt` around
lines 178 - 192, Reformat the indentation in onSharedPreferenceChanged so the
function signature, the when { ... } block, and all case branches use consistent
indentation with the rest of the class; specifically align the
onSharedPreferenceChanged declaration and the when keyword, make the
LOCK_TIMEOUT -> branch body (including the local val lockTimeout and
lockTimeoutChangedListeners.keys.forEach { listener ->
listener.accept(lockTimeout) }) use the same indent level as the LICENSE_TOKEN,
TRIAL_EXPIRATION_DATE, HAS_RUNNING_SUBSCRIPTION -> branch, and ensure the
licenseChangedListeners.keys.forEach { listener ->
listener.accept(licenseToken()) } block has matching brace alignment; keep
existing logic (references: onSharedPreferenceChanged, LOCK_TIMEOUT,
lockTimeout, lockTimeoutChangedListeners, LICENSE_TOKEN, TRIAL_EXPIRATION_DATE,
HAS_RUNNING_SUBSCRIPTION, licenseChangedListeners, licenseToken()) unchanged.
presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt (1)

44-69: Minor: onSettled reads aggregated state without holding lock.

hadWriteAccessBefore is captured before issuing queries, but onSettled reads inappChange / subsChange / failure outside synchronized(lock). Play Billing callbacks are documented to run on the main thread, so this is fine in practice, but since you're already defensively using a lock around the writes, the read side should match for consistency (and to survive any future change of dispatch thread).

🧹 Suggested refactor
 			fun onSettled() {
-				val localInapp = inappChange
-				val localSubs = subsChange
-				val localFailure = failure
+				val localInapp: PurchaseFieldChange?
+				val localSubs: PurchaseFieldChange?
+				val localFailure: Throwable?
+				synchronized(lock) {
+					localInapp = inappChange
+					localSubs = subsChange
+					localFailure = failure
+				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt`
around lines 44 - 69, The onSettled function reads shared variables
(inappChange, subsChange, failure and later calls
licenseEnforcer.hasWriteAccess() to compute hadWriteAccessAfter) without holding
the existing lock; wrap the reading of these aggregated states and the
computation of hadWriteAccessAfter in synchronized(lock) to mirror the
write-side protection: e.g. inside onSettled acquire synchronized(lock) to copy
localInapp/localSubs/localFailure and hadWriteAccessAfter (or compute
hadWriteAccessAfter while holding the lock), then release the lock and continue
the rest of the logic (including
sharedPreferencesHandler.setPurchaseRevokedState and complete) using those local
copies so all reads are protected by the same lock used for writes.
presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt (1)

55-62: Minor: consumeLastRestoreOutcome() read-then-clear is non-atomic.

If both LicenseCheckActivity and WelcomeActivity (or any two consumers) ever run their onResume concurrently, both could observe the same lastRestoreOutcome before either nulls it, resulting in a double dialog. In practice Android Activity lifecycle callbacks all run on the main thread so this is fine today, but a small @Synchronized or atomic getAndSet would make the invariant explicit and future-proof.

🧹 Suggested refactor
-	`@Volatile`
-	var lastRestoreOutcome: RestoreOutcome? = null
-
-	fun consumeLastRestoreOutcome(): RestoreOutcome? {
-		val outcome = lastRestoreOutcome
-		lastRestoreOutcome = null
-		return outcome
-	}
+	private val lastRestoreOutcomeRef = java.util.concurrent.atomic.AtomicReference<RestoreOutcome?>(null)
+
+	var lastRestoreOutcome: RestoreOutcome?
+		get() = lastRestoreOutcomeRef.get()
+		set(value) { lastRestoreOutcomeRef.set(value) }
+
+	fun consumeLastRestoreOutcome(): RestoreOutcome? = lastRestoreOutcomeRef.getAndSet(null)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt`
around lines 55 - 62, The read-then-clear of lastRestoreOutcome in
consumeLastRestoreOutcome is non-atomic and can race if multiple consumers call
it concurrently; update consumeLastRestoreOutcome to perform an atomic
get-and-clear (e.g., synchronize the method or use an AtomicReference for
lastRestoreOutcome and call getAndSet(null)) so the fetch-and-null operation is
atomic and only one caller observes the value (refer to lastRestoreOutcome and
consumeLastRestoreOutcome to locate the change).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt`:
- Around line 54-70: Pending product-details callbacks in
pendingProductDetailsCallbacks are never drained when
onBillingSetupFinished(billingResult) returns non-OK, leaving callers hung;
update onBillingSetupFinished (and the symmetric failure path around lines
handling billing disconnection) to drain pendingProductDetailsCallbacks on error
by copying and clearing the list then invoking each callback with an empty list
(or an error signal) so UI callers like
LicenseContentViewBinder.loadAndBindPrices receive a terminal response; ensure
you reference and clear pendingProductDetailsCallbacks before calling callbacks
to avoid reentrancy and duplicate invocations.
- Around line 40-76: initBillingClient currently overwrites billingClient,
sharedPreferencesHandler, purchaseManager and purchaseRefreshCoordinator on
every call causing leaked BillingClient instances and stale listeners; modify
initBillingClient to be idempotent by either short-circuiting when an existing
billingClient is initialized and ready (check billingClient != null &&
billingClient.isReady()) or gracefully tearing down the old client before
replacing it (call billingClient.endConnection() and clear/transfer any
in-flight state such as pendingProductDetailsCallbacks) so you do not leak
connections or re-register listeners for
PurchaseManager/PurchaseRefreshCoordinator.

In
`@presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt`:
- Around line 82-107: The BillingClient async callbacks can throw and prevent
onQueryComplete() from running; wrap the bodies of both
billingClient.queryPurchasesAsync lambdas in a try/catch/finally so any
exception from purchaseManager.handleInAppPurchases,
purchaseManager.handleSubscriptionPurchases or the acknowledge callback is
caught and logged (or captured into failure) and onQueryComplete() is called
from finally; ensure you set failure when an exception occurs and then call
onQueryComplete() in the finally block so queriesCompleted always progresses to
totalQueries.

---

Nitpick comments:
In `@presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt`:
- Around line 55-62: The read-then-clear of lastRestoreOutcome in
consumeLastRestoreOutcome is non-atomic and can race if multiple consumers call
it concurrently; update consumeLastRestoreOutcome to perform an atomic
get-and-clear (e.g., synchronize the method or use an AtomicReference for
lastRestoreOutcome and call getAndSet(null)) so the fetch-and-null operation is
atomic and only one caller observes the value (refer to lastRestoreOutcome and
consumeLastRestoreOutcome to locate the change).

In
`@presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcome.kt`:
- Around line 3-7: The sealed interface RestoreOutcome uses SCREAMING_SNAKE_CASE
for its implementations (RESTORED, NOTHING_TO_RESTORE, FAILED); rename them to
PascalCase (Restored, NothingToRestore, Failed) and update any references/usages
accordingly; adjust the declarations in RestoreOutcome (data object Restored :
RestoreOutcome, data object NothingToRestore : RestoreOutcome, data class
Failed(val cause: Throwable? = null) : RestoreOutcome) and run a project-wide
refactor to fix imports/usages to the new symbols.

In `@presentation/src/main/res/values/strings.xml`:
- Around line 96-100: Update the two string resources so their format specifiers
are positional: replace the non-positional "%s" with "%1$s" in
screen_license_check_trial_expiration and screen_license_check_trial_active_info
so translations remain unambiguous and lint warnings are resolved.

In
`@presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt`:
- Around line 44-69: The onSettled function reads shared variables (inappChange,
subsChange, failure and later calls licenseEnforcer.hasWriteAccess() to compute
hadWriteAccessAfter) without holding the existing lock; wrap the reading of
these aggregated states and the computation of hadWriteAccessAfter in
synchronized(lock) to mirror the write-side protection: e.g. inside onSettled
acquire synchronized(lock) to copy localInapp/localSubs/localFailure and
hadWriteAccessAfter (or compute hadWriteAccessAfter while holding the lock),
then release the lock and continue the rest of the logic (including
sharedPreferencesHandler.setPurchaseRevokedState and complete) using those local
copies so all reads are protected by the same lock used for writes.

In
`@presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseManagerTest.kt`:
- Around line 17-254: Add a test that asserts acknowledgePurchase is NOT invoked
when a purchase already has isAcknowledged == true: create a purchase via
mockPurchase(ProductInfo.PRODUCT_FULL_VERSION, Purchase.PurchaseState.PURCHASED,
"token-1", isAcknowledged = true), call
purchaseManager.handleInAppPurchases(listOf(purchase), acknowledgePurchase =
acknowledgePurchase) and assert acknowledgedTokens remains empty (or verify no
acknowledgment happened); place it alongside the existing `handleInAppPurchases
acknowledges unacknowledged purchase` test to cover the negative case.

In `@util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt`:
- Around line 178-192: Reformat the indentation in onSharedPreferenceChanged so
the function signature, the when { ... } block, and all case branches use
consistent indentation with the rest of the class; specifically align the
onSharedPreferenceChanged declaration and the when keyword, make the
LOCK_TIMEOUT -> branch body (including the local val lockTimeout and
lockTimeoutChangedListeners.keys.forEach { listener ->
listener.accept(lockTimeout) }) use the same indent level as the LICENSE_TOKEN,
TRIAL_EXPIRATION_DATE, HAS_RUNNING_SUBSCRIPTION -> branch, and ensure the
licenseChangedListeners.keys.forEach { listener ->
listener.accept(licenseToken()) } block has matching brace alignment; keep
existing logic (references: onSharedPreferenceChanged, LOCK_TIMEOUT,
lockTimeout, lockTimeoutChangedListeners, LICENSE_TOKEN, TRIAL_EXPIRATION_DATE,
HAS_RUNNING_SUBSCRIPTION, licenseChangedListeners, licenseToken()) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 54f43bd2-18db-4ed7-a70e-8641617d4f1a

📥 Commits

Reviewing files that changed from the base of the PR and between 2d06892 and 5fcc334.

📒 Files selected for processing (24)
  • presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt
  • presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedReason.kt
  • presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedToastObserver.kt
  • presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcome.kt
  • presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcomeHandler.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/dialog/NoFullVersionDialog.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/dialog/RestoreFailedDialog.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/dialog/RestoreSuccessfulDialog.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt
  • presentation/src/main/res/layout/dialog_no_full_version.xml
  • presentation/src/main/res/layout/dialog_restore_failed.xml
  • presentation/src/main/res/layout/dialog_restore_successful.xml
  • presentation/src/main/res/values/strings.xml
  • presentation/src/nonplaystoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt
  • presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt
  • presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseFieldChange.kt
  • presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseManager.kt
  • presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt
  • presentation/src/test/java/org/cryptomator/presentation/service/PurchaseRevokedToastObserverTest.kt
  • presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseManagerTest.kt
  • presentation/src/testPlaystoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinatorTest.kt
  • util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt
✅ Files skipped from review due to trivial changes (4)
  • presentation/src/main/res/layout/dialog_restore_successful.xml
  • presentation/src/main/res/layout/dialog_no_full_version.xml
  • presentation/src/main/res/layout/dialog_restore_failed.xml
  • presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseFieldChange.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • presentation/src/nonplaystoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
presentation/src/main/res/values/strings.xml (1)

730-730: Mark this string alias as non-translatable.

This mirrors alias usage elsewhere in the file and keeps the localized placeholder owned by screen_license_check_trial_active_info.

🌐 Proposed resource cleanup
-	<string name="screen_settings_license_summary_trial_expires">@string/screen_license_check_trial_active_info</string>
+	<string name="screen_settings_license_summary_trial_expires" translatable="false">@string/screen_license_check_trial_active_info</string>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@presentation/src/main/res/values/strings.xml` at line 730, The string
resource screen_settings_license_summary_trial_expires is an alias and should be
marked non-translatable; update the <string
name="screen_settings_license_summary_trial_expires"> element so it includes
translatable="false" and still references
`@string/screen_license_check_trial_active_info`, mirroring other alias entries so
the localized content remains owned by screen_license_check_trial_active_info.
presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt (1)

69-84: Clean up the empty TextWatcher callbacks.

Detekt flags Lines 70-71. Expression-bodied no-ops keep the required interface methods without empty blocks.

♻️ Proposed cleanup
 		binding.licenseContent.etLicense.addTextChangedListener(object : TextWatcher {
-			override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
-			override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
+			override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+			override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
 			override fun afterTextChanged(s: Editable?) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`
around lines 69 - 84, Replace the empty override blocks in the anonymous
TextWatcher on binding.licenseContent.etLicense by using expression-bodied no-op
implementations for beforeTextChanged and onTextChanged to satisfy Detekt; keep
the existing afterTextChanged implementation intact (which handles
debounceRunnable, debounceHandler.postDelayed,
listener?.onLicenseTextChanged(text), and DEBOUNCE_DELAY_MS) so only the two
empty methods are condensed to expression bodies while preserving the debounce
logic and references.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`:
- Around line 63-68: The current setupLicenseEntryUi calls
licenseContentViewBinder.bindInitialLicenseEntryWithTrialLayout(), which hides
purchaseOptionsGroup and therefore also hides btnTrial/rowTrial (they live under
that group), so the trial CTA never becomes visible; fix by ensuring the trial
row is explicitly shown after binding or by moving the visibility logic into
LicenseContentViewBinder: update
LicenseContentViewBinder.bindInitialLicenseEntryWithTrialLayout (or add a new
method) to set rowTrial and/or btnTrial visibility to VISIBLE while keeping
other purchase rows hidden, or in setupLicenseEntryUi call
binding.licenseContent.rowTrial.visibility = View.VISIBLE (and keep
binding.licenseContent.btnPurchase/GROUP hidden) so the trial button is actually
displayed and functional.

In `@presentation/src/main/res/values/strings.xml`:
- Line 557: The string resource dialog_enter_license_content is worded
inconsistently; replace the mixed passive/imperative phrasing with a single,
clear sentence—e.g., use an imperative or a declarative form such as "Purchase a
license at https://cryptomator.org/android/ or enter your existing Android
License Key"—by updating the value of string name="dialog_enter_license_content"
accordingly.

---

Nitpick comments:
In
`@presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`:
- Around line 69-84: Replace the empty override blocks in the anonymous
TextWatcher on binding.licenseContent.etLicense by using expression-bodied no-op
implementations for beforeTextChanged and onTextChanged to satisfy Detekt; keep
the existing afterTextChanged implementation intact (which handles
debounceRunnable, debounceHandler.postDelayed,
listener?.onLicenseTextChanged(text), and DEBOUNCE_DELAY_MS) so only the two
empty methods are condensed to expression bodies while preserving the debounce
logic and references.

In `@presentation/src/main/res/values/strings.xml`:
- Line 730: The string resource screen_settings_license_summary_trial_expires is
an alias and should be marked non-translatable; update the <string
name="screen_settings_license_summary_trial_expires"> element so it includes
translatable="false" and still references
`@string/screen_license_check_trial_active_info`, mirroring other alias entries so
the localized content remains owned by screen_license_check_trial_active_info.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6c17207e-e7fb-4cdb-8a18-ebde42efef82

📥 Commits

Reviewing files that changed from the base of the PR and between 5fcc334 and 92781b9.

📒 Files selected for processing (9)
  • buildsystem/dependencies.gradle
  • presentation/build.gradle
  • presentation/src/androidTestApkstore/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivityTrialScopeGuardTest.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt
  • presentation/src/main/res/layout/view_license_check_content.xml
  • presentation/src/main/res/values/strings.xml
  • presentation/src/test/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinderTest.kt
✅ Files skipped from review due to trivial changes (2)
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt
  • presentation/src/main/res/layout/view_license_check_content.xml
🚧 Files skipped from review as they are similar to previous changes (1)
  • presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt

Comment thread presentation/src/main/res/values/strings.xml Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@presentation/build.gradle`:
- Around line 219-224: The addToFlavors invocation that configures googleFlavors
with dependencies.googleApiServicesDrive contains a redundant/misnamed exclude
entry ("exclude module: 'googlehttpclient'"); remove that line so only the
correct excludes remain (e.g., 'guava-jdk5', 'httpclient', and the correctly
named group/module "com.google.http-client":"google-http-client") in the
addToFlavors block to avoid a silent no-op and clarify intent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e9695757-c15c-4233-99ab-3c8c35af5d51

📥 Commits

Reviewing files that changed from the base of the PR and between 92781b9 and ef87f9a.

📒 Files selected for processing (2)
  • buildsystem/dependencies.gradle
  • presentation/build.gradle
✅ Files skipped from review due to trivial changes (1)
  • buildsystem/dependencies.gradle

Comment thread presentation/build.gradle
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt (2)

6-7: Use doAfterTextChanged to avoid empty TextWatcher methods.

Lines 72–73 trigger the empty-blocks.EmptyFunctionBlock detekt rule. Since androidx.core:core-ktx is already a project dependency, replace the TextWatcher anonymous class with a single doAfterTextChanged callback to remove the empty implementations and satisfy detekt.

♻️ Proposed cleanup
-import android.text.Editable
-import android.text.TextWatcher
+import androidx.core.widget.doAfterTextChanged
@@
-		binding.licenseContent.etLicense.addTextChangedListener(object : TextWatcher {
-			override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
-			override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
-			override fun afterTextChanged(s: Editable?) {
-				debounceRunnable?.let { debounceHandler.removeCallbacks(it) }
-				debounceRunnable = null
-				val text = s?.toString()
-				if (!text.isNullOrBlank()) {
-					val runnable = Runnable { listener?.onLicenseTextChanged(text) }
-					debounceRunnable = runnable
-					debounceHandler.postDelayed(runnable, DEBOUNCE_DELAY_MS)
-				} else {
-					listener?.onLicenseTextChanged(null)
-				}
-			}
-		})
+		binding.licenseContent.etLicense.doAfterTextChanged { s ->
+			debounceRunnable?.let { debounceHandler.removeCallbacks(it) }
+			debounceRunnable = null
+			val text = s?.toString()
+			if (!text.isNullOrBlank()) {
+				val runnable = Runnable { listener?.onLicenseTextChanged(text) }
+				debounceRunnable = runnable
+				debounceHandler.postDelayed(runnable, DEBOUNCE_DELAY_MS)
+			} else {
+				listener?.onLicenseTextChanged(null)
+			}
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`
around lines 6 - 7, Replace the anonymous TextWatcher in WelcomeLicenseFragment
(the instance added to the license EditText) with the
androidx.core.widget.doAfterTextChanged extension: remove the
android.text.Editable and android.text.TextWatcher imports and the object :
TextWatcher { ... } block, then call licenseEditText.doAfterTextChanged { /*
existing on-change logic */ } to keep only the needed callback; import
androidx.core.widget.doAfterTextChanged if necessary.

19-23: Remove the unused onSkipLicense() callback from the listener contract.

onSkipLicense() is declared in the Listener interface and overridden in WelcomeActivity, but this fragment never invokes it. The layout contains no skip button or skip-related UI element to trigger this callback. Either wire the callback to a UI element or remove the unused contract member.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`
around lines 19 - 23, The Listener interface declares an unused onSkipLicense()
method; remove that method from the Listener interface in WelcomeLicenseFragment
(delete the line with fun onSkipLicense()) and then remove the corresponding
override/implementation in WelcomeActivity (and any other classes implementing
this Listener) so the contract and implementations stay consistent; ensure no
leftover references or imports remain and run a quick build to catch remaining
usages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`:
- Around line 6-7: Replace the anonymous TextWatcher in WelcomeLicenseFragment
(the instance added to the license EditText) with the
androidx.core.widget.doAfterTextChanged extension: remove the
android.text.Editable and android.text.TextWatcher imports and the object :
TextWatcher { ... } block, then call licenseEditText.doAfterTextChanged { /*
existing on-change logic */ } to keep only the needed callback; import
androidx.core.widget.doAfterTextChanged if necessary.
- Around line 19-23: The Listener interface declares an unused onSkipLicense()
method; remove that method from the Listener interface in WelcomeLicenseFragment
(delete the line with fun onSkipLicense()) and then remove the corresponding
override/implementation in WelcomeActivity (and any other classes implementing
this Listener) so the contract and implementations stay consistent; ensure no
leftover references or imports remain and run a quick build to catch remaining
usages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aa1f3e40-4798-4515-9845-e71548c8cdad

📥 Commits

Reviewing files that changed from the base of the PR and between ef87f9a and d4969ae.

📒 Files selected for processing (1)
  • presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt

coderabbitai bot added a commit that referenced this pull request Apr 19, 2026
Docstrings generation was requested by @tobihagemann.

* #630 (comment)

The following files were modified:

* `data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java`
* `data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt`
* `data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java`
* `data/src/main/java/org/cryptomator/data/repository/HubRepositoryImpl.java`
* `data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java`
* `domain/src/main/java/org/cryptomator/domain/Vault.java`
* `domain/src/main/java/org/cryptomator/domain/repository/HubRepository.kt`
* `domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java`
* `domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java`
* `domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockHubVault.java`
* `presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt`
* `presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java`
* `presentation/src/main/java/org/cryptomator/presentation/intent/LicenseCheckIntent.java`
* `presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java`
* `presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt`
* `presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseStateOrchestrator.kt`
* `presentation/src/main/java/org/cryptomator/presentation/presenter/BaseLicensePresenter.kt`
* `presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt`
* `presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt`
* `presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt`
* `presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt`
* `presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt`
* `presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt`
* `presentation/src/main/java/org/cryptomator/presentation/presenter/WelcomePresenter.kt`
* `presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt`
* `presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedReason.kt`
* `presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedToastObserver.kt`
* `presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcomeHandler.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WelcomeView.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/dialog/NoFullVersionDialog.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/dialog/RestoreFailedDialog.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/dialog/RestoreSuccessfulDialog.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/dialog/TrialExpiredDialog.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SharedFilesFragment.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeIntroFragment.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeNotificationsFragment.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeScreenLockFragment.kt`
* `presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt`
* `presentation/src/nonplaystoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt`
* `presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt`
* `presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseManager.kt`
* `presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt`
* `util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt`
@cryptomator cryptomator deleted a comment from coderabbitai bot Apr 19, 2026
@cryptomator cryptomator deleted a comment from coderabbitai bot Apr 19, 2026
@cryptomator cryptomator deleted a comment from coderabbitai bot Apr 19, 2026
@cryptomator cryptomator deleted a comment from coderabbitai bot Apr 19, 2026
@cryptomator cryptomator deleted a comment from coderabbitai bot Apr 19, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt (1)

71-86: Clean up the detekt EmptyFunctionBlock warnings.

The no-op TextWatcher callbacks on lines 72-73 are valid Android boilerplate that detekt flags. Since AndroidX Core KTX is already available in the presentation module, prefer refactoring to doAfterTextChanged to eliminate the warnings.

♻️ Refactor using doAfterTextChanged
+import androidx.core.widget.doAfterTextChanged
 import android.content.Context
 import android.os.Handler
 import android.os.Looper
-import android.text.Editable
-import android.text.TextWatcher
 import android.view.View
-		binding.licenseContent.etLicense.addTextChangedListener(object : TextWatcher {
-			override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
-			override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
-			override fun afterTextChanged(s: Editable?) {
-				debounceRunnable?.let { debounceHandler.removeCallbacks(it) }
-				debounceRunnable = null
-				val text = s?.toString()
-				if (!text.isNullOrBlank()) {
-					val runnable = Runnable { listener?.onLicenseTextChanged(text) }
-					debounceRunnable = runnable
-					debounceHandler.postDelayed(runnable, DEBOUNCE_DELAY_MS)
-				} else {
-					listener?.onLicenseTextChanged(null)
-				}
-			}
-		})
+		binding.licenseContent.etLicense.doAfterTextChanged { editable ->
+			debounceRunnable?.let { debounceHandler.removeCallbacks(it) }
+			debounceRunnable = null
+			val text = editable?.toString()
+			if (!text.isNullOrBlank()) {
+				val runnable = Runnable { listener?.onLicenseTextChanged(text) }
+				debounceRunnable = runnable
+				debounceHandler.postDelayed(runnable, DEBOUNCE_DELAY_MS)
+			} else {
+				listener?.onLicenseTextChanged(null)
+			}
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`
around lines 71 - 86, Replace the anonymous TextWatcher on
binding.licenseContent.etLicense with the AndroidX doAfterTextChanged extension
to remove empty callback methods: remove the object : TextWatcher { ... } block
and use etLicense.doAfterTextChanged { s -> ... } keeping the existing debounce
logic (debounceRunnable, debounceHandler, DEBOUNCE_DELAY_MS) and the
listener?.onLicenseTextChanged(...) calls unchanged so behavior is identical
while eliminating the EmptyFunctionBlock detekt warnings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt`:
- Around line 71-86: Replace the anonymous TextWatcher on
binding.licenseContent.etLicense with the AndroidX doAfterTextChanged extension
to remove empty callback methods: remove the object : TextWatcher { ... } block
and use etLicense.doAfterTextChanged { s -> ... } keeping the existing debounce
logic (debounceRunnable, debounceHandler, DEBOUNCE_DELAY_MS) and the
listener?.onLicenseTextChanged(...) calls unchanged so behavior is identical
while eliminating the EmptyFunctionBlock detekt warnings.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fcb43829-1e38-40fa-96b4-e15bc06bd4fc

📥 Commits

Reviewing files that changed from the base of the PR and between ef87f9a and d4969ae.

📒 Files selected for processing (1)
  • presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt (1)

265-267: Guard external browser launch against ActivityNotFoundException.

On devices without a browser/handler for ACTION_VIEW https URIs (stripped AOSP, managed profiles, kiosk setups), this call will crash the onboarding flow. Consider wrapping in try/catch and surfacing a toast or falling back gracefully.

🛡️ Proposed fix
 	override fun onOpenLicenseLink() {
-		startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://cryptomator.org/android/")))
+		try {
+			startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://cryptomator.org/android/")))
+		} catch (e: android.content.ActivityNotFoundException) {
+			Toast.makeText(this, R.string.error_no_browser_found, Toast.LENGTH_SHORT).show()
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt`
around lines 265 - 267, The onOpenLicenseLink method currently calls
startActivity with an ACTION_VIEW https Uri which can throw
ActivityNotFoundException on devices without a browser; wrap the startActivity
call in a try/catch for ActivityNotFoundException inside onOpenLicenseLink, and
in the catch show a user-friendly fallback (e.g., Toast.makeText(...) or a
dialog) or silently handle it so the onboarding flow doesn't crash; keep using
Intent.ACTION_VIEW and Uri.parse("https://cryptomator.org/android/") but ensure
the exception handling prevents the crash and provides graceful feedback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt`:
- Around line 265-267: The onOpenLicenseLink method currently calls
startActivity with an ACTION_VIEW https Uri which can throw
ActivityNotFoundException on devices without a browser; wrap the startActivity
call in a try/catch for ActivityNotFoundException inside onOpenLicenseLink, and
in the catch show a user-friendly fallback (e.g., Toast.makeText(...) or a
dialog) or silently handle it so the onboarding flow doesn't crash; keep using
Intent.ACTION_VIEW and Uri.parse("https://cryptomator.org/android/") but ensure
the exception handling prevents the crash and provides graceful feedback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 820d9fd3-7e0f-4a1a-a8b1-c904082d0645

📥 Commits

Reviewing files that changed from the base of the PR and between 9f1effd and 7a8bf2a.

📒 Files selected for processing (2)
  • presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt
  • presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
presentation/build.gradle (1)

70-85: ⚠️ Potential issue | 🟡 Minor

The removal of applicationIdSuffix ".debug" prevents debug and release variants from being installed side‑by‑side on the same device.

No other code in the codebase depends on the .debug suffix, and the change is isolated, suggesting the removal is intentional. If this is required for IAP/Play Store testing (which typically requires production applicationIds), clarify the intent in the PR description or a code comment; otherwise, consider reinstating the suffix to preserve the standard development workflow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@presentation/build.gradle` around lines 70 - 85, The debug build no longer
sets applicationIdSuffix ".debug", which prevents installing debug and release
variants side‑by‑side; restore applicationIdSuffix ".debug" inside the debug
block (the debug build config in presentation/build.gradle) so the debug variant
can be installed alongside release, or if removal was intentional for Play/IAP
testing add a clear PR description or a code comment in the debug { ... } block
explaining why applicationIdSuffix was removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@presentation/src/main/res/values/strings.xml`:
- Line 718: The string resource entry named
"screen_settings_license_summary_trial_expires" uses a resource reference value
and should be marked translatable="false"; update the <string
name="screen_settings_license_summary_trial_expires"> declaration to include
translatable="false" so translators won't attempt to localize the literal
"@string/screen_license_check_trial_active_info" reference.

---

Outside diff comments:
In `@presentation/build.gradle`:
- Around line 70-85: The debug build no longer sets applicationIdSuffix
".debug", which prevents installing debug and release variants side‑by‑side;
restore applicationIdSuffix ".debug" inside the debug block (the debug build
config in presentation/build.gradle) so the debug variant can be installed
alongside release, or if removal was intentional for Play/IAP testing add a
clear PR description or a code comment in the debug { ... } block explaining why
applicationIdSuffix was removed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4e067037-99bd-4126-aa3a-afdd6e709381

📥 Commits

Reviewing files that changed from the base of the PR and between 7a8bf2a and 0f5ec1b.

📒 Files selected for processing (4)
  • data/build.gradle
  • presentation/build.gradle
  • presentation/src/main/res/values/strings.xml
  • presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt

<string name="screen_settings_license_summary_write_access">Write access unlocked!</string>
<string name="screen_settings_license_summary_tap_to_unlock">Tap to unlock write access</string>
<string name="screen_settings_license_summary_write_access_mail">Write access for %1$s</string>
<string name="screen_settings_license_summary_trial_expires">@string/screen_license_check_trial_active_info</string>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mark string-reference value as translatable="false".

The value is a resource reference (@string/screen_license_check_trial_active_info), not user-facing text. Consistent with every other reference-only entry in this file (e.g., line 101), it should be translatable="false" so translators don't try to localize the literal @string/... token.

✏️ Proposed fix
-	<string name="screen_settings_license_summary_trial_expires">@string/screen_license_check_trial_active_info</string>
+	<string name="screen_settings_license_summary_trial_expires" translatable="false">@string/screen_license_check_trial_active_info</string>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<string name="screen_settings_license_summary_trial_expires">@string/screen_license_check_trial_active_info</string>
<string name="screen_settings_license_summary_trial_expires" translatable="false">@string/screen_license_check_trial_active_info</string>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@presentation/src/main/res/values/strings.xml` at line 718, The string
resource entry named "screen_settings_license_summary_trial_expires" uses a
resource reference value and should be marked translatable="false"; update the
<string name="screen_settings_license_summary_trial_expires"> declaration to
include translatable="false" so translators won't attempt to localize the
literal "@string/screen_license_check_trial_active_info" reference.

Inline observeTrialExpiry latch into evaluateTrialState, route
hasActiveTrial and evaluateUiState through the single computation.
Halves prefs reads on write-gate checks.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt (1)

28-492: LGTM — thorough coverage of the new consolidation.

Good coverage of the sticky-latch invariants (one-shot write on first past-due observation, idempotence afterwards, no reset on startTrial), hub-vault fallback paths, and UI state composition. The assumeTrue(!FlavorConfig.isPremiumFlavor, …) usage correctly skips negative paths on premium builds.

Optional nit: the 86400000L literal (1 day in ms) appears ~10 times; extracting a private companion val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) would improve readability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt`
around lines 28 - 492, The tests in LicenseEnforcerTest repeat the 86400000L
(one day ms) literal; replace these occurrences by adding a private companion
val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) in the LicenseEnforcerTest class and
update all uses (e.g., in tests referencing System.currentTimeMillis() +/-
86400000L and comparisons in startTrial/evaluateUiState/hasActiveTrial tests) to
use ONE_DAY_MS for clarity and consistency.
presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt (1)

109-124: Nit: FlavorConfig.isPremiumFlavor branch at Line 114 is effectively unreachable.

hasPaidLicense() already returns true unconditionally for premium flavors, so hasWriteAccess() at Line 110 would have returned early. The guard is harmless as defense-in-depth, but you could drop it to reduce noise — or keep it explicitly as a safety net with a short comment noting intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt`
around lines 109 - 124, The check for FlavorConfig.isPremiumFlavor inside
ensureWriteAccess is redundant because hasWriteAccess already returns true for
premium builds via hasPaidLicense; remove the if (FlavorConfig.isPremiumFlavor)
{ return false } branch from ensureWriteAccess (or alternatively replace it with
a brief comment if you prefer a defensive guard), leaving ensureWriteAccess,
hasWriteAccess and hasPaidLicense as the single source of truth for premium
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt`:
- Around line 109-124: The check for FlavorConfig.isPremiumFlavor inside
ensureWriteAccess is redundant because hasWriteAccess already returns true for
premium builds via hasPaidLicense; remove the if (FlavorConfig.isPremiumFlavor)
{ return false } branch from ensureWriteAccess (or alternatively replace it with
a brief comment if you prefer a defensive guard), leaving ensureWriteAccess,
hasWriteAccess and hasPaidLicense as the single source of truth for premium
behavior.

In
`@presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt`:
- Around line 28-492: The tests in LicenseEnforcerTest repeat the 86400000L (one
day ms) literal; replace these occurrences by adding a private companion val
ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) in the LicenseEnforcerTest class and
update all uses (e.g., in tests referencing System.currentTimeMillis() +/-
86400000L and comparisons in startTrial/evaluateUiState/hasActiveTrial tests) to
use ONE_DAY_MS for clarity and consistency.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8e95c9f1-d6af-482d-8e72-660675e760fb

📥 Commits

Reviewing files that changed from the base of the PR and between 0f5ec1b and 4a87c46.

📒 Files selected for processing (2)
  • presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt
  • presentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants