Skip to content

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

Merged
SailReal merged 69 commits intodevelopfrom
feature/freemium
Apr 23, 2026
Merged

Add freemium model with 30-day trial, Google Play IAP, and write-access gating#630
SailReal merged 69 commits intodevelopfrom
feature/freemium

Conversation

@tobihagemann
Copy link
Copy Markdown
Member

@tobihagemann tobihagemann commented Mar 31, 2026

Summary

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

Key changes:

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

State Machine

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

Test Plan

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

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

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

71-86: Clean up the detekt EmptyFunctionBlock warnings.

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

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

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

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

265-267: Guard external browser launch against ActivityNotFoundException.

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

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

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

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

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

70-85: ⚠️ Potential issue | 🟡 Minor

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

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

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

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

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

---

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

Comment thread presentation/src/main/res/values/strings.xml Outdated
Inline observeTrialExpiry latch into evaluateTrialState, route
hasActiveTrial and evaluateUiState through the single computation.
Halves prefs reads on write-gate checks.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

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

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

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

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

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

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

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

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

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

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

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

@SailReal SailReal merged commit d3c39aa into develop Apr 23, 2026
3 checks passed
@SailReal SailReal deleted the feature/freemium branch April 23, 2026 17:41
@SailReal SailReal mentioned this pull request Apr 23, 2026
2 tasks
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