Add freemium model with 30-day trial, Google Play IAP, and write-access gating#630
Add freemium model with 30-day trial, Google Play IAP, and write-access gating#630
Conversation
…ial info, fix subscription state
…itles, rename section to Premium
…eContentViewBinder
…ove double enforcement
There was a problem hiding this comment.
🧹 Nitpick comments (1)
presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt (1)
71-86: Clean up the detektEmptyFunctionBlockwarnings.The no-op
TextWatchercallbacks on lines 72-73 are valid Android boilerplate that detekt flags. Since AndroidX Core KTX is already available in the presentation module, prefer refactoring todoAfterTextChangedto 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
📒 Files selected for processing (1)
presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt
There was a problem hiding this comment.
🧹 Nitpick comments (1)
presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt (1)
265-267: Guard external browser launch againstActivityNotFoundException.On devices without a browser/handler for
ACTION_VIEWhttps URIs (stripped AOSP, managed profiles, kiosk setups), this call will crash the onboarding flow. Consider wrapping intry/catchand 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
📒 Files selected for processing (2)
presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.ktpresentation/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
…radle excludes, prune unused strings
There was a problem hiding this comment.
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 | 🟡 MinorThe 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
.debugsuffix, 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
📒 Files selected for processing (4)
data/build.gradlepresentation/build.gradlepresentation/src/main/res/values/strings.xmlpresentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt
Inline observeTrialExpiry latch into evaluateTrialState, route hasActiveTrial and evaluateUiState through the single computation. Halves prefs reads on write-gate checks.
There was a problem hiding this comment.
🧹 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. TheassumeTrue(!FlavorConfig.isPremiumFlavor, …)usage correctly skips negative paths on premium builds.Optional nit: the
86400000Lliteral (1 day in ms) appears ~10 times; extracting aprivate 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.isPremiumFlavorbranch at Line 114 is effectively unreachable.
hasPaidLicense()already returnstrueunconditionally for premium flavors, sohasWriteAccess()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
📒 Files selected for processing (2)
presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.ktpresentation/src/test/java/org/cryptomator/presentation/licensing/LicenseEnforcerTest.kt
…licate IAP query boilerplate
Summary
Introduces a freemium distribution model via a new
playstoreiapbuild 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 behindLicenseEnforcerand redirect to a purchase screen when the trial expires.Key changes:
playstoreiapflavor across all modules, with a realIapBillingServicebacked by Google Play Billing Library 8.2.1. Other flavors get a no-op stub.LicenseEnforcerevaluates write access from three sources: paid license token, active subscription, or active trial. Once trial expiry is observed, a stickytrialExpiredflag latches inSharedPreferencesto block clock-rollback reactivation. Hub vaults use their own subscription status.PurchaseManagerhandles purchase verification, token persistence, and acknowledgement with TOCTOU-safe state transitions.LicenseStateOrchestratordrives reactive UI updates viaSharedPreferenceschange listeners, used by bothWelcomeActivityandLicenseCheckActivity.LicenseContentViewBinderextracts shared purchase/trial UI into a reusable binder for the onboarding welcome flow and the standalone IAP screen.apkstore/fdroid/litealongside license-key entry.LicenseCheckActivity(upsell) keeps its scope guard and never shows the trial row.WelcomeActivitywith 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.BrowseFilesPresenter,SharedFilesPresenter,VaultListPresenter, andTextEditorActivity, redirecting blocked write actions to the IAP screen with a locked-action header.Vaultmodel extended withhubSubscriptionState.LicenseEnforcerTestcovering trial lifecycle, paid license, subscription, Hub vaults, and flavor-based bypass. Additional tests forPurchaseManager,ProductInfo,DoLicenseCheck,UnlockHubVault, and vault list freemium behavior.LicenseCheckIntent,TextEditorIntent) for type-safe intent extras, replacing raw companion constants.wrap_contentfix 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)Test Plan
:domain:testDebugUnitTest,:presentation:testPlaystoreiapDebugUnitTestassemblePlaystoreDebug,assemblePlaystoreiapDebug,assembleApkstoreDebug,assembleFdroidDebug,assembleLiteDebugplaystoreiap: onboarding flow shows license step, trial starts, write ops workapkstore: welcome License page shows trial button alongside license entry; tapping starts trial and auto-advances