Skip to content

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

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

Add freemium model with 30-day trial, Google Play IAP, and write-access gating#630
tobihagemann wants to merge 58 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
Member

@SailReal SailReal left a comment

Choose a reason for hiding this comment

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

In the license key variant, we do not have the option to activate a trial

Image

Copy link
Copy Markdown
Member

@SailReal SailReal left a comment

Choose a reason for hiding this comment

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

As we now show an activity when writing is prohibited, we can remove the toast because the toast does not add any info

Image

Copy link
Copy Markdown
Member

@SailReal SailReal left a comment

Choose a reason for hiding this comment

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

This should not be shown when screen lock is set

Image

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 on lines +110 to +112
<string name="screen_settings_license_unlock_prompt">Unlock write access</string>
<string name="screen_settings_license_unlock_summary">Tap to enter your license key.</string>
<string name="screen_settings_license_unlocked_iap">Write access is unlocked</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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether the three strings at lines 110-112 are referenced anywhere in code or XML.
for key in screen_settings_license_unlock_prompt screen_settings_license_unlock_summary screen_settings_license_unlocked_iap; do
  echo "=== $key ==="
  rg -n --type kotlin --type java --type xml "R\.string\.$key|@string/$key|\"$key\"" || echo "  (no references found)"
done

Repository: cryptomator/android

Length of output: 612


Remove unused license strings at lines 110-112.

These three strings have no references anywhere in the codebase and appear to be leftovers from an earlier refactor iteration. Shipping orphaned strings increases translation maintenance burden unnecessarily. Remove:

  • screen_settings_license_unlock_prompt (line 110)
  • screen_settings_license_unlock_summary (line 111)
  • screen_settings_license_unlocked_iap (line 112)
🤖 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 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.

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

@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

Comment on lines +40 to +76
private fun initBillingClient(context: Context) {
this.sharedPreferencesHandler = SharedPreferencesHandler(context)
this.purchaseManager = PurchaseManager(sharedPreferencesHandler)
this.purchaseRefreshCoordinator = PurchaseRefreshCoordinator(sharedPreferencesHandler)
val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.enablePrepaidPlans()
.build()
billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases(pendingPurchasesParams)
.enableAutoServiceReconnection()
.build()
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Timber.tag("IapBillingService").d("Billing setup successful")
queryExistingPurchases()
synchronized(pendingProductDetailsCallbacks) {
if (pendingProductDetailsCallbacks.isNotEmpty()) {
val callbacks = ArrayList(pendingProductDetailsCallbacks)
pendingProductDetailsCallbacks.clear()
queryProductDetails { products ->
callbacks.forEach { it(products) }
}
}
}
} else {
Timber.tag("IapBillingService").e("Billing setup not successful, error: %d", billingResult.responseCode)
}
}

override fun onBillingServiceDisconnected() {
Timber.tag("IapBillingService").i("Billing service disconnected")
}
})
}
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

initBillingClient is not idempotent — risks leaking BillingClient instances.

Binder.init(context) can be invoked more than once across the Service lifetime (e.g. multiple bindService calls from different activities, or re-binds after a process-lifecycle event). Each call overwrites billingClient, sharedPreferencesHandler, purchaseManager, and purchaseRefreshCoordinator without ending the previous BillingClient, leaking its connection and any in-flight callbacks. It also re-registers pending-purchase listeners on a stale client. Consider short-circuiting when already initialized, or tearing the existing client down first.

🛡️ Suggested guard
 private fun initBillingClient(context: Context) {
+	if (::billingClient.isInitialized) {
+		return
+	}
 	this.sharedPreferencesHandler = SharedPreferencesHandler(context)
📝 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
private fun initBillingClient(context: Context) {
this.sharedPreferencesHandler = SharedPreferencesHandler(context)
this.purchaseManager = PurchaseManager(sharedPreferencesHandler)
this.purchaseRefreshCoordinator = PurchaseRefreshCoordinator(sharedPreferencesHandler)
val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.enablePrepaidPlans()
.build()
billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases(pendingPurchasesParams)
.enableAutoServiceReconnection()
.build()
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Timber.tag("IapBillingService").d("Billing setup successful")
queryExistingPurchases()
synchronized(pendingProductDetailsCallbacks) {
if (pendingProductDetailsCallbacks.isNotEmpty()) {
val callbacks = ArrayList(pendingProductDetailsCallbacks)
pendingProductDetailsCallbacks.clear()
queryProductDetails { products ->
callbacks.forEach { it(products) }
}
}
}
} else {
Timber.tag("IapBillingService").e("Billing setup not successful, error: %d", billingResult.responseCode)
}
}
override fun onBillingServiceDisconnected() {
Timber.tag("IapBillingService").i("Billing service disconnected")
}
})
}
private fun initBillingClient(context: Context) {
if (::billingClient.isInitialized) {
return
}
this.sharedPreferencesHandler = SharedPreferencesHandler(context)
this.purchaseManager = PurchaseManager(sharedPreferencesHandler)
this.purchaseRefreshCoordinator = PurchaseRefreshCoordinator(sharedPreferencesHandler)
val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.enablePrepaidPlans()
.build()
billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases(pendingPurchasesParams)
.enableAutoServiceReconnection()
.build()
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Timber.tag("IapBillingService").d("Billing setup successful")
queryExistingPurchases()
synchronized(pendingProductDetailsCallbacks) {
if (pendingProductDetailsCallbacks.isNotEmpty()) {
val callbacks = ArrayList(pendingProductDetailsCallbacks)
pendingProductDetailsCallbacks.clear()
queryProductDetails { products ->
callbacks.forEach { it(products) }
}
}
}
} else {
Timber.tag("IapBillingService").e("Billing setup not successful, error: %d", billingResult.responseCode)
}
}
override fun onBillingServiceDisconnected() {
Timber.tag("IapBillingService").i("Billing service disconnected")
}
})
}
🤖 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/IapBillingService.kt`
around lines 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.

Comment on lines +54 to +70
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Timber.tag("IapBillingService").d("Billing setup successful")
queryExistingPurchases()
synchronized(pendingProductDetailsCallbacks) {
if (pendingProductDetailsCallbacks.isNotEmpty()) {
val callbacks = ArrayList(pendingProductDetailsCallbacks)
pendingProductDetailsCallbacks.clear()
queryProductDetails { products ->
callbacks.forEach { it(products) }
}
}
}
} else {
Timber.tag("IapBillingService").e("Billing setup not successful, error: %d", billingResult.responseCode)
}
}
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

Pending product-details callbacks are never drained on billing-setup failure.

If onBillingSetupFinished returns a non-OK response, pendingProductDetailsCallbacks are left queued indefinitely, so any UI waiting for prices (e.g. LicenseContentViewBinder.loadAndBindPrices) silently hangs and the subscription/lifetime buttons stay disabled with no feedback. Auto-reconnection mitigates transient failures, but terminal failures (billing unavailable, feature not supported) never recover. Consider either invoking queued callbacks with an empty list on non-OK, or propagating an error signal so the UI can surface "unavailable".

Also applies to: 118-124

🤖 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/IapBillingService.kt`
around lines 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.

Comment on lines +82 to +107
val inappParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
billingClient.queryPurchasesAsync(inappParams) { billingResult: BillingResult, purchases: List<Purchase> ->
synchronized(lock) {
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
failure = Throwable("Query failed with code: ${billingResult.responseCode}")
} else {
inappChange = purchaseManager.handleInAppPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
}
}
onQueryComplete()
}
val subsParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
billingClient.queryPurchasesAsync(subsParams) { billingResult: BillingResult, purchases: List<Purchase> ->
synchronized(lock) {
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
failure = Throwable("Query failed with code: ${billingResult.responseCode}")
} else {
subsChange = purchaseManager.handleSubscriptionPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
}
}
onQueryComplete()
}
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 | 🟠 Major

onComplete can fail to fire if an async callback throws.

The outer try/catch at lines 31/108 only guards setup and the initial queryPurchasesAsync invocations — it cannot catch exceptions thrown later on the Billing callback thread. If purchaseManager.handleInAppPurchases(...) / handleSubscriptionPurchases(...) or the provided acknowledge callback throws inside either response lambda, onQueryComplete() is skipped, queriesCompleted never reaches totalQueries, onSettled() never runs, and onComplete is never invoked — the restore hangs indefinitely from the caller's perspective (e.g. a spinner in the UI).

Wrap each callback body defensively so failures still drive onQueryComplete():

🛡️ Suggested fix
 			billingClient.queryPurchasesAsync(inappParams) { billingResult: BillingResult, purchases: List<Purchase> ->
-				synchronized(lock) {
-					if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
-						failure = Throwable("Query failed with code: ${billingResult.responseCode}")
-					} else {
-						inappChange = purchaseManager.handleInAppPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
-					}
-				}
-				onQueryComplete()
+				try {
+					synchronized(lock) {
+						if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
+							failure = Throwable("Query failed with code: ${billingResult.responseCode}")
+						} else {
+							inappChange = purchaseManager.handleInAppPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
+						}
+					}
+				} catch (t: Throwable) {
+					Timber.tag("PurchaseRefreshCoordinator").e(t, "INAPP callback failed")
+					synchronized(lock) { failure = t }
+				} finally {
+					onQueryComplete()
+				}
 			}
@@
 			billingClient.queryPurchasesAsync(subsParams) { billingResult: BillingResult, purchases: List<Purchase> ->
-				synchronized(lock) {
-					if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
-						failure = Throwable("Query failed with code: ${billingResult.responseCode}")
-					} else {
-						subsChange = purchaseManager.handleSubscriptionPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
-					}
-				}
-				onQueryComplete()
+				try {
+					synchronized(lock) {
+						if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
+							failure = Throwable("Query failed with code: ${billingResult.responseCode}")
+						} else {
+							subsChange = purchaseManager.handleSubscriptionPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
+						}
+					}
+				} catch (t: Throwable) {
+					Timber.tag("PurchaseRefreshCoordinator").e(t, "SUBS callback failed")
+					synchronized(lock) { failure = t }
+				} finally {
+					onQueryComplete()
+				}
 			}
📝 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
val inappParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
billingClient.queryPurchasesAsync(inappParams) { billingResult: BillingResult, purchases: List<Purchase> ->
synchronized(lock) {
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
failure = Throwable("Query failed with code: ${billingResult.responseCode}")
} else {
inappChange = purchaseManager.handleInAppPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
}
}
onQueryComplete()
}
val subsParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
billingClient.queryPurchasesAsync(subsParams) { billingResult: BillingResult, purchases: List<Purchase> ->
synchronized(lock) {
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
failure = Throwable("Query failed with code: ${billingResult.responseCode}")
} else {
subsChange = purchaseManager.handleSubscriptionPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
}
}
onQueryComplete()
}
val inappParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
billingClient.queryPurchasesAsync(inappParams) { billingResult: BillingResult, purchases: List<Purchase> ->
try {
synchronized(lock) {
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
failure = Throwable("Query failed with code: ${billingResult.responseCode}")
} else {
inappChange = purchaseManager.handleInAppPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
}
}
} catch (t: Throwable) {
Timber.tag("PurchaseRefreshCoordinator").e(t, "INAPP callback failed")
synchronized(lock) { failure = t }
} finally {
onQueryComplete()
}
}
val subsParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
billingClient.queryPurchasesAsync(subsParams) { billingResult: BillingResult, purchases: List<Purchase> ->
try {
synchronized(lock) {
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
failure = Throwable("Query failed with code: ${billingResult.responseCode}")
} else {
subsChange = purchaseManager.handleSubscriptionPurchases(purchases, clearIfNotFound = true, acknowledgePurchase = acknowledge)
}
}
} catch (t: Throwable) {
Timber.tag("PurchaseRefreshCoordinator").e(t, "SUBS callback failed")
synchronized(lock) { failure = t }
} finally {
onQueryComplete()
}
}
🤖 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 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.

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 on lines +63 to +68
private fun setupLicenseEntryUi() {
licenseContentViewBinder.bindInitialLicenseEntryWithTrialLayout()
binding.licenseContent.btnTrial.text = getString(R.string.screen_welcome_trial_button)
binding.licenseContent.btnTrial.setOnClickListener { listener?.onStartTrial() }
binding.licenseContent.tvLicenseLink.setOnClickListener { listener?.onOpenLicenseLink() }
binding.licenseContent.btnPurchase.visibility = View.GONE
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 | 🟠 Major

Make the non-freemium trial CTA visible.

bindInitialLicenseEntryWithTrialLayout() hides purchaseOptionsGroup, but btnTrial/rowTrial live under that group in view_license_check_content.xml. As written, Lines 65-66 configure a trial button that remains hidden, so apkstore/fdroid/lite users cannot start the surfaced welcome trial from this screen.

Move the “license entry + trial row only” visibility into LicenseContentViewBinder, or explicitly show the trial row while keeping non-trial purchase rows hidden.

🤖 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 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.


<string name="dialog_enter_license_title">Provide a valid license</string>
<string name="dialog_enter_license_content">We detected that you installed Cryptomator without using Google Play Store. Provide a valid license, which can be purchased on <a href="https://cryptomator.org/android/">https://cryptomator.org/android/</a></string>
<string name="dialog_enter_license_content">A license can be purchased on <a href="https://cryptomator.org/android/">https://cryptomator.org/android/</a> or enter your existing Android License Key</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

Tighten the license-entry sentence.

Current wording mixes passive voice with an imperative: “A license can be purchased … or enter …”.

✏️ Proposed copy fix
-	<string name="dialog_enter_license_content">A license can be purchased on <a href="https://cryptomator.org/android/">https://cryptomator.org/android/</a> or enter your existing Android License Key</string>
+	<string name="dialog_enter_license_content">Purchase a license at <a href="https://cryptomator.org/android/">https://cryptomator.org/android/</a> or enter your existing Android license key.</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="dialog_enter_license_content">A license can be purchased on <a href="https://cryptomator.org/android/">https://cryptomator.org/android/</a> or enter your existing Android License Key</string>
<string name="dialog_enter_license_content">Purchase a license at <a href="https://cryptomator.org/android/">https://cryptomator.org/android/</a> or enter your existing Android license key.</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 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.

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
Comment on lines +219 to 224
addToFlavors(googleFlavors, dependencies.googleApiServicesDrive) {
exclude module: 'guava-jdk5'
exclude module: 'httpclient'
exclude module: "google-http-client"
exclude module: "jetified-google-http-client"
exclude module: 'googlehttpclient'
exclude group: "com.google.http-client", module: "google-http-client"
exclude group: "com.google.http-client", module: "jetified-google-http-client"
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm no transitive dep actually uses the bare 'googlehttpclient' artifact name,
# and that 'google-http-client' is the canonical module name referenced elsewhere.
rg -nP --type=gradle -C1 "googlehttpclient|google-http-client"

Repository: cryptomator/android

Length of output: 2898


Remove the redundant and misnamed exclude: 'googlehttpclient' is not a real module coordinate.

Line 222 contains exclude module: 'googlehttpclient' (without hyphens), but the actual module name is google-http-client with hyphens, which is already correctly excluded on line 223. The bare googlehttpclient exclude is a silent no-op and should be removed to clarify intent.

🧹 Proposed fix
 	addToFlavors(googleFlavors, dependencies.googleApiServicesDrive) {
 		exclude module: 'guava-jdk5'
 		exclude module: 'httpclient'
-		exclude module: 'googlehttpclient'
 		exclude group: "com.google.http-client", module: "google-http-client"
 	}
📝 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
addToFlavors(googleFlavors, dependencies.googleApiServicesDrive) {
exclude module: 'guava-jdk5'
exclude module: 'httpclient'
exclude module: "google-http-client"
exclude module: "jetified-google-http-client"
exclude module: 'googlehttpclient'
exclude group: "com.google.http-client", module: "google-http-client"
exclude group: "com.google.http-client", module: "jetified-google-http-client"
}
addToFlavors(googleFlavors, dependencies.googleApiServicesDrive) {
exclude module: 'guava-jdk5'
exclude module: 'httpclient'
exclude group: "com.google.http-client", module: "google-http-client"
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@presentation/build.gradle` around lines 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.

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

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