diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java index 57cef31ac..e5f11198c 100644 --- a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java @@ -17,6 +17,28 @@ class DatabaseUpgrades { private final Map> availableUpgrades; + /** + * Creates the DatabaseUpgrades registry and registers the provided upgrade steps + * into the internal mapping keyed by each step's source version. + * + * Each constructor parameter is the implementation for the corresponding + * from->to version and will be added to the availableUpgrades map. + * + * @param upgrade0To1 upgrade step from version 0 to 1 + * @param upgrade1To2 upgrade step from version 1 to 2 + * @param upgrade2To3 upgrade step from version 2 to 3 + * @param upgrade3To4 upgrade step from version 3 to 4 + * @param upgrade4To5 upgrade step from version 4 to 5 + * @param upgrade5To6 upgrade step from version 5 to 6 + * @param upgrade6To7 upgrade step from version 6 to 7 + * @param upgrade7To8 upgrade step from version 7 to 8 + * @param upgrade8To9 upgrade step from version 8 to 9 + * @param upgrade9To10 upgrade step from version 9 to 10 + * @param upgrade10To11 upgrade step from version 10 to 11 + * @param upgrade11To12 upgrade step from version 11 to 12 + * @param upgrade12To13 upgrade step from version 12 to 13 + * @param upgrade13To14 upgrade step from version 13 to 14 + */ @Inject public DatabaseUpgrades( // Upgrade0To1 upgrade0To1, // @@ -52,6 +74,12 @@ public DatabaseUpgrades( // upgrade13To14); } + /** + * Builds a registry that groups provided upgrades by their source version. + * + * @param upgrades varargs of available DatabaseUpgrade instances to register + * @return a map from source version to the list of upgrades that start at that version; each list is sorted in descending (reverse natural) order + */ private Map> defineUpgrades(DatabaseUpgrade... upgrades) { Map> result = new HashMap<>(); for (DatabaseUpgrade upgrade : upgrades) { diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt b/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt index 85a18951c..94e9a2990 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt @@ -10,6 +10,17 @@ import timber.log.Timber @Singleton internal class Upgrade13To14 @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) : DatabaseUpgrade(13, 14) { + /** + * Applies the schema migration steps from version 13 to 14 on the provided database. + * + * If `origin > 0` (an existing installation), marks the welcome flow completed. For builds that + * require migrating a license token (non-premium flavor), reads an existing `LICENSE_TOKEN` from + * the database and saves it to shared preferences. In all cases, removes the `LICENSE_TOKEN` + * column/data from the database. + * + * @param db The database to migrate. + * @param origin The originating schema version; values greater than 0 indicate an existing install. + */ override fun internalApplyTo(db: Database, origin: Int) { if (origin > 0) { // Any user going through a schema migration is an existing user — skip welcome @@ -24,10 +35,23 @@ internal class Upgrade13To14 @Inject constructor(private val sharedPreferencesHa removeLicenseFromDb(db) } + /** + * Indicates whether the current build uses the premium flavor (i.e., does not store a license key in the database). + * + * @return `true` if the current build is the premium flavor, `false` otherwise. + */ private fun nonLicenseKeyVariant(): Boolean { return FlavorConfig.isPremiumFlavor } + /** + * Removes the `LICENSE_TOKEN` column from the `UPDATE_CHECK_ENTITY` table by recreating the table without that column. + * + * The existing rows for `_id`, `RELEASE_NOTE`, `VERSION`, `URL_TO_APK`, `APK_SHA256`, and `URL_TO_RELEASE_NOTE` are preserved + * by copying them into the new table. The operation is executed within a database transaction. + * + * @param db The database to modify. + */ private fun removeLicenseFromDb(db: Database) { db.beginTransaction() try { @@ -55,6 +79,12 @@ internal class Upgrade13To14 @Inject constructor(private val sharedPreferencesHa } } + /** + * Retrieves the existing license token from the UPDATE_CHECK_ENTITY table. + * + * @param db The database to query. + * @return The `LICENSE_TOKEN` value from the first row if present, `null` otherwise. + */ private fun getExistingLicenseToken(db: Database): String? { Sql.query("UPDATE_CHECK_ENTITY") .columns(listOf("LICENSE_TOKEN")) @@ -66,6 +96,11 @@ internal class Upgrade13To14 @Inject constructor(private val sharedPreferencesHa return null } + /** + * Marks the onboarding welcome flow as completed in shared preferences. + * + * Sets the flag that causes the welcome screen to be skipped on subsequent launches. + */ private fun setWelcomeFlowCompleted() { sharedPreferencesHandler.setWelcomeFlowCompleted() Timber.tag("Upgrade13To14").i("Skip welcome screen") diff --git a/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java index 2d82b8c9e..5ad581d49 100644 --- a/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java +++ b/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java @@ -20,9 +20,24 @@ public class UpdateCheckEntity extends DatabaseEntity { private String urlToReleaseNote; + /** + * Constructs a new UpdateCheckEntity with all fields unset (null). + * + *

Required by the persistence framework for entity instantiation.

+ */ public UpdateCheckEntity() { } + /** + * Creates a new UpdateCheckEntity with the given metadata. + * + * @param id primary key (may be null before persistence) + * @param releaseNote release note text or reference + * @param version version string of the release + * @param urlToApk download URL for the APK + * @param apkSha256 SHA-256 checksum of the APK + * @param urlToReleaseNote URL referencing the release notes + */ @Generated(hash = 867488251) public UpdateCheckEntity(Long id, String releaseNote, String version, String urlToApk, String apkSha256, String urlToReleaseNote) { this.id = id; @@ -38,10 +53,20 @@ public Long getId() { return id; } + /** + * Sets the entity's primary key. + * + * @param id the primary key value, or null if not yet assigned + */ public void setId(Long id) { this.id = id; } + /** + * Gets the version string of the update. + * + * @return the version string, or {@code null} if not set + */ public String getVersion() { return this.version; } diff --git a/data/src/main/java/org/cryptomator/data/repository/HubRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/HubRepositoryImpl.java index a6ee6025e..6fb7efb1d 100644 --- a/data/src/main/java/org/cryptomator/data/repository/HubRepositoryImpl.java +++ b/data/src/main/java/org/cryptomator/data/repository/HubRepositoryImpl.java @@ -49,11 +49,29 @@ public HubRepositoryImpl(Context context) { this.context = context; } + /** + * Creates an OkHttp logging interceptor that logs HTTP messages to Timber using the "OkHttp" tag. + * + * @param context Android Context used to initialize the interceptor + * @return an Interceptor that forwards OkHttp log messages to Timber with tag "OkHttp" + */ private Interceptor httpLoggingInterceptor(Context context) { HttpLoggingInterceptor.Logger logger = message -> Timber.tag("OkHttp").d(message); return new HttpLoggingInterceptor(logger, context); } + /** + * Retrieves the vault's JWE access token and subscription state from the Hub. + * + * @param unverifiedHubVaultConfig configuration that provides the Hub API base URL and vault identifier + * @param accessToken Bearer access token for authorization + * @return a {@link HubRepository.VaultAccess} containing the JWE payload and the vault subscription state + * @throws HubLicenseUpgradeRequiredException if the Hub responds with HTTP 402 (payment required) + * @throws HubVaultAccessForbiddenException if the Hub responds with HTTP 403 (forbidden) + * @throws HubVaultIsArchivedException if the Hub responds with HTTP 410 (gone / archived) + * @throws HubUserSetupRequiredException if the Hub responds with status 449 (user setup required) + * @throws FatalBackendException for other HTTP failures, missing response body on HTTP 200, or I/O errors + */ @Override public HubRepository.VaultAccess getVaultAccess(UnverifiedHubVaultConfig unverifiedHubVaultConfig, String accessToken) throws BackendException { var request = new Request.Builder().get() // diff --git a/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java index a84d916ff..55ca99f90 100644 --- a/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java +++ b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java @@ -64,6 +64,18 @@ private OkHttpClient httpClient() { .build(); } + /** + * Checks whether a newer application version is available for the provided current version. + * + * If a newer version is found, the method fetches update details, updates the cached + * UpdateCheckEntity (id 1) with APK URL, version and SHA-256, persists the cache, and + * returns an UpdateCheck describing the update. If the provided version already matches + * the latest known version, no update is performed and the result is absent. + * + * @param appVersion the current application version to compare against the latest metadata + * @return an Optional containing an UpdateCheck when an update is available; Optional.absent() when the appVersion matches the latest version + * @throws BackendException if fetching, verifying, or processing remote update metadata fails + */ @Override public Optional getUpdateCheck(final String appVersion) throws BackendException { LatestVersion latestVersion = loadLatestVersion(); @@ -88,6 +100,13 @@ public Optional getUpdateCheck(final String appVersion) throws Back return Optional.of(updateCheck); } + /** + * Downloads the APK pointed to by the cached update entity into the provided file and verifies its SHA-256. + * + * @param file the destination file to write the downloaded APK to + * @throws HashMismatchUpdateCheckException if the downloaded file's SHA-256 does not match the expected value + * @throws GeneralUpdateErrorException for network, I/O, or non-success HTTP status code errors encountered while downloading + */ @Override public void update(File file) throws GeneralUpdateErrorException { try { diff --git a/domain/src/main/java/org/cryptomator/domain/Vault.java b/domain/src/main/java/org/cryptomator/domain/Vault.java index 6153b40a2..395788ca1 100644 --- a/domain/src/main/java/org/cryptomator/domain/Vault.java +++ b/domain/src/main/java/org/cryptomator/domain/Vault.java @@ -21,6 +21,11 @@ public class Vault implements Serializable { private final boolean hubVault; private final boolean hubPaidLicense; + /** + * Creates a Vault instance from the given Builder's validated state. + * + * @param builder the Builder containing the validated properties to initialize this Vault + */ private Vault(Builder builder) { this.id = builder.id; this.name = builder.name; @@ -37,10 +42,25 @@ private Vault(Builder builder) { this.hubPaidLicense = builder.hubPaidLicense; } + /** + * Create a new Builder for configuring and constructing a Vault. + * + * @return a Builder instance initialized with default values for building a Vault + */ public static Builder aVault() { return new Builder(); } + /** + * Create a Builder pre-populated from an existing Vault. + * + * The returned Builder is initialized with the vault's id, cloud, cloudType, name, path, + * unlocked state, saved password and its crypto mode, format, shortening threshold, position, + * and hub-related flags. + * + * @param vault the source Vault to copy properties from + * @return a Builder initialized with the source vault's properties + */ public static Builder aCopyOf(Vault vault) { return new Builder() // .withId(vault.getId()) // @@ -57,6 +77,11 @@ public static Builder aCopyOf(Vault vault) { .withHubPaidLicense(vault.hasHubPaidLicense()); } + /** + * Gets the vault's identifier. + * + * @return the vault id, or {@code null} if the vault has no assigned identifier + */ public Long getId() { return id; } @@ -101,18 +126,39 @@ public int getPosition() { return position; } + /** + * Indicates whether this vault is read-only. + * + * @return `true` if the vault is read-only, `false` otherwise. + */ public boolean isReadOnly() { return false; //TODO Implement read-only check } + /** + * Indicates whether this vault is managed by Hub. + * + * @return true if the vault is a Hub vault, false otherwise. + */ public boolean isHubVault() { return hubVault; } + /** + * Indicates whether the vault has an associated Hub paid license. + * + * @return `true` if the vault has a Hub paid license, `false` otherwise. + */ public boolean hasHubPaidLicense() { return hubPaidLicense; } + /** + * Determines whether the given object represents the same Vault, using instance identity or matching non-null vault id. + * + * @param obj the object to compare with this Vault + * @return `true` if {@code obj} is the same instance or a {@code Vault} whose non-null id equals this vault's id, `false` otherwise + */ @Override public boolean equals(Object obj) { if (obj == null || getClass() != obj.getClass()) { @@ -149,6 +195,9 @@ public static class Builder { private boolean hubVault; private boolean hubPaidLicense; + /** + * Creates a new Builder initialized with default field values. + */ private Builder() { } @@ -219,21 +268,45 @@ public Builder withFormat(int version) { return this; } + /** + * Sets the filename shortening threshold for the vault being built. + * + * @param shorteningThreshold the threshold value at which shortening is applied (use -1 to leave unset) + * @return this builder instance + */ public Builder withShorteningThreshold(int shorteningThreshold) { this.shorteningThreshold = shorteningThreshold; return this; } + /** + * Sets whether the vault has an associated Hub paid license. + * + * @param hubPaidLicense true if the vault has a Hub paid license, false otherwise + * @return this Builder instance for chaining + */ public Builder withHubPaidLicense(boolean hubPaidLicense) { this.hubPaidLicense = hubPaidLicense; return this; } + /** + * Marks the vault under construction as a Hub vault. + * + * @param hubVault `true` to mark the vault as a Hub-managed vault, `false` otherwise + * @return this builder + */ public Builder withHubVault(boolean hubVault) { this.hubVault = hubVault; return this; } + /** + * Sets the vault's position used for ordering. + * + * @param position numeric position used for ordering; must be set to a value other than -1 before calling {@code build()} + * @return this Builder instance + */ public Builder withPosition(int position) { this.position = position; return this; diff --git a/domain/src/main/java/org/cryptomator/domain/repository/HubRepository.kt b/domain/src/main/java/org/cryptomator/domain/repository/HubRepository.kt index aec32003d..9538f2da4 100644 --- a/domain/src/main/java/org/cryptomator/domain/repository/HubRepository.kt +++ b/domain/src/main/java/org/cryptomator/domain/repository/HubRepository.kt @@ -5,9 +5,25 @@ import org.cryptomator.domain.exception.BackendException interface HubRepository { + /** + * Retrieves vault access data for the specified unverified hub vault configuration using the provided access token. + * + * @param unverifiedHubVaultConfig The unverified hub vault configuration that identifies the vault. + * @param accessToken The access token to authenticate the request against the hub. + * @return A [VaultAccess] containing the vault key in JWE form and the vault's subscription state. + * @throws BackendException If the backend request fails or access is denied. + */ @Throws(BackendException::class) fun getVaultAccess(unverifiedHubVaultConfig: UnverifiedHubVaultConfig, accessToken: String): VaultAccess + /** + * Fetches the hub user associated with the given unverified hub vault configuration and access token. + * + * @param unverifiedHubVaultConfig The unverified hub vault configuration identifying the vault context. + * @param accessToken The access token used to authenticate the request against the hub. + * @return A UserDto containing the user's id, name, public key, private key, and setup code. + * @throws BackendException If the backend request fails or returns an error. + */ @Throws(BackendException::class) fun getUser(unverifiedHubVaultConfig: UnverifiedHubVaultConfig, accessToken: String): UserDto diff --git a/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java b/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java index 07f263513..ed2b5bd11 100644 --- a/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java +++ b/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java @@ -10,7 +10,21 @@ public interface UpdateCheckRepository { - Optional getUpdateCheck(String version) throws BackendException; + /** + * Retrieves the update-check information for the specified application version. + * + * @param version the application version identifier to query (e.g., semantic version string) + * @return an {@code Optional} containing the update information for the given version, or {@code Optional.absent()} if no data is available + * @throws BackendException if a backend error prevents retrieving the update information + */ +Optional getUpdateCheck(String version) throws BackendException; - void update(File file) throws GeneralUpdateErrorException; + /** + * Applies an update using the given update file. + * + * @param file the update package file to apply + * @throws GeneralUpdateErrorException if the update cannot be applied (for example due to I/O errors, + * validation failures, or installation problems) + */ +void update(File file) throws GeneralUpdateErrorException; } diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java b/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java index b95ffbf5b..543ffadcc 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java @@ -38,11 +38,24 @@ public class DoLicenseCheck { private final SharedPreferencesHandler sharedPreferencesHandler; private String license; + /** + * Creates a new DoLicenseCheck use case with the given shared-preferences handler and an initial license token. + * + * @param license the incoming license token to verify (may be empty, in which case a stored token will be retrieved) + */ DoLicenseCheck(final SharedPreferencesHandler sharedPreferencesHandler, @Parameter final String license) { this.sharedPreferencesHandler = sharedPreferencesHandler; this.license = license; } + /** + * Verify the provided or stored license JWT, persist it if valid, and expose its subject. + * + * @return a {@link LicenseCheck} that yields the JWT subject string when invoked + * @throws DesktopSupporterCertificateException if the token's signature is invalid but verifies with the desktop supporter certificate + * @throws LicenseNotValidException if the token is invalid or its signature cannot be verified + * @throws FatalBackendException if a cryptographic error occurs while obtaining the public key + */ public LicenseCheck execute() throws BackendException { license = useLicenseOrRetrieveFromPreferences(license); try { @@ -61,6 +74,14 @@ public LicenseCheck execute() throws BackendException { } } + /** + * Ensure a non-empty license token is available by returning the provided license or, if empty, + * the token stored in shared preferences. + * + * @param license the incoming license token; if empty the method will attempt to read a stored token + * @return the non-empty license token + * @throws NoLicenseAvailableException if both the provided license and the stored token are empty + */ private String useLicenseOrRetrieveFromPreferences(String license) throws NoLicenseAvailableException { if (!license.isEmpty()) { return license; diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockHubVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockHubVault.java index 78e4907e0..d90c87e2c 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockHubVault.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockHubVault.java @@ -40,6 +40,15 @@ public void onCancel() { cancelled = true; } + /** + * Unlocks a hub-backed vault: validates the hub API version, derives a hub-backed target + * Vault (marking hub backing and paid-license state), and delegates the unlock operation + * to the cloud repository. + * + * @return the Cloud result produced by the cloud repository after unlocking the target vault + * @throws BackendException if the cloud repository or hub repository encounters a backend error + * @throws HubInvalidVersionException if the hub API level is less than {@code HUB_MINIMUM_VERSION} + */ public Cloud execute() throws BackendException { HubRepository.ConfigDto config = hubRepository.getConfig(unverifiedVaultConfig, accessToken); if (config.getApiLevel() < HUB_MINIMUM_VERSION) { diff --git a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt index 06ee9976f..86d6b8661 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt @@ -54,6 +54,11 @@ class CryptomatorApp : MultiDexApplication(), HasComponent @Volatile var lastRestoreOutcome: RestoreOutcome? = null + /** + * Retrieve and clear the last stored restore outcome. + * + * @return The last stored `RestoreOutcome`, or `null` if none was set. + */ fun consumeLastRestoreOutcome(): RestoreOutcome? { val outcome = lastRestoreOutcome lastRestoreOutcome = null @@ -62,6 +67,11 @@ class CryptomatorApp : MultiDexApplication(), HasComponent private val pendingProductDetailsCallbacks = mutableListOf<(List) -> Unit>() + /** + * Performs application startup and global initialization for the app process. + * + * Initializes logging and crash reporting, determines and logs build flavor and device info, initializes dependency injection, starts and binds background services, registers activity lifecycle callbacks (including the purchase-revoked observer only for freemium builds), applies saved UI night-mode, runs cache cleanup, applies an optional Microsoft workaround VmPolicy, and installs a global RxJava error handler. + */ override fun onCreate() { super.onCreate() setupLogging() @@ -100,6 +110,13 @@ class CryptomatorApp : MultiDexApplication(), HasComponent RxJavaPlugins.setErrorHandler { e: Throwable? -> Timber.tag("CryptomatorApp").e(e, "BaseErrorHandler detected a problem") } } + /** + * Starts the application's background services used across the app. + * + * Attempts to start and bind the cryptors service, the IAP billing service (freemium builds only), + * and the auto-upload service. If starting a service throws an IllegalStateException, the error + * is logged and startup continues for the remaining services. + */ private fun launchServices() { try { startCryptorsService() @@ -118,6 +135,13 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } } + /** + * Starts and binds to the CryptorsService and wires its runtime state into the application. + * + * On connection, stores the service binder, assigns the service's `Cryptors` instance as the app delegate, + * provides the service with the application's `FileUtil`, and calls `updateService()`. + * On disconnection, clears the stored binder and removes the app cryptors delegate. + */ private fun startCryptorsService() { bindService(Intent(this, CryptorsService::class.java), object : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { @@ -138,6 +162,13 @@ class CryptomatorApp : MultiDexApplication(), HasComponent }, BIND_AUTO_CREATE) } + /** + * Starts and binds to the in-app-purchase (IAP) billing service on freemium builds. + * + * If the current build flavor is not freemium, the method logs the decision and returns without binding. + * When the service connects, the app stores and initializes the billing binder and delivers any queued + * product-detail callbacks. When the service disconnects, the stored binder is cleared. + */ private fun startIapBillingService() { if (!FlavorConfig.isFreemiumFlavor) { Timber.tag("App").d("IAP billing service skipped for flavor %s", BuildConfig.FLAVOR) @@ -158,12 +189,29 @@ class CryptomatorApp : MultiDexApplication(), HasComponent }, BIND_AUTO_CREATE) } + /** + * Starts the in-app purchase flow for the specified product when running the freemium flavor. + * + * This is a no-op on non-freemium builds or if the billing service is not connected. + * + * @param activity A weak reference to the Activity used to launch the purchase UI. + * @param productId The identifier of the product to purchase. + */ fun launchPurchaseFlow(activity: WeakReference, productId: String) { if (FlavorConfig.isFreemiumFlavor) { iapBillingServiceBinder?.startPurchaseFlow(activity, productId) } } + /** + * Fetches in-app product details and delivers them to the provided callback. + * + * On freemium builds, the callback is invoked with retrieved products if the billing service is connected; + * otherwise the callback is queued and will be invoked once the service connects. On non-freemium builds, + * the callback is invoked immediately with an empty list. + * + * @param callback Called with the list of available `ProductInfo` objects. + */ fun queryProductDetails(callback: (List) -> Unit) { if (FlavorConfig.isFreemiumFlavor) { synchronized(pendingProductDetailsCallbacks) { @@ -174,6 +222,11 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } } + /** + * Delivers queued product-details callbacks by querying the in-app billing service and invoking each callback with the resulting product list. + * + * Queued callbacks are cleared after delivery. If the billing service binder is not connected, callbacks are cleared without being invoked. + */ private fun drainPendingProductDetailsCallbacks() { synchronized(pendingProductDetailsCallbacks) { if (pendingProductDetailsCallbacks.isEmpty()) { @@ -187,6 +240,16 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } } + /** + * Attempts to restore purchases and reports the result via the provided callback. + * + * If the build is not the freemium flavor, the callback is invoked immediately with + * `RestoreOutcome.NOTHING_TO_RESTORE`. If the in-app billing service is not connected, + * the callback is invoked with `RestoreOutcome.FAILED`. Otherwise the connected IAP + * service is used and its resulting outcome is forwarded to the callback. + * + * @param onComplete Callback invoked with the resulting `RestoreOutcome`. + */ fun restorePurchases(onComplete: (RestoreOutcome) -> Unit = {}) { if (!FlavorConfig.isFreemiumFlavor) { onComplete(RestoreOutcome.NOTHING_TO_RESTORE) @@ -201,6 +264,13 @@ class CryptomatorApp : MultiDexApplication(), HasComponent binder.restorePurchases(onComplete) } + /** + * Binds to the AutoUploadService and initializes the service binder when connected. + * + * When the service connects, stores the binder in `autoUploadServiceBinder` and calls its + * `init(...)` method with the app's cloud/content/file utilities and application context. + * Logs connection and disconnection events. + */ private fun startAutoUploadService() { bindService(Intent(this, AutoUploadService::class.java), object : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { @@ -274,12 +344,26 @@ class CryptomatorApp : MultiDexApplication(), HasComponent Timber.plant(ReleaseLogger(Companion.applicationContext)) } + /** + * Provides the application's Dagger dependency injection component. + * + * @return The initialized ApplicationComponent for resolving app-wide dependencies. + */ override fun getComponent(): ApplicationComponent { return applicationComponent } private val startedActivities = AtomicInteger(0) private val serviceNotifier: ActivityLifecycleCallbacks = object : NoOpActivityLifecycleCallbacks() { + /** + * Tracks app foreground state when an activity starts and notifies bound services. + * + * Increments the internal started-activity counter, updates the Cryptors service with whether + * the app is in the foreground, and — when transitioning from background to foreground on + * freemium builds — triggers a purchase refresh to detect refunds or lapsed subscriptions. + * + * @param activity The activity that has started. + */ override fun onActivityStarted(activity: Activity) { // Using onActivityStarted/Stopped (not Resumed/Paused) because B.onStart fires before A.onStop during // intra-app navigation, so the counter never transiently hits 0 on screen transitions. @@ -293,11 +377,21 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } } + /** + * Notifies the application that an activity has stopped and updates the app's foreground state. + * + * Decrements the internal started-activities counter and informs the bound service of the new count so it can adjust foreground/background behavior. + */ override fun onActivityStopped(activity: Activity) { updateService(startedActivities.decrementAndGet()) } } + /** + * Ensures the CryptorsService is bound and notifies it whether the application is in the foreground. + * + * @param startedCount The current count of started activities; the service is considered in foreground when this value is greater than 0. + */ private fun updateService(startedCount: Int = startedActivities.get()) { val localServiceBinder = cryptoServiceBinder if (localServiceBinder == null) { diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java b/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java index a47963aed..c9b47cb79 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java +++ b/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java @@ -130,16 +130,46 @@ public interface ActivityComponent { void inject(S3AddOrChangeFragment s3AddOrChangeFragment); - void inject(CryptomatorVariantsActivity cryptomatorVariantsActivity); - - void inject(WelcomeActivity welcomeActivity); - - void inject(WelcomeIntroFragment welcomeIntroFragment); - - void inject(WelcomeLicenseFragment welcomeLicenseFragment); - - void inject(WelcomeNotificationsFragment welcomeNotificationsFragment); - - void inject(WelcomeScreenLockFragment welcomeScreenLockFragment); + /** + * Injects dependencies into a CryptomatorVariantsActivity instance. + * + * @param cryptomatorVariantsActivity the activity instance to inject + */ +void inject(CryptomatorVariantsActivity cryptomatorVariantsActivity); + + /** + * Injects dependencies into the specified WelcomeActivity instance. + * + * @param welcomeActivity the WelcomeActivity to receive injected dependencies + */ +void inject(WelcomeActivity welcomeActivity); + + /** + * Injects Dagger-provided dependencies into the specified WelcomeIntroFragment. + * + * @param welcomeIntroFragment the fragment instance to inject dependencies into + */ +void inject(WelcomeIntroFragment welcomeIntroFragment); + + /** + * Injects dependencies into the specified WelcomeLicenseFragment. + * + * @param welcomeLicenseFragment the fragment instance to receive injected dependencies + */ +void inject(WelcomeLicenseFragment welcomeLicenseFragment); + + /** + * Injects required dependencies into the specified WelcomeNotificationsFragment. + * + * @param welcomeNotificationsFragment the fragment instance to receive injected dependencies + */ +void inject(WelcomeNotificationsFragment welcomeNotificationsFragment); + + /** + * Injects dependencies into the WelcomeScreenLockFragment instance. + * + * @param welcomeScreenLockFragment the fragment instance to receive injections + */ +void inject(WelcomeScreenLockFragment welcomeScreenLockFragment); } diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/LicenseCheckIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/LicenseCheckIntent.java index d539bca85..66a2849cc 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/intent/LicenseCheckIntent.java +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/LicenseCheckIntent.java @@ -7,6 +7,11 @@ @Intent(LicenseCheckActivity.class) public interface LicenseCheckIntent { + /** + * Specifies an optional action identifier associated with the locked state. + * + * @return the action identifier, or {@code null} if absent + */ @Optional String lockedAction(); diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java index 5eb4aac4f..9f27c52e4 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java @@ -8,8 +8,18 @@ @Intent(TextEditorActivity.class) public interface TextEditorIntent { - CloudFileModel textFile(); + /** + * Provides the cloud file to be opened by the text editor. + * + * @return the CloudFileModel representing the text file to open + */ +CloudFileModel textFile(); + /** + * Indicates whether hub write access is allowed for the target text file. + * + * @return `true` if hub write access is allowed, `false` if not, or `null` if the value is absent from the intent + */ @Optional Boolean hubWriteAllowed(); diff --git a/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt b/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt index 4311f51ec..f28710f9b 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt @@ -58,16 +58,32 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: ); companion object { + /** + * Finds the LockedAction whose enum name matches the provided string. + * + * @param name The enum name to match (case-sensitive); may be `null`. + * @return The matching `LockedAction`, or `null` if no match is found. + */ fun fromName(name: String?): LockedAction? { return values().firstOrNull { it.name == name } } } } + /** + * Determines whether write operations are permitted for the current user. + * + * @return `true` if a paid license is present or an active trial exists, `false` otherwise. + */ fun hasWriteAccess(): Boolean { return hasPaidLicense() || hasActiveTrial() } + /** + * Determines whether the user should be treated as having a paid license. + * + * @return `true` if a paid license is present (premium flavor, a stored license token, or a running subscription), `false` otherwise. + */ fun hasPaidLicense(): Boolean { if (FlavorConfig.isPremiumFlavor) { return true @@ -81,6 +97,12 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: return false } + /** + * Starts a 30-day trial by setting the trial expiration timestamp in preferences if none is set. + * + * If a trial expiration is already present (> 0), the method returns without changing it. Otherwise + * it stores System.currentTimeMillis() + 30 days as the trial expiration date. + */ fun startTrial() { if (sharedPreferencesHandler.trialExpirationDate() > 0) { return @@ -89,6 +111,10 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: sharedPreferencesHandler.setTrialExpirationDate(trialExpiration) } + /** + * Checks whether the stored trial expiration timestamp is due and, if so and it is not already marked, + * sets the trial expired flag in preferences. + */ private fun observeTrialExpiry() { val trialExpiration = sharedPreferencesHandler.trialExpirationDate() val now = System.currentTimeMillis() @@ -97,12 +123,29 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: } } + /** + * Determine whether a trial period is currently active. + * + * Observes expiry state before evaluation to ensure sticky/expired flags are up to date. + * + * @return `true` if a trial expiration date is set, is in the future, and the trial is not marked expired; `false` otherwise. + */ fun hasActiveTrial(): Boolean { observeTrialExpiry() val trialExpiration = sharedPreferencesHandler.trialExpirationDate() return trialExpiration > 0 && trialExpiration > System.currentTimeMillis() && !sharedPreferencesHandler.isTrialExpired() } + /** + * Computes the current trial status and a human-readable expiration date when applicable. + * + * Evaluates whether a trial is active, expired, and returns a formatted expiration date if an expiration is set and either active or expired. + * + * @return A [TrialState] containing: + * - `isActive`: `true` when a future expiration is set and the trial is not marked expired. + * - `isExpired`: `true` when an expiration is set and has passed or the trial is marked expired. + * - `formattedExpirationDate`: a locale-formatted date string when an expiration is present and the trial is active or expired, or `null` otherwise. + */ fun evaluateTrialState(): TrialState { observeTrialExpiry() val trialExpiration = sharedPreferencesHandler.trialExpirationDate() @@ -125,6 +168,12 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: val trialExpirationText: String? ) + /** + * Builds the license-related UI state used by screens to reflect access and trial status. + * + * @param context Context used to resolve the localized trial expiration string when applicable. + * @return A LicenseUiState containing write-access and paid-license flags, the evaluated TrialState, and an optional localized trialExpirationText (present when the trial is active or expired). + */ fun evaluateUiState(context: Context): LicenseUiState { val trialState = evaluateTrialState() val expirationText = if (trialState.isActive || trialState.isExpired) { @@ -138,9 +187,21 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: ) } + /** + * Provides the default string resource used as the reason banner when write access is restricted. + * + * @return The string resource id for the default read-only banner. + */ @StringRes fun defaultReasonRes(): Int = R.string.read_only_banner + /** + * Checks whether the requested write action is permitted and, if not, notifies the user and (for non-premium builds) opens the license-check screen. + * + * @param activity Activity used to show UI and to launch the license-check flow. + * @param action The locked action being attempted; used to select the user-facing message and to indicate which action is locked when launching the license-check screen. + * @return `true` if write access is allowed for the requested action, `false` otherwise. + */ fun ensureWriteAccess(activity: Activity, action: LockedAction): Boolean { if (hasWriteAccess()) { return true @@ -160,6 +221,15 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: return false } + /** + * Determine whether write actions are permitted for the given vault. + * + * For hub vaults, returns `true` if the vault has a hub-paid license or app-wide write access is available. + * For non-hub or `null` vaults, returns whether app-wide write access is available. + * + * @param vault The vault to check; may be `null` (treated as non-hub). + * @return `true` if write actions are allowed for the provided vault, `false` otherwise. + */ fun hasWriteAccessForVault(vault: VaultModel?): Boolean { if (vault?.isHubVault == true) { return vault.hasHubPaidLicense || hasWriteAccess() @@ -167,6 +237,14 @@ class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: return hasWriteAccess() } + /** + * Checks and enforces write access for the specified vault, treating hub vaults with their own restrictions. + * + * @param activity Activity used to display UI and to launch the license-check flow when enforcement requires user interaction. + * @param vault The vault to validate; if `null` or not a hub vault, global write-access enforcement is applied. + * @param action The write action being attempted, used to determine the appropriate enforcement messaging or flow. + * @return `true` if write access is granted for the requested action, `false` otherwise. + */ fun ensureWriteAccessForVault(activity: Activity, vault: VaultModel?, action: LockedAction): Boolean { if (vault?.isHubVault == true) { if (hasWriteAccessForVault(vault)) { diff --git a/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseStateOrchestrator.kt b/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseStateOrchestrator.kt index 2ba5e97b0..ace0d0822 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseStateOrchestrator.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseStateOrchestrator.kt @@ -14,12 +14,30 @@ class LicenseStateOrchestrator( ) { interface Target { - fun onPurchaseStateChanged(hasWriteAccess: Boolean, hasPaidLicense: Boolean) - fun onTrialStateChanged(active: Boolean, expired: Boolean, expirationText: String?) + /** + * Notifies the target about an update to the user's purchase and license status. + * + * @param hasWriteAccess Whether the user currently has write access to premium functionality. + * @param hasPaidLicense Whether the user currently holds a paid license. + */ +fun onPurchaseStateChanged(hasWriteAccess: Boolean, hasPaidLicense: Boolean) + /** + * Updates the UI about the current trial status. + * + * @param active True if a trial is currently active. + * @param expired True if the trial has expired. + * @param expirationText Localized text describing the trial expiration (e.g., date or remaining time), or `null` if unavailable. + */ +fun onTrialStateChanged(active: Boolean, expired: Boolean, expirationText: String?) } private val licenseChangeListener = Consumer { _ -> updateState() } + /** + * Registers the license-change listener, synchronizes the UI with the current license state, and triggers optional price loading for freemium builds. + * + * Calls into the shared preferences handler to listen for license changes, immediately updates the target UI state, and invokes `priceLoader` if the build is a freemium flavor and a loader was provided. + */ fun onResume() { sharedPreferencesHandler.addLicenseChangedListeners(licenseChangeListener) updateState() @@ -28,10 +46,22 @@ class LicenseStateOrchestrator( } } + /** + * Unregisters the orchestrator's license-change listener from the SharedPreferencesHandler. + * + * Call when the hosting component is paused to stop receiving license change events. + */ fun onPause() { sharedPreferencesHandler.removeLicenseChangedListeners(licenseChangeListener) } + /** + * Synchronizes the current license UI state and notifies the target about purchase and trial status. + * + * Evaluates the current license UI state and calls the target's purchase-state callback. + * If the build is freemium and no paid license is present, also notifies the target of the trial state + * (active/expired and optional expiration text). + */ fun updateState() { val uiState = licenseEnforcer.evaluateUiState(contextProvider()) target.onPurchaseStateChanged(uiState.hasWriteAccess, uiState.hasPaidLicense) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BaseLicensePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BaseLicensePresenter.kt index 6e88b1357..5bb2c72da 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BaseLicensePresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BaseLicensePresenter.kt @@ -16,6 +16,15 @@ open class BaseLicensePresenter @Inject internal construc protected val sharedPreferencesHandler: SharedPreferencesHandler ) : Presenter(exceptionHandlers) { + /** + * Validates and triggers a license status check for a license encoded in the given Uri. + * + * If `data` is null or does not contain a license fragment or last path segment, the method returns + * without performing any action. Otherwise it extracts the license (preferring the URI fragment, + * then the last path segment), updates the view's license entry, and starts a license status check. + * + * @param data A Uri that encodes the license either as its fragment or as its last path segment. + */ fun validate(data: Uri?) { data?.let { val license = it.fragment ?: it.lastPathSegment @@ -29,18 +38,31 @@ open class BaseLicensePresenter @Inject internal construc } } + /** + * Initiates a license status check for the provided license string. + * + * @param license The license string to check, or `null` if no license is provided. + */ fun validateDialogAware(license: String?) { doLicenseCheckUseCase .withLicense(license) .run(CheckLicenseStatusSubscriber()) } + /** + * Shows an obscuration information dialog when a security-related filtered touch event is detected. + */ fun onFilteredTouchEventForSecurity() { view?.showDialog(AppIsObscuredInfoDialog.newInstance()) } private inner class CheckLicenseStatusSubscriber : NoOpResultHandler() { + /** + * Handles a successful license check by persisting the returned mail, closing any open dialog, and showing a confirmation dialog with the mail. + * + * @param licenseCheck The license check result whose `mail()` value will be saved and displayed. + */ override fun onSuccess(licenseCheck: LicenseCheck) { super.onSuccess(licenseCheck) view?.closeDialog() @@ -48,6 +70,11 @@ open class BaseLicensePresenter @Inject internal construc view?.showConfirmationDialog(licenseCheck.mail()) } + /** + * Handles a license-check failure by displaying the provided error. + * + * @param t The throwable representing the error that occurred during the license check. + */ override fun onError(t: Throwable) { super.onError(t) showError(t) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 913a93167..bd72f4a6e 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -506,6 +506,13 @@ class BrowseFilesPresenter @Inject constructor( // }) } + /** + * Opens a cloud file using the most appropriate viewer for its type. + * + * For text files (".txt", ".md", ".todo") this opens the internal text editor and enables write access when the license permits; for image files (excluding ".gif") this opens the image preview; otherwise the file is opened with an external viewer. + * + * @param cloudFile The cloud file model to open. + */ private fun viewFile(cloudFile: CloudFileModel) { val lowerFileName = cloudFile.name.lowercase() if (lowerFileName.endsWith(".txt") || lowerFileName.endsWith(".md") || lowerFileName.endsWith(".todo")) { @@ -533,6 +540,17 @@ class BrowseFilesPresenter @Inject constructor( // return (mimeTypes.fromFilename(filename) ?: MimeType.WILDCARD_MIME_TYPE).mediatype == "image" } + /** + * Opens a cloud file with an external viewer, preparing the file URI, intent and permissions before launch. + * + * Computes and stores the file's hash, selects a content URI (or a legacy file URI when the Microsoft workaround applies), + * sets the intent MIME type and grants read (and write when the current vault permits) URI permissions, and then launches + * an external viewer via an activity result callback. If the "keep unlocked while editing" preference is enabled, shows + * a writable-file notification and suspends the app lock while the file is open. If no suitable activity is available, + * shows a "file type not supported" dialog. + * + * @param cloudFile The cloud file model representing the file to open. + */ private fun viewExternalFile(cloudFile: CloudFileModel) { val viewFileIntent = Intent(Intent.ACTION_VIEW) var openFileType = OpenFileType.DEFAULT @@ -1131,6 +1149,15 @@ class BrowseFilesPresenter @Inject constructor( // view?.showDialog(FileNameDialog()) } + /** + * Downloads decrypted content for the given text file and opens it either in the internal text editor or in an external viewer. + * + * Shows per-file progress while downloading unless the file was just created. If `internalEditor` is true, opens the internal editor and grants write capability only when the current vault allows write access; otherwise opens the file via the external viewer flow. + * + * @param textFile The cloud text file to download and open. + * @param newlyCreated True if the file was just created (suppresses per-file progress UI). + * @param internalEditor True to open the file with the app's internal text editor, false to open externally. + */ fun onOpenWithTextFileClicked(textFile: CloudFileModel, newlyCreated: Boolean, internalEditor: Boolean) { val decryptData = downloadFileUtil.createDecryptedDataFor(this, textFile) downloadFilesUseCase // diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt index d847a8cd5..10162a11c 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt @@ -33,6 +33,13 @@ class ChooseCloudServicePresenter @Inject constructor( // return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) } + /** + * Prepare and deliver the list of cloud providers to the view based on the current flavor configuration. + * + * Removes the CRYPTO entry unconditionally. If `FlavorConfig.excludesGoogleDrive` is true, removes + * GOOGLE_DRIVE; otherwise, when `FlavorConfig.isLiteFlavor` is true, additionally removes DROPBOX, + * ONEDRIVE, and PCLOUD. The resulting list is passed to `view?.render`. + */ override fun resumed() { val cloudTypeModels: MutableList = ArrayList(listOf(*CloudTypeModel.values())) cloudTypeModels.remove(CloudTypeModel.CRYPTO) @@ -90,10 +97,21 @@ class ChooseCloudServicePresenter @Inject constructor( // }) } + /** + * Finish the presenter and deliver the user's selected cloud to the caller. + * + * @param cloud The selected cloud to return to the caller after mapping. + */ private fun onCloudSelected(cloud: Cloud) { finishWithResult(cloudModelMapper.toModel(cloud)) } + /** + * Shows a snackbar prompting the user about Cryptomator variants in the Lite flavor. + * + * When the app runs in the Lite variant this displays a snackbar with an action that + * opens the Cryptomator variants screen. + */ fun showCloudMissingSnackbarHintInLiteVariant() { if (FlavorConfig.isLiteFlavor) { view?.showSnackbar(R.string.snack_bar_cryptomator_variants_hint, object : SnackbarAction { diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt index f72c032ea..52cf77cd4 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt @@ -129,6 +129,11 @@ class CloudSettingsPresenter @Inject constructor( // private inner class CloudsSubscriber : DefaultResultHandler>() { + /** + * Processes retrieved domain clouds into presentation models, applies flavor and login filters, augments the list with built-in cloud entries, and renders the resulting list on the view. + * + * @param clouds The list of domain Cloud objects returned by the use case. + */ override fun onSuccess(clouds: List) { val cloudModel = cloudModelMapper.toModels(clouds) // .filter { isSingleLoginCloud(it) } // diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt index 8b8ff77b1..68d709d4c 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt @@ -76,6 +76,13 @@ class SettingsPresenter @Inject internal constructor( .send(activity()) } + /** + * Builds the Markdown-formatted body for the error report email, including a summary section and device/app information. + * + * Includes a human-readable distribution variant derived from BuildConfig.FLAVOR, the app version and build code, Android version and API level, and the device model. + * + * @return The complete email body as a Markdown-formatted String. + */ private fun errorReportEmailBody(): String { val variant = when (BuildConfig.FLAVOR) { "apkstore" -> { diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt index bb8e0b9c6..acdf2a95f 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt @@ -301,10 +301,26 @@ class SharedFilesPresenter @Inject constructor( // } } + /** + * Collects the filenames for upload items that collide with existing files at the destination. + * + * @return A list of filenames for upload items that already exist at the destination. + */ private fun namesOfExistingFiles(): List { return existingFilesForUpload.mapTo(ArrayList()) { it.fileName } } + /** + * Handles the Save action: enforces vault write license if needed, updates edited filenames, validates them, and proceeds to save. + * + * If a vault is selected but write access is not granted, requests license enforcement for uploading and exits without changing state. + * Otherwise updates the presenter's filenames from the provided list, then: + * - shows a "filenames must be unique" message if any conflict exists, + * - shows an "invalid characters" message if any filename is invalid, + * - or begins the file-saving/upload preparation when validation passes. + * + * @param filesForUpload List of SharedFileModel containing the filenames as edited in the UI. + */ fun onSaveButtonPressed(filesForUpload: List) { if (selectedVault != null && !licenseEnforcer.hasWriteAccessForVault(selectedVault)) { view?.let { v -> @@ -386,11 +402,22 @@ class SharedFilesPresenter @Inject constructor( // return hasFileNameConflict } + /** + * Update the presenter's selected vault and enable or disable the upload action in the view. + * + * @param vault The vault selected by the user, or `null` to clear the selection. + */ fun onVaultSelected(vault: VaultModel?) { selectedVault = vault view?.setUploadEnabled(vault != null) } + /** + * Update the presenter's authentication state. + * + * @param authenticationState The authentication flow to use for the next authentication step + * (for example `AuthenticationState.CHOOSE_LOCATION` or `AuthenticationState.INIT_ROOT`). + */ private fun setAuthenticationState(authenticationState: AuthenticationState) { this.authenticationState = authenticationState } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index cfdf43bf3..da8c90810 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -99,10 +99,23 @@ class VaultListPresenter @Inject constructor( // private var vaultAction: VaultAction? = null private var hasShownTrialExpiredDialog = false + /** + * Provides the presenter workflows used to add an existing vault or create a new vault. + * + * @return An iterable containing the workflows for adding an existing vault and creating a new vault. + */ override fun workflows(): Iterable> { return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) } + /** + * Initiates the welcome flow when it hasn't been completed and, for freemium builds, + * shows the trial-expired dialog once if the trial is expired and no paid license exists. + * + * If the welcome flow is launched, this method returns immediately without performing + * further checks. For freemium flavors the trial-expired dialog is displayed at most + * once per presenter instance. + */ override fun resumed() { if (launchWelcomeFlowIfNeeded()) { return @@ -117,6 +130,13 @@ class VaultListPresenter @Inject constructor( // } } + /** + * Starts the welcome flow when it has not been completed yet. + * + * If the welcome flow is incomplete, launches WelcomeActivity (with an activity-result callback). + * + * @return `true` if the welcome flow was launched, `false` otherwise. + */ private fun launchWelcomeFlowIfNeeded(): Boolean { if (!sharedPreferencesHandler.hasCompletedWelcomeFlow()) { requestActivityResult( @@ -128,12 +148,24 @@ class VaultListPresenter @Inject constructor( // return false } + /** + * Reloads the vault list when the window gains focus. + * + * @param hasFocus `true` if the window now has focus, `false` otherwise. + */ fun onWindowFocusChanged(hasFocus: Boolean) { if (hasFocus) { loadVaultList() } } + /** + * Prepares the view by performing initial one-time checks and showing any required setup dialogs. + * + * Performs the welcome-flow gate, shows a lock-screen prompt if the device has no secure keyguard (once), + * displays a migration notice for vaults removed during migration, triggers an app update check for APK-store builds + * when configured, and initiates permission checks. + */ fun prepareView() { if (launchWelcomeFlowIfNeeded()) { return @@ -160,6 +192,14 @@ class VaultListPresenter @Inject constructor( // checkPermissions() } + /** + * Initiates an application update availability check when an internet connection is present. + * + * If an update is found, delegates handling to `updateStatusRetrieved`; if no update is available, + * logs that the current version is up to date. Always records that an update check was executed. + * Any errors during the check are reported via `showError`. If no network is available, logs that + * the check was not started. + */ private fun checkForAppUpdates() { if (networkConnectionCheck.isPresent) { updateCheckUseCase // @@ -217,6 +257,13 @@ class VaultListPresenter @Inject constructor( // ) } + /** + * Handles the result of a local-storage permission request for auto-upload and then proceeds to notification-permission handling. + * + * Logs an error if local storage permission was not granted; in all cases invokes checkNotificationPermission(). + * + * @param result The permission request result; `result.granted()` indicates the local storage permission was granted. + */ @Callback fun onLocalStoragePermissionResultForAutoUploadAndCheckNotificationPermission(result: PermissionsResult) { if (!result.granted()) { @@ -225,6 +272,12 @@ class VaultListPresenter @Inject constructor( // checkNotificationPermission() } + /** + * Ensures the app has notification permission or advances the permission flow. + * + * If the runtime notification permission should be requested, initiates a permission request. + * Otherwise continues by checking for CBC-encrypted vaults. + */ private fun checkNotificationPermission() { if (shouldRequestNotificationPermission()) { requestPermissions( @@ -237,11 +290,24 @@ class VaultListPresenter @Inject constructor( // } } + /** + * Determines whether the app should request the `POST_NOTIFICATIONS` runtime permission. + * + * @return `true` if the device is running a version newer than Android S_V2 and `POST_NOTIFICATIONS` + * is not granted, `false` otherwise. + */ private fun shouldRequestNotificationPermission(): Boolean { return Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2 && ContextCompat.checkSelfPermission(context(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED } + /** + * Handles the result of a notification permission request and continues with CBC-encrypted vault checks. + * + * Logs an error if the notification permission was not granted and then invokes the CBC-encrypted vaults check. + * + * @param result The outcome of the permission request indicating whether the permission was granted. + */ @Callback fun requestNotificationPermission(result: PermissionsResult) { if (!result.granted()) { @@ -496,6 +562,11 @@ class VaultListPresenter @Inject constructor( // vaultAction = null } + /** + * Ensures the provided authenticated vault is presented and either starts the unlock flow when it's locked or opens its contents when unlocked. + * + * @param authenticatedVault The vault model to present; if locked, triggers the user unlock flow, otherwise navigates into the vault's contents. + */ private fun requireUserAuthentication(authenticatedVault: VaultModel) { view?.addOrUpdateVault(authenticatedVault) if (authenticatedVault.isLocked) { @@ -510,11 +581,21 @@ class VaultListPresenter @Inject constructor( // } } + /** + * Continues presenter setup after the welcome flow completes. + * + * @param result The ActivityResult returned by the welcome flow activity. + */ @Callback fun welcomeFlowCompleted(result: ActivityResult) { prepareView() } + /** + * Handles an activity result containing a selected Cloud and initiates loading of its root folder. + * + * @param result The ActivityResult whose intent carries the selected Cloud under the `SINGLE_RESULT` extra. + */ @Callback fun vaultUnlockedVaultList(result: ActivityResult) { val cloud = result.intent().getSerializableExtra(SINGLE_RESULT) as Cloud diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/WelcomePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/WelcomePresenter.kt index d1ccdcab5..874c3d173 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/WelcomePresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/WelcomePresenter.kt @@ -23,6 +23,12 @@ class WelcomePresenter @Inject internal constructor( sharedPreferencesHandler: SharedPreferencesHandler ) : BaseLicensePresenter(exceptionHandlers, doLicenseCheckUseCase, sharedPreferencesHandler) { + /** + * Requests the runtime notification permission and reports the result to the view. + * + * On Android versions up to S_V2 this treats the permission as granted and immediately notifies the view. + * On newer versions it prompts the user for `Manifest.permission.POST_NOTIFICATIONS` using the welcome permission callback and a snackbar message. + */ fun requestNotificationPermission() { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { view?.onNotificationPermissionResult(true) @@ -35,6 +41,13 @@ class WelcomePresenter @Inject internal constructor( ) } + /** + * Handles the result of the welcome notification permission request and notifies the view. + * + * Logs an error if the permission was not granted, then calls `view?.onNotificationPermissionResult` with the grant status. + * + * @param result The permission result containing whether the notification permission was granted. + */ @Callback fun requestWelcomeNotificationPermission(result: PermissionsResult) { if (!result.granted()) { @@ -43,6 +56,12 @@ class WelcomePresenter @Inject internal constructor( view?.onNotificationPermissionResult(result.granted()) } + /** + * Attempts to open the system "Set new password" (screen lock) activity when requested. + * + * If `setScreenLock` is true, launches the device policy activity for setting a new password; if the activity is not available, a debug message is logged. + * + * @param setScreenLock Whether to start the system screen lock setup activity. fun onSetScreenLock(setScreenLock: Boolean) { if (setScreenLock) { try { diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt b/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt index 0ef8e3b09..684506908 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt @@ -15,6 +15,12 @@ data class ProductPrices( val lifetimePrice: String? ) +/** + * Resolves formatted prices for the known yearly subscription and full-version products from the list. + * + * @receiver The list of ProductInfo entries to search. + * @return A ProductPrices containing `subscriptionPrice` (formatted price for PRODUCT_YEARLY_SUBSCRIPTION) and `lifetimePrice` (formatted price for PRODUCT_FULL_VERSION); each is `null` if the corresponding product is not present. + */ fun List.resolveProductPrices(): ProductPrices { val subscription = find { it.productId == ProductInfo.PRODUCT_YEARLY_SUBSCRIPTION } val lifetime = find { it.productId == ProductInfo.PRODUCT_FULL_VERSION } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedReason.kt b/presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedReason.kt index 33aeec785..c15e4b5d3 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedReason.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedReason.kt @@ -8,6 +8,12 @@ enum class PurchaseRevokedReason(@StringRes val toastMessageRes: Int) { SUBSCRIPTION_INACTIVE(R.string.toast_purchase_revoked_subscription_inactive); companion object { + /** + * Finds the enum constant whose name exactly matches the given string. + * + * @param name The enum constant name to look up; may be `null`. + * @return The matching `PurchaseRevokedReason` if found, `null` otherwise. + */ fun fromName(name: String?): PurchaseRevokedReason? { return entries.firstOrNull { it.name == name } } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedToastObserver.kt b/presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedToastObserver.kt index 5c4119b1f..f172001f9 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedToastObserver.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/service/PurchaseRevokedToastObserver.kt @@ -10,6 +10,11 @@ class PurchaseRevokedToastObserver( private val sharedPreferencesHandler: SharedPreferencesHandler ) : NoOpActivityLifecycleCallbacks() { + /** + * Checks for a pending "purchase revoked" flag when an activity resumes, shows a corresponding Toast if a valid reason is stored, logs a warning if the reason is invalid or missing, and clears the pending state. + * + * @param activity The resumed Activity used to display the Toast when a valid revoke reason is found. + */ override fun onActivityResumed(activity: Activity) { if (!sharedPreferencesHandler.purchaseRevokedPending()) { return diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcomeHandler.kt b/presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcomeHandler.kt index 37c4586a8..ab53725a3 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcomeHandler.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/service/RestoreOutcomeHandler.kt @@ -1,5 +1,10 @@ package org.cryptomator.presentation.service interface RestoreOutcomeHandler { - fun onRestoreOutcome(outcome: RestoreOutcome) + /** + * Handles the outcome of a restore operation. + * + * @param outcome The result of a restore operation containing status and related details. + */ +fun onRestoreOutcome(outcome: RestoreOutcome) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt index efa90346d..1a419885c 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt @@ -170,6 +170,14 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi } } + /** + * Handle toolbar/menu item selections and dispatch the corresponding presenter or fragment actions. + * + * Certain actions (delete, move, share) require write access and will be gated by the license enforcer before proceeding. + * + * @param itemId The selected menu item's resource id. + * @return `true` if the menu selection was handled, `false` otherwise. + */ override fun onMenuItemSelected(itemId: Int): Boolean = when (itemId) { R.id.action_create_folder -> { showCreateFolderDialog() @@ -464,80 +472,159 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi browseFilesFragment().show(nodes) } + /** + * Adds the given cloud node to the current list or updates it if already present. + * + * @param node The cloud node (file or folder) to add or update in the displayed list. + */ override fun addOrUpdateCloudNode(node: CloudNodeModel<*>) { browseFilesFragment().addOrUpdate(node) } + /** + * Initiates the create-folder flow for the current folder. + * + * Checks whether creating a folder is permitted in the current vault and, if permitted, + * displays the create-folder dialog. + */ override fun onCreateNewFolderClicked() { if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.CREATE_FOLDER)) { showCreateFolderDialog() } } + /** + * Displays the dialog used to create a new folder. + */ private fun showCreateFolderDialog() { showDialog(CreateFolderDialog()) } + /** + * Initiates an upload into the given folder after verifying that the current vault allows write access. + * + * If write access is permitted, delegates the upload action to the presenter. + * + * @param folder The target folder to upload files into. + */ override fun onUploadFilesClicked(folder: CloudFolderModel) { if (ensureWriteAccessForFolder(folder, LicenseEnforcer.LockedAction.UPLOAD_FILES)) { browseFilesPresenter.onUploadFilesClicked(folder) } } + /** + * Initiates creation of a new text file in the current folder if the current vault permits writes. + * + * Checks write access for the current vault and proceeds with the text-file creation flow only when allowed. + */ override fun onCreateNewTextFileClicked() { if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.CREATE_TEXT_FILE)) { browseFilesPresenter.onCreateNewTextFileClicked() } } + /** + * Initiates the rename flow for the given cloud file if the current vault allows renaming. + * + * @param cloudFile The cloud file to rename. + */ override fun onRenameFileClicked(cloudFile: CloudFileModel) { if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.RENAME_NODE)) { onRenameCloudNodeClicked(cloudFile) } } + /** + * Initiates the rename flow for the given folder when write access to its vault is permitted. + * + * @param cloudFolderModel The folder model to rename; used as the target for the rename dialog/action. + */ override fun onRenameFolderClicked(cloudFolderModel: CloudFolderModel) { if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.RENAME_NODE)) { onRenameCloudNodeClicked(cloudFolderModel) } } + /** + * Shows a rename dialog for the given cloud node. + * + * @param cloudNodeModel The cloud node (file or folder) to rename; the dialog will be pre-filled with its current name. + */ private fun onRenameCloudNodeClicked(cloudNodeModel: CloudNodeModel<*>) { showDialog(CloudNodeRenameDialog.newInstance(cloudNodeModel)) } + /** + * Shows a confirmation dialog to delete the provided cloud node when the current vault allows write access. + * + * @param cloudFile The cloud node to be deleted; presented in the confirmation dialog if deletion is permitted. + */ override fun onDeleteNodeClicked(cloudFile: CloudNodeModel<*>) { if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.DELETE_NODE)) { showConfirmDeleteNodeDialog(listOf(cloudFile)) } } + /** + * Initiates sharing of the given cloud file if the current vault allows sharing. + * + * @param cloudFile The cloud file to be shared. + */ override fun onShareFileClicked(cloudFile: CloudFileModel) { if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.SHARE_NODE)) { browseFilesPresenter.onShareFileClicked(cloudFile) } } + /** + * Initiates moving the given cloud file within the current folder if write access is allowed. + * + * @param cloudFile The cloud file to move. + */ override fun onMoveFileClicked(cloudFile: CloudFileModel) { if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.MOVE_NODE)) { browseFilesPresenter.onMoveNodeClicked(folder, cloudFile) } } + /** + * Handle a user request to open an existing text file in the default external text editor. + * + * @param cloudFile The cloud file to open. + */ override fun onOpenWithTextFileClicked(cloudFile: CloudFileModel) { browseFilesPresenter.onOpenWithTextFileClicked(cloudFile, newlyCreated = false, internalEditor = false) } + /** + * Shows a confirmation dialog for deleting the given cloud nodes. + * + * @param nodes The cloud nodes to be deleted if the user confirms. + */ private fun showConfirmDeleteNodeDialog(nodes: List>) { showDialog(ConfirmDeleteCloudNodeDialog.newInstance(nodes)) } + /** + * Initiates moving the given folder into the current folder when write access to the current vault is permitted. + * + * @param cloudFolderModel The folder to move. + */ override fun onMoveFolderClicked(cloudFolderModel: CloudFolderModel) { if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.MOVE_NODE)) { browseFilesPresenter.onMoveNodeClicked(folder, cloudFolderModel) } } + /** + * Creates a back stack entry to navigate out to the given parent folder. + * + * Replaces the current fragment with a BrowseFilesFragment for the provided folder and + * applies the "navigate out of folder" animation. + * + * @param sourceParent The parent folder to navigate back to. + */ private fun createBackStackFor(sourceParent: CloudFolderModel) { replaceFragment( BrowseFilesFragment.newInstance( @@ -581,17 +668,42 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi browseFilesFragment().showLoading(loading) } - private fun browseFilesFragment(): BrowseFilesFragment = getCurrentFragment(R.id.fragment_container) as BrowseFilesFragment + /** + * Gets the current BrowseFilesFragment from the fragment container. + * + * @return The active BrowseFilesFragment instance. + */ +private fun browseFilesFragment(): BrowseFilesFragment = getCurrentFragment(R.id.fragment_container) as BrowseFilesFragment + /** + * Determine whether the current folder's vault allows the specified write-protected action. + * + * @param action The write-protected action to check (one of `LicenseEnforcer.LockedAction`). + * @return `true` if write access is granted for the current folder's vault for the provided action, `false` otherwise. + */ private fun ensureWriteAccessForCurrentVault(action: LicenseEnforcer.LockedAction): Boolean { return ensureWriteAccessForFolder(browseFilesFragment().folder, action) } + /** + * Checks whether write access is allowed for the vault that contains the given folder. + * + * If `folder` is `null`, the current fragment folder is used as the target. + * + * @param folder The folder whose vault write access should be verified, or `null` to use the current folder. + * @param action The write-capable action to check permission for. + * @return `true` if write access for the target folder's vault is allowed for the specified action, `false` otherwise. + */ private fun ensureWriteAccessForFolder(folder: CloudFolderModel?, action: LicenseEnforcer.LockedAction): Boolean { val targetFolder = folder ?: browseFilesFragment().folder return licenseEnforcer.ensureWriteAccessForVault(this, targetFolder.vault(), action) } + /** + * Creates a new text file in the currently displayed folder with the given name. + * + * @param fileName The desired name for the new text file within the current folder. + */ override fun onCreateNewTextFileClicked(fileName: String) { browseFilesPresenter.onCreateNewTextFileClicked(browseFilesFragment().folder, fileName) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt index e95b24a9e..e2cb22867 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt @@ -51,9 +51,22 @@ class LicenseCheckActivity : BaseActivity(ActivityL LicenseStateOrchestrator( sharedPreferencesHandler, licenseEnforcer, { this }, target = object : LicenseStateOrchestrator.Target { + /** + * Handles changes to purchase state by updating the license content view. + * + * @param hasWriteAccess `true` if the current purchase state grants write access to app features, `false` otherwise. + * @param hasPaidLicense `true` if a paid license is present, `false` otherwise. + */ override fun onPurchaseStateChanged(hasWriteAccess: Boolean, hasPaidLicense: Boolean) { licenseContentViewBinder.bindPurchaseState(hasWriteAccess, hasPaidLicense) } + /** + * Updates the UI to reflect the current trial status. + * + * @param active `true` if a trial is currently active, `false` otherwise. + * @param expired `true` if the trial has expired, `false` otherwise. + * @param expirationText Human-readable text describing the trial expiration (e.g., date), or `null` if not available. + */ override fun onTrialStateChanged(active: Boolean, expired: Boolean, expirationText: String?) { licenseContentViewBinder.bindTrialState(active, expired, expirationText) } @@ -62,6 +75,12 @@ class LicenseCheckActivity : BaseActivity(ActivityL ) } + /** + * Initializes the activity, registers a security touch listener, and validates the incoming intent. + * + * Registers a listener on the root view that forwards filtered touch events to the presenter for security + * and invokes intent validation using the current activity intent. + */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding.activityRootView.setOnFilteredTouchEventForSecurityListener(object : ObscuredAwareCoordinatorLayout.Listener { @@ -72,22 +91,41 @@ class LicenseCheckActivity : BaseActivity(ActivityL validate(intent) } + /** + * Resumes license state orchestration and processes any pending restore outcome. + * + * Invokes the orchestrator's resume logic and, if the application has a pending restore outcome, consumes it and forwards it to onRestoreOutcome. + */ override fun onResume() { super.onResume() orchestrator.onResume() (application as CryptomatorApp).consumeLastRestoreOutcome()?.let { onRestoreOutcome(it) } } + /** + * Pauses the license state orchestrator when the activity is paused. + */ override fun onPause() { super.onPause() orchestrator.onPause() } + /** + * Initializes the activity's view state by deriving the current locked action from the injected intent + * and configuring the upsell UI accordingly. + */ override fun setupView() { lockedAction = LicenseEnforcer.LockedAction.fromName(licenseCheckIntent.lockedAction()) setupUpsellView() } + /** + * Configures the upsell UI: sets up the toolbar, displays or hides the info text for any locked action, + * and selects the appropriate mode-specific content. + * + * If a `lockedAction` is present, its header message is shown in the info text; otherwise the info text is hidden. + * Chooses the in-app-purchase UI for freemium builds and the license-entry UI for non-freemium builds. + */ private fun setupUpsellView() { setSupportActionBar(binding.mtToolbar.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -110,6 +148,13 @@ class LicenseCheckActivity : BaseActivity(ActivityL } } + /** + * Configures the activity UI for the in-app purchase (full-version) flow. + * + * Sets the action bar title, binds the IAP-specific content and legal links, and configures + * the purchase buttons. Activating the trial option starts a trial via the license enforcer + * and refreshes the license state. + */ private fun setupIapView() { supportActionBar?.title = getString(R.string.screen_license_check_title_full_version) licenseContentViewBinder.bindInitialIapLayout() @@ -124,6 +169,13 @@ class LicenseCheckActivity : BaseActivity(ActivityL ) } + /** + * Configures the UI for entering a license key. + * + * Sets the action bar title, binds the license-entry layout, shows the purchase/submit button + * with the "OK" label and hooks it to submit the entered license, and makes the license link + * open the Cryptomator Android website when tapped. + */ private fun setupLicenseEntryView() { supportActionBar?.title = getString(R.string.screen_license_check_title) licenseContentViewBinder.bindInitialLicenseEntryLayout() @@ -135,6 +187,12 @@ class LicenseCheckActivity : BaseActivity(ActivityL } } + /** + * Handle a newly delivered intent by updating activity state, recomputing the locked action, + * refreshing the upsell UI, updating the license orchestrator, and validating the intent data. + * + * @param intent The new intent delivered to the activity. + */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -145,26 +203,52 @@ class LicenseCheckActivity : BaseActivity(ActivityL validate(intent) } + /** + * Validates the intent's data URI using the license check presenter. + * + * @param intent The incoming Intent whose `data` Uri will be validated. + */ private fun validate(intent: Intent) { val data: Uri? = intent.data licenseCheckPresenter.validate(data) } + /** + * Populates the license input with the provided text and shows the license entry UI. + * + * @param license The license string to display in the license input field. + */ override fun showOrUpdateLicenseEntry(license: String) { binding.licenseContent.etLicense.setText(license) binding.licenseContent.licenseEntryGroup.visibility = View.VISIBLE } + /** + * Shows a confirmation dialog to confirm the license associated with the provided email address. + * + * @param mail The email address to display in the confirmation dialog. + */ override fun showConfirmationDialog(mail: String) { showDialog(LicenseConfirmationDialog.newInstance(mail)) } + /** + * Navigates to the vault list screen and prevents returning to this activity via the back stack. + */ override fun licenseConfirmationClicked() { vaultListIntent() // .preventGoingBackInHistory() // .startActivity(this) // } + /** + * Shows the dialog corresponding to a restore outcome. + * + * Displays a success dialog when the outcome is `RESTORED`, a no-full-version dialog when `NOTHING_TO_RESTORE`, + * and a failure dialog for any `FAILED` outcome. + * + * @param outcome The result of a restore operation that determines which dialog to display. + */ override fun onRestoreOutcome(outcome: RestoreOutcome) { when (outcome) { RestoreOutcome.RESTORED -> showDialog(RestoreSuccessfulDialog.newInstance()) @@ -173,10 +257,26 @@ class LicenseCheckActivity : BaseActivity(ActivityL } } - override fun onRestoreSuccessfulDialogFinished() = Unit - override fun onNoFullVersionDialogFinished() = Unit - override fun onRestoreFailedDialogFinished() = Unit + /** + * Called when the restore-successful dialog is finished by the user. + * + * Default implementation performs no action. + */ +override fun onRestoreSuccessfulDialogFinished() = Unit + /** + * Callback invoked when the "no full version" dialog is dismissed. + * + * No action is performed by this implementation. + */ +override fun onNoFullVersionDialogFinished() = Unit + /** + * Called when the restore-failed dialog is finished; no action is taken. + */ +override fun onRestoreFailedDialogFinished() = Unit + /** + * Submits the current license text from the input field to the presenter for validation and dialog-aware handling. + */ private fun onLicenseSubmit() { licenseCheckPresenter.validateDialogAware(binding.licenseContent.etLicense.text?.toString()) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt index 2ce4615dd..73e6abf50 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt @@ -170,6 +170,11 @@ class SharedFilesActivity : BaseActivity(ActivityLayoutBi finish() } + /** + * Attempts to open this application's launch activity (so the user can create a vault) and then finishes this activity. + * + * If no launch intent is available for the package, the method still finishes the activity. + */ override fun onNotEnoughVaultsCreateVaultClicked() { packageManager.getLaunchIntentForPackage(packageName) ?.let { @@ -179,10 +184,18 @@ class SharedFilesActivity : BaseActivity(ActivityLayoutBi finish() } + /** + * Enable or disable the upload action in the hosted SharedFilesFragment. + * + * @param enabled `true` to enable uploads, `false` to disable them. + */ override fun setUploadEnabled(enabled: Boolean) { sharedFilesFragment().setUploadEnabled(enabled) } + /** + * Notifies the presenter that the upload operation was canceled. + */ override fun onUploadCanceled() { presenter.onUploadCanceled() } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt index 10166e510..140f7967e 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt @@ -31,6 +31,13 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin @InjectIntent lateinit var textEditorIntent: TextEditorIntent + /** + * Determine whether the activity currently allows modifying the opened text file. + * + * Considers both the app's license state and the incoming intent's hub write permission. + * + * @return `true` if writing is allowed, `false` otherwise. + */ private fun hasWriteAccess(): Boolean { return licenseEnforcer.hasWriteAccess() || textEditorIntent.hubWriteAllowed() == true } @@ -43,8 +50,16 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin setupToolbar() } - override fun createFragment(): Fragment = TextEditorFragment() + /** + * Creates a new TextEditorFragment to host in this activity. + * + * @return A new instance of TextEditorFragment. + */ +override fun createFragment(): Fragment = TextEditorFragment() + /** + * Handles the system back navigation: if write access is available, delegates handling to the presenter; otherwise invokes the default back behavior. + */ override fun onBackPressed() { if (!hasWriteAccess()) { super.onBackPressed() @@ -105,6 +120,11 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin return true } + /** + * Prepares the options menu by wiring the search view's query listener and showing or hiding the save action based on write access. + * + * @return The result of calling the superclass implementation of `onPrepareOptionsMenu`. + */ override fun onPrepareOptionsMenu(menu: Menu): Boolean { val searchView = menu.findItem(R.id.action_search).actionView as SearchView searchView.setOnQueryTextListener(this) @@ -127,6 +147,13 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin UnsavedChangesDialog.withContext(this).show() } + /** + * Displays the provided text file content in the editor UI. + * + * If the current session does not have write access, the editor is set to read-only after displaying the content. + * + * @param textFileContent The text content of the file to display in the editor. + */ override fun displayTextFileContent(textFileContent: String) { textEditorFragment().displayTextFileContent(textFileContent) if (!hasWriteAccess()) { @@ -134,6 +161,9 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin } } + /** + * Requests that any unsaved changes to the current text file be saved. + */ override fun onSaveChangesClicked() { textEditorPresenter.saveChanges() } @@ -142,9 +172,17 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin performBackPressed() } + /** + * Closes the activity when the vault is expected to be unlocked. + */ override fun vaultExpectedToBeUnlocked() { finish() } - private fun textEditorFragment(): TextEditorFragment = getCurrentFragment(R.id.fragment_container) as TextEditorFragment + /** + * Retrieve the currently displayed TextEditorFragment from the fragment container. + * + * @return The active TextEditorFragment instance. + */ +private fun textEditorFragment(): TextEditorFragment = getCurrentFragment(R.id.fragment_container) as TextEditorFragment } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt index 3895ca633..50b701bb6 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt @@ -176,10 +176,19 @@ class VaultListActivity : BaseActivity(Activi vaultListFragment().addOrUpdateVault(vaultModel) } + /** + * Initiates the flow to add an existing vault by delegating the action to the presenter. + */ override fun onAddExistingVault() { vaultListPresenter.onAddExistingVault() } + /** + * Initiates the creation flow for a new vault if creating is permitted. + * + * Checks whether vault creation is allowed (for example due to license or trial restrictions); + * if allowed, starts the vault creation flow. If not allowed, the call is a no-op. + */ override fun onCreateVault() { if (!licenseEnforcer.ensureWriteAccess(this, LicenseEnforcer.LockedAction.CREATE_VAULT)) { return @@ -212,15 +221,28 @@ class VaultListActivity : BaseActivity(Activi vaultListPresenter.deleteVault(vaultModel) } + /** + * Processes the user's choice from the lock-screen setup dialog. + * + * @param setScreenLock `true` if the user chose to enable the screen lock, `false` otherwise. + */ override fun onAskForLockScreenFinished(setScreenLock: Boolean) { vaultListPresenter.onAskForLockScreenFinished(setScreenLock) } + /** + * Launches the license check flow to allow the user to unlock the full version. + */ override fun onUnlockFullVersionClicked() { Intents.licenseCheckIntent().startActivity(this) } - private fun vaultListFragment(): VaultListFragment = // + /** + * Retrieves the VaultListFragment currently attached to the fragment container. + * + * @return The VaultListFragment instance found in R.id.fragment_container. + */ + private fun vaultListFragment(): VaultListFragment = // getCurrentFragment(R.id.fragment_container) as VaultListFragment override fun onUpdateAppDialogLoaded() { diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt index 74ac77b4f..378bed743 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt @@ -61,6 +61,15 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind LicenseStateOrchestrator( sharedPreferencesHandler, licenseEnforcer, { this }, target = object : LicenseStateOrchestrator.Target { + /** + * Updates the license page UI to reflect the current purchase and trial status. + * + * Updates the license fragment's unlocked state based on write-access and paid-license flags. + * For non-premium, non-freemium flavors without a paid license, also evaluates and updates the trial state shown on the license fragment. + * + * @param hasWriteAccess True if the current user/license grants write access (unlocked features). + * @param hasPaidLicense True if a paid license is present. + */ override fun onPurchaseStateChanged(hasWriteAccess: Boolean, hasPaidLicense: Boolean) { if (!this@WelcomeActivity::pagerAdapter.isInitialized) { return @@ -75,6 +84,13 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind ) } } + /** + * Notify the activity that the trial state changed and propagate the state to the license fragment. + * + * @param active `true` if a trial is currently active, `false` otherwise. + * @param expired `true` if the trial has expired, `false` otherwise. + * @param expirationText Human-readable remaining time or expiration information, or `null` if not available. + */ override fun onTrialStateChanged(active: Boolean, expired: Boolean, expirationText: String?) { if (!this@WelcomeActivity::pagerAdapter.isInitialized) { return @@ -94,11 +110,21 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind private val pages = mutableListOf() private var navBasePaddingBottom: Int = 0 + /** + * Validates a newly delivered intent for onboarding-related data so the activity can react (e.g., handle deep links). + */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) validate(intent) } + /** + * Initializes the welcome screen UI and onboarding pager, or navigates away if the welcome flow is already completed. + * + * Configures the toolbar, installs a security-aware touch listener, applies bottom insets padding to the navigation container, + * constructs pager pages and adapter, validates the incoming intent, and refreshes notification permission, license orchestrator, + * and screen-lock state. If the persistent "welcome completed" flag is set, opens the vault list and finishes setup immediately. + */ override fun setupView() { if (sharedPreferencesHandler.hasCompletedWelcomeFlow()) { openVaultList() @@ -132,6 +158,11 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind updateScreenLockState() } + /** + * Resumes onboarding-related state and handles any pending restore outcome. + * + * If the welcome flow has already been completed and the activity is not finishing, opens the vault list and finishes the activity. Otherwise, resumes the license state orchestrator, consumes and dispatches a pending restore outcome (if any), and refreshes notification-permission and screen-lock UI state. + */ override fun onResume() { super.onResume() if (sharedPreferencesHandler.hasCompletedWelcomeFlow() && !isFinishing) { @@ -144,11 +175,20 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind updateScreenLockState() } + /** + * Propagates the activity pause lifecycle event to the LicenseStateOrchestrator. + */ override fun onPause() { super.onPause() orchestrator.onPause() } + /** + * Populates the onboarding pager's page list in the correct order for the welcome flow. + * + * The sequence is Intro, an optional License page (included only when the build is not the premium + * flavor), Notifications, and ScreenLock. + */ private fun setupPages() { pages.clear() pages.add(FragmentPage.Intro) @@ -159,6 +199,17 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind pages.add(FragmentPage.ScreenLock) } + /** + * Initializes the welcome ViewPager2: attaches the pager adapter, sets the initial page and user input, + * registers page-change handling to update navigation UI and per-page state, and wires Back/Next button actions. + * + * The page-change handler updates navigation buttons and refreshes page-specific state: + * - License page: refreshes license orchestrator state. + * - Notifications page: refreshes notification-permission UI state. + * - ScreenLock page: refreshes keyguard/lock UI state. + * + * The Back button moves to the previous page when possible. The Next button advances to the next page or completes onboarding when on the last page. + */ private fun setupPager() { pagerAdapter = WelcomePagerAdapter(this, pages) binding.welcomePager.adapter = pagerAdapter @@ -187,6 +238,10 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind } } + /** + * Update navigation button visibility and text according to the given page index. + * + * @param position The index of the currently selected page; 0 is the first page. */ private fun updateNavigationButtons(position: Int) { binding.btnBack.visibility = if (position == 0) View.INVISIBLE else View.VISIBLE binding.btnNext.text = if (position == pagerAdapter.itemCount - 1) { @@ -196,10 +251,20 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind } } + /** + * Determines whether the runtime requires the runtime notification permission. + * + * @return `true` if the device is running a platform newer than Android S_V2 (i.e., POST_NOTIFICATIONS must be requested), `false` otherwise. + */ private fun needsNotificationPermission(): Boolean { return Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2 } + /** + * Determines whether notification posting is permitted for this app on the device. + * + * @return `true` if `POST_NOTIFICATIONS` is granted or the API level does not require the permission, `false` otherwise. + */ private fun hasNotificationPermission(): Boolean { return !needsNotificationPermission() || ContextCompat.checkSelfPermission( this, @@ -207,6 +272,14 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind ) == PackageManager.PERMISSION_GRANTED } + /** + * Updates the notifications page with the current notification permission state. + * + * If `grantedOverride` is provided, that value is used; otherwise the method queries + * the system permission state and forwards the result to the notifications fragment. + * + * @param grantedOverride Optional override for the permission state; when non-null, this value is applied instead of reading the system permission. + */ private fun updateNotificationPermissionState(grantedOverride: Boolean? = null) { if (!this::pagerAdapter.isInitialized) { return @@ -215,6 +288,12 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind pagerAdapter.notificationsFragment?.updatePermissionState(granted) } + /** + * Update the ScreenLock page's UI to reflect whether the device's screen lock is secure. + * + * If the pager adapter is initialized and the ScreenLock fragment is present, the fragment's + * screen-lock state is updated from `keyguardManager.isKeyguardSecure`. Otherwise this is a no-op. + */ private fun updateScreenLockState() { if (!this::pagerAdapter.isInitialized) { return @@ -222,17 +301,35 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind pagerAdapter.screenLockFragment?.updateScreenLockState(keyguardManager.isKeyguardSecure) } + /** + * Completes onboarding by recording completion and navigating to the vault list. + * + * Marks the welcome flow as completed, records that the screen-lock dialog has already been shown, + * then opens the vault list and finishes the activity. + */ private fun completeWelcomeFlow() { sharedPreferencesHandler.setWelcomeFlowCompleted() sharedPreferencesHandler.setScreenLockDialogAlreadyShown() openVaultList() } + /** + * Finalizes onboarding by setting the activity result to `RESULT_OK` and closing the activity. + */ private fun openVaultList() { setResult(RESULT_OK) finish() } + /** + * Validates the intent's data URI with the welcome presenter when applicable. + * + * If the provided intent contains a data URI and the app is not a premium flavor, + * the URI is forwarded to the welcome presenter for validation. No action is taken + * for a null intent, an intent without data, or when running the premium flavor. + * + * @param intent The incoming intent that may contain a data URI to validate. + */ private fun validate(intent: Intent?) { val data = intent?.data if (data != null && !FlavorConfig.isPremiumFlavor) { @@ -240,41 +337,79 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind } } + /** + * Prefills the license entry field in the license onboarding page with the provided license string. + * + * @param license The license text or key to populate into the license input field. + */ override fun showOrUpdateLicenseEntry(license: String) { pagerAdapter.licenseFragment?.prefillLicense(license) } - // In onboarding, a valid license auto-advances to the next page instead of showing a dialog + /** + * Handles a confirmed license email during onboarding by refreshing license state and advancing to the next onboarding page without presenting a confirmation dialog. + * + * @param mail The email address associated with the confirmed license. + */ override fun showConfirmationDialog(mail: String) { orchestrator.updateState() autoAdvanceToNextPage() } + /** + * Updates the onboarding UI's notification-permission state based on the result of a permission request. + * + * @param granted `true` if notification permission was granted, `false` otherwise. + */ override fun onNotificationPermissionResult(granted: Boolean) { updateNotificationPermissionState(granted) } - // WelcomeLicenseFragment.Listener + /** + * Validate the provided license text and update the license dialog's validation state. + * + * @param license The license text entered by the user, or `null` if the input was cleared. + */ override fun onLicenseTextChanged(license: String?) { welcomePresenter.validateDialogAware(license) } + /** + * Opens the Cryptomator Android license page in a web browser. + * + * Launches an ACTION_VIEW intent for https://cryptomator.org/android/. + */ override fun onOpenLicenseLink() { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://cryptomator.org/android/"))) } + /** + * Starts a trial license, refreshes the license state, and advances the onboarding pager to the next page. + */ override fun onStartTrial() { licenseEnforcer.startTrial() orchestrator.updateState() autoAdvanceToNextPage() } + /** + * Skips the license step and continues the onboarding flow. + * + * Advances to the next onboarding page or completes the welcome flow if currently on the last page. + */ override fun onSkipLicense() { advanceOrComplete() } - // RestoreOutcomeHandler + /** + * Shows a dialog corresponding to the restore operation outcome. + * + * For RestoreOutcome.RESTORED shows a successful-restore dialog, for NOTHING_TO_RESTORE shows a no-full-version dialog, + * and for RestoreOutcome.FAILED shows a failed-restore dialog. + * + * @param outcome The result of a restore attempt. + */ override fun onRestoreOutcome(outcome: RestoreOutcome) { when (outcome) { @@ -284,22 +419,48 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind } } - override fun onRestoreSuccessfulDialogFinished() = Unit - override fun onNoFullVersionDialogFinished() = Unit - override fun onRestoreFailedDialogFinished() = Unit - - // WelcomeNotificationsFragment.Listener + /** + * Called when the restore-successful dialog is dismissed. + * + * Default no-op implementation. + */ +override fun onRestoreSuccessfulDialogFinished() = Unit + /** + * Called when the "no full version" restore dialog is dismissed. + * + * This implementation intentionally performs no action. + */ +override fun onNoFullVersionDialogFinished() = Unit + /** + * Called when the restore-failed dialog is dismissed. + * + * No action is performed in this implementation. + */ +override fun onRestoreFailedDialogFinished() = Unit + + /** + * Initiates the notification permission request flow. + * + * Starts the process to obtain the runtime POST_NOTIFICATIONS permission from the user. + */ override fun onRequestNotifications() { welcomePresenter.requestNotificationPermission() } - // WelcomeScreenLockFragment.Listener + /** + * Requests enabling or disabling the device screen lock via the presenter. + * + * @param setScreenLock `true` to enable the device screen lock, `false` to disable it. + */ override fun onSetScreenLock(setScreenLock: Boolean) { welcomePresenter.onSetScreenLock(setScreenLock) } + /** + * Advances the onboarding pager to the next page or finishes the welcome flow when on the last page. + */ private fun advanceOrComplete() { val pos = binding.welcomePager.currentItem if (pos < pagerAdapter.itemCount - 1) { @@ -309,6 +470,12 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind } } + /** + * Schedules an automatic advance to the next pager page after a short delay. + * + * If the activity is still active, the pager remains on the same source page, and the source page + * is not the last page, the pager will move forward by one page after `AUTO_ADVANCE_DELAY_MS`. + */ private fun autoAdvanceToNextPage() { val sourcePage = binding.welcomePager.currentItem binding.welcomePager.postDelayed({ @@ -336,13 +503,29 @@ class WelcomeActivity : BaseActivity(ActivityWelcomeBind val screenLockFragment: WelcomeScreenLockFragment? get() = findPageFragment() + /** + * Returns the fragment instance for the first page of type `P` if that page's fragment is currently instantiated. + * + * @return The fragment of type `F` for the first matching page, or `null` if no matching page exists or its fragment is not created. + */ private inline fun findPageFragment(): F? { val pos = pages.indexOfFirst { it is P } return if (pos >= 0) supportFragmentManager.findFragmentByTag("f$pos") as? F else null } - override fun getItemCount(): Int = pages.size - + /** + * Provides the number of pages the adapter exposes. + * + * @return The number of pages in the adapter. + */ +override fun getItemCount(): Int = pages.size + + /** + * Creates the Fragment instance for the page at the given position. + * + * @param position The index of the page in the adapter's pages list. + * @return The Fragment associated with that page (Intro, License, Notifications, or ScreenLock). + */ override fun createFragment(position: Int): Fragment { return when (pages[position]) { is FragmentPage.Intro -> WelcomeIntroFragment() diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt index 2af11ea37..e7b59b806 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt @@ -11,9 +11,30 @@ interface SharedFilesView : View { fun displayVaults(vaults: List) fun displayFilesToUpload(sharedFiles: List) fun displayDialogUnableToUploadFiles() - fun showReplaceDialog(existingFiles: List, size: Int) - fun showChosenLocation(folder: CloudFolderModel) - fun showUploadDialog(uploadingFiles: Int) - fun setUploadEnabled(enabled: Boolean) + /** + * Displays a confirmation dialog listing files that already exist at the destination and their combined size. + * + * @param existingFiles The names of files that would be replaced if the upload proceeds. + * @param size The total size, in bytes, of the conflicting files. + */ +fun showReplaceDialog(existingFiles: List, size: Int) + /** + * Shows the currently selected destination folder for uploads in the UI. + * + * @param folder The cloud folder chosen as the upload destination. + */ +fun showChosenLocation(folder: CloudFolderModel) + /** + * Shows an upload progress dialog that reflects the current upload operation. + * + * @param uploadingFiles The number of files currently being uploaded to display in the dialog. + */ +fun showUploadDialog(uploadingFiles: Int) + /** + * Enables or disables user-initiated upload actions and related controls in the view. + * + * @param enabled `true` to allow uploads and enable upload controls, `false` to prevent uploads and disable them. + */ +fun setUploadEnabled(enabled: Boolean) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt index bd3d5cf5b..2e1be72d4 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt @@ -2,7 +2,17 @@ package org.cryptomator.presentation.ui.activity.view interface UpdateLicenseView : View { - fun showOrUpdateLicenseEntry(license: String) - fun showConfirmationDialog(mail: String) + /** + * Displays or updates a license entry in the UI for the given license text. + * + * @param license The license text to display or use to update the existing license entry. + */ +fun showOrUpdateLicenseEntry(license: String) + /** + * Presents a confirmation dialog related to the given email address. + * + * @param mail The email address to display or confirm in the dialog. + */ +fun showConfirmationDialog(mail: String) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WelcomeView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WelcomeView.kt index 764c96e61..055eb2ddc 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WelcomeView.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WelcomeView.kt @@ -1,5 +1,10 @@ package org.cryptomator.presentation.ui.activity.view interface WelcomeView : UpdateLicenseView { - fun onNotificationPermissionResult(granted: Boolean) + /** + * Called when the user responds to the notification permission request. + * + * @param granted `true` if notification permission was granted, `false` otherwise. + */ +fun onNotificationPermissionResult(granted: Boolean) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/NoFullVersionDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/NoFullVersionDialog.kt index 426048dbf..8929f0bdf 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/NoFullVersionDialog.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/NoFullVersionDialog.kt @@ -12,9 +12,23 @@ class NoFullVersionDialog : BaseDialog { _ -> setupLicense() } + /** + * Loads the preferences screen and initializes preference-related handlers and UI state. + * + * This inflates preferences from R.xml.preferences, creates the fragment's SharedPreferences handler, + * and runs setup routines that adjust displayed values and remove or configure preferences + * (app version, LRU cache size, license UI, biometric availability, and Cryptomator variants). + */ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { sharedPreferencesHandler = SharedPreferencesHandler(activity()) addPreferencesFromResource(R.xml.preferences) @@ -155,6 +162,11 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { } } + /** + * Updates the DISPLAY_LRU_CACHE_SIZE preference summary to a colored, human-readable representation of the LRU cache size. + * + * Reads the total LRU cache size, formats it into bytes/KB/MB/GB/TB with one decimal place as needed, applies the preference text color span, and sets it as the preference's summary provider. + */ private fun setupLruCacheSize() { val preference = findPreference(DISPLAY_LRU_CACHE_SIZE_ITEM_KEY) as Preference? @@ -183,6 +195,18 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { } } + /** + * Configure license-related preference UI and category entries according to build flavor and stored license/subscription state. + * + * Updates the license preference's title, summary, enabled state, and click behavior; for freemium builds it also adds or removes + * "manage subscription" and "upgrade to lifetime" preferences inside the license category, launches the license check intent when + * appropriate, and initiates the in-app purchase flow and asynchronous price resolution for the lifetime upgrade preference. + * + * Side effects: + * - May start activities via intents (license check, Play subscriptions URL). + * - May call the app's purchase flow. + * - Adds/removes child preferences in the license category and updates their summaries. + */ private fun setupLicense() { val licenseCategory = findPreference(LICENSE_ITEM_KEY) as PreferenceCategory? val licensePref = findPreference(SharedPreferencesHandler.MAIL) as Preference? @@ -304,6 +328,13 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { (findPreference(UPDATE_INTERVAL_ITEM_KEY) as Preference?)?.let { versionCategory?.removePreference(it) } } + /** + * Updates the update-check preference's summary to show the last update check time or a "never" message. + * + * Reads the last update check timestamp from SharedPreferences, formats it using the user's date format, + * applies the app's light text color to the resulting string, and sets it as the summaryProvider for + * the preference identified by `UPDATE_CHECK_ITEM_KEY`. + */ fun setupUpdateCheck() { val preference = findPreference(UPDATE_CHECK_ITEM_KEY) as Preference? @@ -327,6 +358,13 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { } } + /** + * Removes the Cryptomator variants preference from the general settings section on premium builds. + * + * When the app is running in a premium flavor, this locates the preference identified by + * `CRYPTOMATOR_VARIANTS` and removes it from the "general" preference category so the option + * is not shown to premium users. + */ private fun setupCryptomatorVariants() { if (FlavorConfig.isPremiumFlavor) { (findPreference(CRYPTOMATOR_VARIANTS) as Preference?)?.let { preference -> @@ -335,6 +373,15 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { } } + /** + * Registers preference click/change listeners, assigns navigation intents to preferences, + * and attaches the license-change listener when the fragment becomes active. + * + * This activates UI handlers for error reports, cache operations, debug and security toggles, + * photo upload and LRU cache controls, Microsoft workaround, and (on APK store builds) the update check. + * It also sets navigation intents for cloud, biometric, Cryptomator variants (omitted on premium builds), + * auto-upload vault chooser, and licenses, then registers `licenseChangeListener` with `sharedPreferencesHandler`. + */ override fun onResume() { super.onResume() (findPreference(SEND_ERROR_REPORT_ITEM_KEY) as Preference?)?.onPreferenceClickListener = sendErrorReportClickListener @@ -362,11 +409,23 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { sharedPreferencesHandler.addLicenseChangedListeners(licenseChangeListener) } + /** + * Cleans up fragment-level listeners and lifecycle state when the fragment is paused. + * + * Removes the license change listener from the shared preferences handler and then + * delegates to the superclass `onPause` implementation. + */ override fun onPause() { sharedPreferencesHandler.removeLicenseChangedListeners(licenseChangeListener) super.onPause() } + /** + * Disables debug mode and updates the corresponding switch in the settings UI to unchecked. + * + * This clears the debug-mode flag in shared preferences and sets the `DEBUG_MODE` SwitchPreference's + * checked state to `false` if the preference is present. + */ fun deactivateDebugMode() { sharedPreferencesHandler.setDebugMode(false) (findPreference(SharedPreferencesHandler.DEBUG_MODE) as SwitchPreference?)?.isChecked = false diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SharedFilesFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SharedFilesFragment.kt index e3003067b..648bc58df 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SharedFilesFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SharedFilesFragment.kt @@ -84,10 +84,22 @@ class SharedFilesFragment : BaseFragment(FragmentSha filesAdapter.show(files) } + /** + * Marks the given cloud folder as the currently selected location in the UI. + * + * If the folder's path is empty, the root path ("/") is selected instead. + * + * @param folder Cloud folder to show as the chosen location. + */ fun showChosenLocation(folder: CloudFolderModel) { locationsAdapter.setSelectedLocation(if (folder.path.isEmpty()) "/" else folder.path) } + /** + * Enables or disables the upload/save button in the fragment UI. + * + * @param enabled True to enable the upload/save button, false to disable it. + */ fun setUploadEnabled(enabled: Boolean) { binding.receiveSaveLayout.saveFiles.isEnabled = enabled } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt index 00ee27626..47d8f6bc7 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt @@ -31,16 +31,34 @@ class TextEditorFragment : BaseFragment(FragmentTextE textEditorPresenter.loadFileContent() } + /** + * Sets the editor's text to the provided content. + * + * @param textFileContent The text to display in the editor; passing `null` clears the editor. + */ fun displayTextFileContent(textFileContent: String?) { binding.textEditor.setText(textFileContent) } + /** + * Sets the text editor to read-only mode. + * + * Disables focus and cursor visibility so the user cannot edit or place the caret in the editor. + */ fun setReadOnly() { binding.textEditor.isFocusable = false binding.textEditor.isFocusableInTouchMode = false binding.textEditor.isCursorVisible = false } + /** + * Initiates a new search for the given query, clears existing highlights, and jumps to the first match. + * + * Clears current highlight spans; if `query` is empty the method returns after clearing highlights. + * Otherwise resets the search position and advances to the next match so the first occurrence is selected and brought into view. + * + * @param query The search text to locate and highlight in the editor. + */ fun onQueryText(query: String) { textEditorPresenter.query = query diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeIntroFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeIntroFragment.kt index 5733b4064..aecfa8389 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeIntroFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeIntroFragment.kt @@ -6,6 +6,11 @@ import org.cryptomator.presentation.databinding.FragmentWelcomeIntroBinding @Fragment class WelcomeIntroFragment : BaseFragment(FragmentWelcomeIntroBinding::inflate) { + /** + * Performs any view setup required by the fragment. + * + * This fragment uses only static content and therefore requires no runtime view initialization. + */ override fun setupView() { // static content only } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt index d18c5f234..29fda0475 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt @@ -17,10 +17,24 @@ import org.cryptomator.util.FlavorConfig class WelcomeLicenseFragment : BaseFragment(FragmentWelcomeLicenseBinding::inflate) { interface Listener { - fun onLicenseTextChanged(license: String?) - fun onOpenLicenseLink() - fun onStartTrial() - fun onSkipLicense() + /** + * Notifies the listener that the license input has changed. + * + * @param license The current license text, or `null` if the input is empty or cleared. + */ +fun onLicenseTextChanged(license: String?) + /** + * Handle a user request to open the license (legal information) link. + */ +fun onOpenLicenseLink() + /** + * Signals that the user requested to start a trial period. + */ +fun onStartTrial() + /** + * Invoked when the user skips the license entry step. + */ +fun onSkipLicense() } private val licenseContentViewBinder by lazy { LicenseContentViewBinder(binding.licenseContent, FlavorConfig.isFreemiumFlavor) } @@ -28,20 +42,39 @@ class WelcomeLicenseFragment : BaseFragment(Fragm private val debounceHandler = Handler(Looper.getMainLooper()) private var debounceRunnable: Runnable? = null + /** + * Attaches the fragment to the given context and assigns the fragment's `listener` if the context + * implements `Listener`. + * + * @param context The context the fragment is being attached to; assigned to `listener` when it + * implements `WelcomeLicenseFragment.Listener`, otherwise `listener` remains `null`. + */ override fun onAttach(context: Context) { super.onAttach(context) listener = context as? Listener } + /** + * Prepares and initializes the fragment's user interface after the view is created. + */ override fun setupView() { setupUi() } + /** + * Removes any pending debounce callback for license input and then performs standard view teardown. + */ override fun onDestroyView() { debounceRunnable?.let { debounceHandler.removeCallbacks(it) } super.onDestroyView() } + /** + * Configure the fragment's UI for the current build flavor. + * + * Initializes the in-app purchase UI when the app is the freemium flavor; otherwise + * initializes the license-entry UI. + */ private fun setupUi() { if (FlavorConfig.isFreemiumFlavor) { setupIapUi() @@ -50,6 +83,12 @@ class WelcomeLicenseFragment : BaseFragment(Fragm } } + /** + * Configures the in-app purchase UI: sets the initial IAP layout, binds legal links, + * wires purchase and trial buttons, and loads current prices. + * + * The trial button is wired to invoke the fragment's `Listener.onStartTrial()` when clicked. + */ private fun setupIapUi() { val app = requireActivity().application as CryptomatorApp licenseContentViewBinder.bindInitialIapLayout() @@ -62,6 +101,15 @@ class WelcomeLicenseFragment : BaseFragment(Fragm licenseContentViewBinder.loadAndBindPrices(app) } + /** + * Configures the license-entry UI: binds the license-with-trial layout, wires trial and license-link + * buttons to the fragment listener, hides the purchase button, and installs a debounced text watcher + * on the license input. + * + * The text watcher clears any pending callbacks on each edit and, after a delay defined by + * DEBOUNCE_DELAY_MS, notifies the listener of non-blank license text via `onLicenseTextChanged`. + * If the input is blank or null, the listener is notified immediately with `null`. + */ private fun setupLicenseEntryUi() { licenseContentViewBinder.bindInitialLicenseEntryWithTrialLayout() binding.licenseContent.btnTrial.text = getString(R.string.screen_welcome_trial_button) @@ -86,6 +134,14 @@ class WelcomeLicenseFragment : BaseFragment(Fragm }) } + /** + * Update the purchase UI to reflect whether features are unlocked and whether the user has a paid license. + * + * If the fragment is not attached to its activity, the call is ignored. + * + * @param unlocked `true` if features are unlocked, `false` otherwise. + * @param hasPaidLicense `true` if the user holds a paid license, `false` otherwise. + */ fun updateUnlocked(unlocked: Boolean, hasPaidLicense: Boolean) { if (!isAdded) { return @@ -93,6 +149,15 @@ class WelcomeLicenseFragment : BaseFragment(Fragm licenseContentViewBinder.bindPurchaseState(unlocked, hasPaidLicense) } + /** + * Updates the UI to reflect the current trial subscription state. + * + * No-op if the fragment is not currently attached. + * + * @param active `true` if a trial is currently active, `false` otherwise. + * @param expired `true` if the trial has expired, `false` otherwise. + * @param expirationText Optional text describing the trial expiration (e.g., remaining time); `null` to clear any expiration label. + */ fun updateTrialState(active: Boolean, expired: Boolean, expirationText: String?) { if (!isAdded) { return @@ -100,6 +165,13 @@ class WelcomeLicenseFragment : BaseFragment(Fragm licenseContentViewBinder.bindTrialState(active, expired, expirationText) } + /** + * Loads product prices from the provided application and binds them to the license UI. + * + * No-op if the fragment is not attached to its activity. + * + * @param app Application instance used to load product prices. + */ fun loadAndBindPrices(app: CryptomatorApp) { if (!isAdded) { return @@ -107,6 +179,14 @@ class WelcomeLicenseFragment : BaseFragment(Fragm licenseContentViewBinder.loadAndBindPrices(app) } + /** + * Populates the license input and shows or hides the license-entry group depending on the app flavor. + * + * If the fragment is not attached, this method does nothing. When attached, it sets `etLicense` to the provided + * `license` string and hides `licenseEntryGroup` if `FlavorConfig.isFreemiumFlavor` is true, otherwise shows it. + * + * @param license The license string to place into the license input field. + */ fun prefillLicense(license: String) { if (!isAdded) { return diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeNotificationsFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeNotificationsFragment.kt index 89727e4b9..49e20cf4d 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeNotificationsFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeNotificationsFragment.kt @@ -9,26 +9,62 @@ import org.cryptomator.presentation.databinding.FragmentWelcomeNotificationsBind class WelcomeNotificationsFragment : BaseFragment(FragmentWelcomeNotificationsBinding::inflate) { interface Listener { - fun onRequestNotifications() + /** + * Notifies the host that the user has requested notification permission. + * + * Implementations should initiate the platform's notification-permission flow or otherwise + * handle enabling notifications for the app in response to the user's request. + */ +fun onRequestNotifications() } private var listener: Listener? = null + /** + * Attaches the fragment to its host and assigns the listener if the host implements it. + * + * If `context` implements `WelcomeNotificationsFragment.Listener`, it is stored in `listener`; + * otherwise `listener` remains `null`. + * + * @param context The host `Context` provided by the system, potentially implementing `Listener`. + */ override fun onAttach(context: Context) { super.onAttach(context) listener = context as? Listener } + /** + * Initializes the fragment's view components. + * + * Prepares UI wiring and listeners required when the fragment's view is created. + */ override fun setupView() { setupUi() } + /** + * Wires up the fragment's UI interactions. + * + * Attaches a click listener to the notification permission button that invokes + * the fragment's `Listener.onRequestNotifications()` callback when available. + */ private fun setupUi() { binding.btnNotificationPermission.setOnClickListener { listener?.onRequestNotifications() } } + /** + * Update the fragment's notification-permission UI to reflect the current permission state. + * + * If the fragment is not attached to its host, the call is a no-op. + * + * When `granted` is true the permission button is disabled and the status text is shown; + * when false the button is enabled and the status text is hidden. The permission button + * is always made visible. + * + * @param granted `true` if notification permission is granted, `false` otherwise. + */ fun updatePermissionState(granted: Boolean) { if (!isAdded) { return diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeScreenLockFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeScreenLockFragment.kt index 63e7d8543..8a894438c 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeScreenLockFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeScreenLockFragment.kt @@ -9,26 +9,51 @@ import org.cryptomator.presentation.databinding.FragmentWelcomeScreenLockBinding class WelcomeScreenLockFragment : BaseFragment(FragmentWelcomeScreenLockBinding::inflate) { interface Listener { - fun onSetScreenLock(setScreenLock: Boolean) + /** + * Notifies the host that the user requested to enable or disable the screen lock. + * + * @param setScreenLock `true` to request enabling the screen lock, `false` to request disabling it. + */ +fun onSetScreenLock(setScreenLock: Boolean) } private var listener: Listener? = null + /** + * Attaches the fragment to the given context and captures the host `Listener` if the context implements it. + * + * @param context The Context being attached; if it implements [Listener], it will be stored in the fragment's `listener` property. + */ override fun onAttach(context: Context) { super.onAttach(context) listener = context as? Listener } + /** + * Initializes the fragment's UI elements and listeners. + */ override fun setupView() { setupUi() } + /** + * Installs the click behavior for the "Set screen lock" button so that tapping it notifies the fragment's + * listener with the current checked state of the screen-lock checkbox. + */ private fun setupUi() { binding.btnSetScreenLock.setOnClickListener { listener?.onSetScreenLock(binding.cbSetScreenLock.isChecked) } } + /** + * Updates the fragment UI to reflect whether the device has a secure screen lock configured. + * + * When `isSecure` is true, the "set screen lock" controls are disabled, the checkbox is unchecked, + * and the status text is shown; when false, the controls are enabled and the status text is hidden. + * + * @param isSecure `true` if the device currently has a secure screen lock configured, `false` otherwise. + */ fun updateScreenLockState(isSecure: Boolean) { if (!isAdded) { return diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt index a364c9108..aa17f60c1 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt @@ -22,7 +22,13 @@ class LicenseContentViewBinder( private val context get() = binding.root.context - /** Sets the initial visibility state and button defaults for IAP mode. */ + /** + * Configure the view state for the initial in-app purchase (IAP) layout. + * + * Hides the license entry group, primary purchase button, and license link; + * shows the purchase options group, restore purchase link, and legal links; + * and disables the subscription and lifetime purchase buttons. + */ fun bindInitialIapLayout() { binding.licenseEntryGroup.visibility = View.GONE binding.btnPurchase.visibility = View.GONE @@ -34,7 +40,13 @@ class LicenseContentViewBinder( binding.btnLifetime.isEnabled = false } - /** Sets the initial visibility state for license-entry (non-IAP) mode. */ + /** + * Configure the UI for non-IAP license entry mode. + * + * Shows the license entry group and the license link (setting its text to + * `dialog_enter_license_content`), and hides purchase options, restore purchase, + * and legal links. + */ fun bindInitialLicenseEntryLayout() { binding.licenseEntryGroup.visibility = View.VISIBLE binding.purchaseOptionsGroup.visibility = View.GONE @@ -44,7 +56,12 @@ class LicenseContentViewBinder( binding.tvLicenseLink.text = context.getString(R.string.dialog_enter_license_content) } - /** Sets the initial visibility state for license-entry mode with the trial row visible (welcome flow only). */ + /** + * Configure the license-entry welcome layout with the trial row visible. + * + * Shows the purchase options and the trial row, and hides the subscription and lifetime rows + * along with their separating dividers. + */ fun bindInitialLicenseEntryWithTrialLayout() { bindInitialLicenseEntryLayout() binding.purchaseOptionsGroup.visibility = View.VISIBLE @@ -55,7 +72,11 @@ class LicenseContentViewBinder( binding.rowTrial.visibility = View.VISIBLE } - /** Sets click listeners on Terms and Privacy links. */ + /** + * Opens the Cryptomator Terms and Privacy web pages when the corresponding links are tapped. + * + * Tapping the Terms link opens https://cryptomator.org/terms/ and tapping the Privacy link opens https://cryptomator.org/privacy/ in a browser. + */ fun bindLegalLinks() { binding.tvTerms.setOnClickListener { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://cryptomator.org/terms/"))) @@ -65,7 +86,13 @@ class LicenseContentViewBinder( } } - /** Wires trial, subscription, lifetime, and restore button click listeners. */ + /** + * Wires purchase-related click handlers for trial, subscription, lifetime, and restore actions. + * + * @param activity Activity used as the caller context for launching purchase flows and, if resumed and implementing RestoreOutcomeHandler, receiving restore outcomes. + * @param app Application facade used to start purchase flows and perform restore operations; if the activity cannot receive the restore outcome, the outcome is stored on the app. + * @param onTrialClicked Callback invoked when the trial button is clicked. + */ fun bindPurchaseButtons( activity: Activity, app: CryptomatorApp, @@ -91,7 +118,11 @@ class LicenseContentViewBinder( } } - /** Queries product details and updates price buttons on the UI thread. */ + /** + * Loads localized prices for the subscription and lifetime products and updates the corresponding buttons in the bound view. + * + * Queries product details from the provided app, resolves subscription and lifetime price strings, and posts a UI-thread update to bind those prices to the purchase buttons. + */ fun loadAndBindPrices(app: CryptomatorApp) { app.queryProductDetails { products -> val prices = products.resolveProductPrices() @@ -101,7 +132,14 @@ class LicenseContentViewBinder( } } - /** Updates subscription and lifetime button text and enabled state from resolved prices. */ + /** + * Update subscription and lifetime button labels and enable the corresponding buttons when a non-empty price is provided. + * + * If a price is `null` or empty, the corresponding button is not modified. + * + * @param subscriptionPrice The resolved subscription price string to display, or `null`/empty to leave the subscription button unchanged. + * @param lifetimePrice The resolved lifetime price string to display, or `null`/empty to leave the lifetime button unchanged. + */ fun bindProductPrices(subscriptionPrice: String?, lifetimePrice: String?) { if (!subscriptionPrice.isNullOrEmpty()) { binding.btnSubscription.text = subscriptionPrice @@ -113,7 +151,14 @@ class LicenseContentViewBinder( } } - /** Updates purchase-related view visibility based on license state. */ + /** + * Update the purchase-related UI to reflect whether the app is unlocked or a paid license is present. + * + * When the binder is configured for the freemium flavor, this toggles visibility of purchase options and the restore link based on whether a paid license exists; otherwise it enables or disables the purchase button according to the unlock state and hides trial/info views when a paid license exists. + * + * @param unlocked True if the app is currently unlocked by a license. + * @param hasPaidLicense True if the user has a paid (non-trial) license. + */ fun bindPurchaseState(unlocked: Boolean, hasPaidLicense: Boolean) { if (isFreemiumFlavor) { binding.purchaseOptionsGroup.visibility = if (hasPaidLicense) View.GONE else View.VISIBLE @@ -132,7 +177,13 @@ class LicenseContentViewBinder( } } - /** Updates trial-related view visibility based on trial state. */ + /** + * Update the UI to reflect an active trial, an expired trial, or no trial. + * + * @param active True when a trial is currently active. + * @param expired True when a trial has expired. + * @param expirationText Optional text describing the trial expiration (shown when active or expired). + */ fun bindTrialState(active: Boolean, expired: Boolean, expirationText: String?) { if (active || expired) { binding.trialButtonGroup.visibility = View.GONE diff --git a/presentation/src/nonplaystoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt b/presentation/src/nonplaystoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt index 3dab74ff7..4add19b7b 100644 --- a/presentation/src/nonplaystoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt +++ b/presentation/src/nonplaystoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt @@ -13,27 +13,59 @@ import timber.log.Timber */ class IapBillingService : Service() { + /** + * Performs base Service initialization and logs a debug message indicating the stub billing service was created. + */ override fun onCreate() { super.onCreate() Timber.tag("IapBillingService").d("Stub service created") } - override fun onBind(intent: Intent?): IBinder = Binder() + /** + * Provides a binder exposing the stubbed in-app billing API to clients. + * + * @param intent The intent used to bind to the service; its contents are ignored. + * @return A new `Binder` instance that exposes no-op billing methods and returns empty/default results. + */ +override fun onBind(intent: Intent?): IBinder = Binder() class Binder : android.os.Binder() { + /** + * Performs initialization for the billing binder used in app flavors without Google Play Billing. + * + * This implementation intentionally does nothing; the provided `context` is ignored. + * + * @param context Android `Context` that is accepted for API compatibility but not used. + */ fun init(context: Context) { // no-op } + /** + * Placeholder for initiating an in-app purchase; in this stub implementation it performs no action. + * + * @param activity WeakReference to the calling Activity; ignored by this implementation. + * @param productId The ID of the product to purchase; ignored by this implementation. + */ fun startPurchaseFlow(activity: WeakReference, productId: String) { // no-op } + /** + * Immediately invokes the provided callback with an empty list of product details. + * + * @param callback Called with the available `ProductInfo` items; in this stub implementation it is invoked with an empty `List`. + */ fun queryProductDetails(callback: (List) -> Unit) { callback(emptyList()) } + /** + * Notifies the caller that there are no purchases to restore and invokes the provided completion callback. + * + * @param onComplete Callback that receives the restore outcome; invoked with `RestoreOutcome.NOTHING_TO_RESTORE`. + */ fun restorePurchases(onComplete: (RestoreOutcome) -> Unit) { onComplete(RestoreOutcome.NOTHING_TO_RESTORE) } diff --git a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt index eae6bdf61..df2ec0698 100644 --- a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt +++ b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/IapBillingService.kt @@ -37,6 +37,14 @@ class IapBillingService : Service(), PurchasesUpdatedListener { private val productDetailsMap = ConcurrentHashMap() private val pendingProductDetailsCallbacks = mutableListOf<(List) -> Unit>() + /** + * Initializes billing-related components, configures the Google Play BillingClient, and starts the billing connection. + * + * When the connection is successfully established, existing purchases are queried/restored and any queued + * product-details callbacks are flushed by querying product details and invoking each queued callback with the results. + * + * @param context Context used to create the billing client and related helpers. + */ private fun initBillingClient(context: Context) { this.sharedPreferencesHandler = SharedPreferencesHandler(context) this.purchaseManager = PurchaseManager(sharedPreferencesHandler) @@ -75,11 +83,21 @@ class IapBillingService : Service(), PurchasesUpdatedListener { }) } + /** + * Called when the service is created. + * + * Performs standard service initialization for this Service implementation. + */ override fun onCreate() { super.onCreate() Timber.tag("IapBillingService").d("Service created") } + /** + * Initiates a restore/refresh of existing purchases and acknowledges any pending transactions. + * + * @param onComplete Callback invoked with the resulting `RestoreOutcome` when the refresh completes. + */ fun queryExistingPurchases(onComplete: (RestoreOutcome) -> Unit = {}) { purchaseRefreshCoordinator.refresh( billingClient = billingClient, @@ -89,6 +107,16 @@ class IapBillingService : Service(), PurchasesUpdatedListener { ) } + /** + * Acknowledges a completed purchase with Google Play Billing and retries once on transient failures. + * + * Sends an acknowledge request for the given purchase token to the configured BillingClient. If the + * billing response indicates a transient failure (service disconnected, service unavailable, or + * generic error), the method retries the acknowledge exactly once. + * + * @param purchaseToken The purchase token to acknowledge. + * @param isRetry Internal flag indicating this invocation is a retry; callers should not set this to `true`. + */ private fun acknowledgePurchase(purchaseToken: String, isRetry: Boolean = false) { val params = AcknowledgePurchaseParams.newBuilder() .setPurchaseToken(purchaseToken) @@ -115,6 +143,13 @@ class IapBillingService : Service(), PurchasesUpdatedListener { } } + /** + * Fetches product details for the configured INAPP and SUBS products and delivers an aggregated list of ProductInfo to the provided callback. + * + * The method queries INAPP and SUBS product details separately (per Billing Library requirements), caches returned ProductDetails, and invokes `callback` once both queries complete. If the billing client is not ready, the callback is enqueued and will be invoked after billing initialization completes. + * + * @param callback Invoked with a list of ProductInfo where each entry contains the product ID and its formatted price (empty string if unavailable). + */ fun queryProductDetails(callback: (List) -> Unit) { if (!billingClient.isReady) { synchronized(pendingProductDetailsCallbacks) { @@ -194,6 +229,15 @@ class IapBillingService : Service(), PurchasesUpdatedListener { } } + /** + * Initiates the Play Billing flow for the given product using cached product details. + * + * If product details for the specified productId are not available, shows a short toast + * informing the user that the purchase is not available and does nothing else. + * + * @param activity WeakReference to an Activity used to display UI and to launch the billing flow. + * @param productId The product identifier to purchase; must match an entry in the cached product details. + */ fun launchPurchaseFlow(activity: WeakReference, productId: String) { val details = productDetailsMap[productId] if (details == null) { @@ -217,6 +261,15 @@ class IapBillingService : Service(), PurchasesUpdatedListener { activity.get()?.let { billingClient.launchBillingFlow(it, billingFlowParams) } } + /** + * Handles purchase updates from the Play Billing library and acts on their outcome. + * + * Routes successful purchases to the purchase manager for processing and acknowledges them. + * Logs user cancellations and logs failures along with the billing response code. + * + * @param billingResult The billing result containing the response code and additional info. + * @param purchases The list of updated purchases, or `null` if none are provided. + */ override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List?) { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { purchaseManager.handleInAppPurchases(purchases) { token -> acknowledgePurchase(token) } @@ -229,6 +282,11 @@ class IapBillingService : Service(), PurchasesUpdatedListener { } } + /** + * Cleans up resources by ending the billing client connection if initialized and logs service destruction. + * + * This method ensures the BillingClient connection is closed to avoid leaks before the service is destroyed. + */ override fun onDestroy() { super.onDestroy() if (::billingClient.isInitialized) { @@ -237,22 +295,50 @@ class IapBillingService : Service(), PurchasesUpdatedListener { Timber.tag("IapBillingService").i("Service destroyed") } - override fun onBind(intent: Intent?): IBinder = Binder(this) + /** + * Provide a Binder instance that clients use to interact with this service. + * + * @return An IBinder exposing the service's Binder tied to this IapBillingService instance. + */ +override fun onBind(intent: Intent?): IBinder = Binder(this) class Binder(private val service: IapBillingService) : android.os.Binder() { + /** + * Initializes the billing client and purchase-related components using the provided Context. + * + * @param context Context used to initialize the billing client (prefer the application context). + */ fun init(context: Context) { service.initBillingClient(context) } + /** + * Starts the purchase flow for the specified product using the given Activity reference. + * + * @param activity A weak reference to the Activity used to launch the billing UI; may be cleared if the Activity is no longer available. + * @param productId The product identifier to purchase. + */ fun startPurchaseFlow(activity: WeakReference, productId: String) { service.launchPurchaseFlow(activity, productId) } + /** + * Requests current product details and invokes the callback with the results when available. + * + * The callback receives a list of ProductInfo entries containing product IDs and their formatted prices. + * + * @param callback Invoked with the retrieved list of ProductInfo once the query completes. + */ fun queryProductDetails(callback: (List) -> Unit) { service.queryProductDetails(callback) } + /** + * Requests restoration (refresh) of existing purchases and invokes the provided callback with the result. + * + * @param onComplete Callback invoked with the resulting [RestoreOutcome] once the restore/refresh operation completes. + */ fun restorePurchases(onComplete: (RestoreOutcome) -> Unit) { service.queryExistingPurchases(onComplete) } diff --git a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseManager.kt b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseManager.kt index bebe48575..2cb345e83 100644 --- a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseManager.kt +++ b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseManager.kt @@ -8,6 +8,18 @@ class PurchaseManager( private val sharedPreferencesHandler: SharedPreferencesHandler ) { + /** + * Updates the persisted license token based on the provided in-app purchases for the full-version product. + * + * Iterates the purchases and, on finding a `PURCHASED` entry for the full-version product, persists the purchase token + * if no token existed before and invokes the `acknowledgePurchase` callback for unacknowledged purchases. + * If no matching `PURCHASED` purchase is found and `clearIfNotFound` is true, clears the stored license token. + * + * @param purchases The list of Play Billing `Purchase` objects to inspect. + * @param clearIfNotFound If true, clear the stored license token when no matching purchased full-version is found. + * @param acknowledgePurchase Callback invoked with a purchase token to acknowledge an unacknowledged purchase. + * @return A `PurchaseFieldChange` describing the license state before and after processing and whether the token was cleared. + */ fun handleInAppPurchases(purchases: List, clearIfNotFound: Boolean = false, acknowledgePurchase: (String) -> Unit): PurchaseFieldChange { val tokenBefore = sharedPreferencesHandler.licenseToken() val before = tokenBefore.isNotEmpty() @@ -38,6 +50,19 @@ class PurchaseManager( return PurchaseFieldChange(before = before, after = before, cleared = false) } + /** + * Updates persisted subscription state based on the provided Google Play purchases. + * + * Sets the stored running-subscription flag to true when a `PURCHASED` purchase for + * `ProductInfo.PRODUCT_YEARLY_SUBSCRIPTION` is found (and invokes the provided acknowledgement + * callback for unacknowledged purchases). If no matching `PURCHASED` purchase is found and + * `clearIfNotFound` is true, clears the stored running-subscription flag. + * + * @param purchases The list of Google Play `Purchase` objects to inspect. + * @param clearIfNotFound If true, clears the stored subscription state when no matching purchase is found. + * @param acknowledgePurchase Callback invoked with a purchase token to acknowledge an unacknowledged purchase. + * @return A `PurchaseFieldChange` describing the subscription state before and after processing and whether it was cleared. + */ fun handleSubscriptionPurchases(purchases: List, clearIfNotFound: Boolean = false, acknowledgePurchase: (String) -> Unit): PurchaseFieldChange { val before = sharedPreferencesHandler.hasRunningSubscription() for (purchase in purchases) { diff --git a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt index 6fe6bc4a8..d0ef9d9b0 100644 --- a/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt +++ b/presentation/src/playstoreiap/java/org/cryptomator/presentation/service/PurchaseRefreshCoordinator.kt @@ -15,7 +15,24 @@ class PurchaseRefreshCoordinator( ) { // Play Billing async callbacks run on the main thread per BillingClient docs, but we still guard the aggregation - // counter and the per-query field writes with a shared lock for defensive correctness. + /** + * Orchestrates a refresh of Play Store purchases for both INAPP and SUBS, aggregates their results, + * and invokes a single completion callback with the overall restore outcome. + * + * Performs two asynchronous queries (INAPP and SUBS), processes results via the provided + * PurchaseManager, and ensures `onComplete` is called at most once with: + * - `RestoreOutcome.RESTORED` if either product set indicates restored purchases, + * - `RestoreOutcome.NOTHING_TO_RESTORE` if no changes were found, + * - `RestoreOutcome.FAILED` on any query or unexpected error. + * + * If write access to license state was lost during the refresh and a product set was cleared, + * a pending purchase-revoked state is recorded in shared preferences with an appropriate reason. + * + * @param billingClient Play Billing client used to query purchases. + * @param purchaseManager Handles processing of the queried INAPP and SUBS purchase lists. + * @param acknowledge Callback invoked with a purchase token to acknowledge a purchase when required. + * @param onComplete Callback invoked once with the aggregated `RestoreOutcome`. + */ fun refresh( billingClient: BillingClient, purchaseManager: PurchaseManager, @@ -23,6 +40,11 @@ class PurchaseRefreshCoordinator( onComplete: (RestoreOutcome) -> Unit, ) { val completed = AtomicBoolean(false) + /** + * Invokes the final completion callback exactly once with the provided outcome. + * + * @param outcome The restore outcome to deliver to the `onComplete` callback. + */ fun complete(outcome: RestoreOutcome) { if (completed.compareAndSet(false, true)) { onComplete(outcome) @@ -43,6 +65,13 @@ class PurchaseRefreshCoordinator( val hadWriteAccessBefore = licenseEnforcer.hasWriteAccess() + /** + * Finalizes the two purchase queries, determines the final restore outcome, and updates revoked state when applicable. + * + * If a failure was recorded or either purchase result is missing, completes with `RestoreOutcome.FAILED`. + * If write access was present before but is now absent and either purchase set was cleared, marks a pending purchase-revoked state with an appropriate `PurchaseRevokedReason`. + * Otherwise, completes with `RestoreOutcome.RESTORED` if either purchase result indicates a restored item, or `RestoreOutcome.NOTHING_TO_RESTORE` if not. + */ fun onSettled() { val localInapp = inappChange val localSubs = subsChange @@ -68,6 +97,12 @@ class PurchaseRefreshCoordinator( complete(outcome) } + /** + * Records completion of a single purchase query and, when all queries have finished, invokes settlement. + * + * Increments the shared completed-query counter under the coordinator lock and calls `onSettled()` once + * the number of completed queries equals the expected total. + */ fun onQueryComplete() { val ready: Boolean synchronized(lock) { diff --git a/util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt b/util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt index f465d1f89..cfa5732c3 100644 --- a/util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt +++ b/util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt @@ -162,19 +162,46 @@ constructor(context: Context) : SharedPreferences.OnSharedPreferenceChangeListen return defaultSharedPreferences.getValue(PHOTO_UPLOAD_INCLUDING_VIDEOS, false) } + /** + * Indicates whether use of an LRU cache is enabled. + * + * @return `true` if LRU cache usage is enabled, `false` otherwise. + */ fun useLruCache(): Boolean { return defaultSharedPreferences.getValue(USE_LRU_CACHE, false) } + /** + * Registers a listener to be notified when the license token or related subscription/trial preferences change. + * + * The listener is invoked immediately with the current license token and will receive updates on subsequent changes. + * + * @param listener Consumer that receives the current and future license token values. + */ fun addLicenseChangedListeners(listener: Consumer) { licenseChangedListeners[listener] = null listener.accept(licenseToken()) } + /** + * Unregisters a previously added listener so it no longer receives license-change notifications. + * + * @param listener The listener to remove from the registry of license change subscribers. + */ fun removeLicenseChangedListeners(listener: Consumer) { licenseChangedListeners.remove(listener) } +/** + * Handles preference changes by notifying registered listeners for affected keys. + * + * When `LOCK_TIMEOUT` changes, notifies all registered lock-timeout listeners with the current + * `lockTimeout`. When `LICENSE_TOKEN`, `TRIAL_EXPIRATION_DATE`, or `HAS_RUNNING_SUBSCRIPTION` + * change, notifies all registered license listeners with the current `licenseToken()`. + * + * @param sharedPreferences The SharedPreferences instance where the change occurred. + * @param key The key of the changed preference, or null if unspecified. + */ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { when (key) { LOCK_TIMEOUT -> { @@ -191,6 +218,13 @@ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key } } + /** + * Gets the configured LRU cache size in bytes. + * + * If the preference is not set, defaults to 100 MB. + * + * @return The cache size in bytes. + */ fun lruCacheSize(): Int { return defaultSharedPreferences.getValue(LRU_CACHE_SIZE, "100").toInt() * 1024 * 1024 } @@ -199,42 +233,96 @@ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key return defaultSharedPreferences.getValue(MAIL, "") } + /** + * Stores the user's email address in shared preferences. + * + * @param mail The email address to persist (may be empty to clear). + */ fun setMail(mail: String) { defaultSharedPreferences.setValue(MAIL, mail) } + /** + * Retrieves the current stored license token or an empty string if none is set. + * + * @return The stored license token, or an empty string when not set. + */ fun licenseToken(): String { return defaultSharedPreferences.getValue(LICENSE_TOKEN, "") } + /** + * Stores the provided license token in the application's shared preferences. + * + * @param licenseToken The license token to persist. + */ fun setLicenseToken(licenseToken: String) { defaultSharedPreferences.setValue(LICENSE_TOKEN, licenseToken) } + /** + * Gets the stored trial expiration timestamp. + * + * @return The trial expiration time in milliseconds since the Unix epoch, or `0` if not set. + */ fun trialExpirationDate(): Long { return defaultSharedPreferences.getValue(TRIAL_EXPIRATION_DATE, 0L) } + /** + * Stores the trial expiration timestamp in shared preferences. + * + * @param date The expiration timestamp in milliseconds since epoch. + */ fun setTrialExpirationDate(date: Long) { defaultSharedPreferences.setValue(TRIAL_EXPIRATION_DATE, date) } + /** + * Determines whether the trial period has been marked as expired. + * + * @return `true` if the trial is expired, `false` otherwise. + */ fun isTrialExpired(): Boolean { return defaultSharedPreferences.getValue(TRIAL_EXPIRED, false) } + /** + * Marks whether the trial period is expired in shared preferences. + * + * @param value `true` if the trial is expired, `false` otherwise. + */ fun setTrialExpired(value: Boolean) { defaultSharedPreferences.setValue(TRIAL_EXPIRED, value) } + /** + * Indicates whether a purchase revocation is pending. + * + * @return `true` if a purchase revocation is pending according to stored preferences, `false` otherwise. + */ fun purchaseRevokedPending(): Boolean { return defaultSharedPreferences.getValue(PURCHASE_REVOKED_PENDING, false) } + /** + * Retrieves the stored reason for a revoked purchase. + * + * @return The revocation reason string, or an empty string if no reason is set. + */ fun purchaseRevokedReason(): String { return defaultSharedPreferences.getValue(PURCHASE_REVOKED_REASON, "") } + /** + * Stores the purchase revocation state and associated reason in preferences. + * + * Writes the `pending` flag to the `PURCHASE_REVOKED_PENDING` preference and the `reason` + * string to the `PURCHASE_REVOKED_REASON` preference. + * + * @param pending `true` if a purchase revocation is pending, `false` otherwise. + * @param reason A human-readable reason explaining why the purchase was revoked or is pending revocation. + */ fun setPurchaseRevokedState(pending: Boolean, reason: String) { defaultSharedPreferences.edit { editor -> editor.putBoolean(PURCHASE_REVOKED_PENDING, pending) @@ -242,18 +330,38 @@ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key } } + /** + * Clears any recorded purchase-revocation state. + * + * Marks revocation as not pending and clears the revocation reason. + */ fun clearPurchaseRevokedState() { setPurchaseRevokedState(pending = false, reason = "") } + /** + * Indicates whether a running subscription is recorded in preferences. + * + * @return `true` if a running subscription is recorded, `false` otherwise. + */ fun hasRunningSubscription(): Boolean { return defaultSharedPreferences.getValue(HAS_RUNNING_SUBSCRIPTION, false) } + /** + * Sets whether the user currently has an active subscription. + * + * @param value `true` if the user has a running subscription, `false` otherwise. + */ fun setHasRunningSubscription(value: Boolean) { defaultSharedPreferences.setValue(HAS_RUNNING_SUBSCRIPTION, value) } + /** + * Indicates whether the app should keep the vault unlocked while the user is editing. + * + * @return `true` if keeping unlocked while editing is enabled, `false` otherwise. + */ fun keepUnlockedWhileEditing(): Boolean { return defaultSharedPreferences.getBoolean(KEEP_UNLOCKED_WHILE_EDITING, false) } @@ -347,18 +455,38 @@ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key defaultSharedPreferences.setValue(MICROSOFT_WORKAROUND, enabled) } + /** + * Indicates whether the Microsoft workaround is enabled. + * + * @return `true` if the Microsoft workaround is enabled, `false` otherwise (defaults to `false`). + */ fun microsoftWorkaround(): Boolean { return defaultSharedPreferences.getBoolean(MICROSOFT_WORKAROUND, false) } + /** + * Indicates whether the user has completed the application's welcome flow. + * + * @return `true` if the welcome flow has been completed, `false` otherwise. + */ fun hasCompletedWelcomeFlow(): Boolean { return defaultSharedPreferences.getValue(WELCOME_FLOW_COMPLETED, false) } + /** + * Marks the welcome flow as completed by storing a boolean flag in shared preferences. + */ fun setWelcomeFlowCompleted() { defaultSharedPreferences.setValue(WELCOME_FLOW_COMPLETED, true) } + /** + * Adds the given host to the persisted set of trusted hub hosts. + * + * The host is stored in preferences under the `TRUSTED_HUB_HOSTS` key and persisted immediately. + * + * @param host The host to add (e.g., "example.com"). + */ fun addTrustedHubHosts(host: String) { val hosts = defaultSharedPreferences .getStringSet(TRUSTED_HUB_HOSTS, emptySet())