diff --git a/buildsystem/dependencies.gradle b/buildsystem/dependencies.gradle index 9073c6d6b..34c876f80 100644 --- a/buildsystem/dependencies.gradle +++ b/buildsystem/dependencies.gradle @@ -105,6 +105,8 @@ ext { recyclerViewFastScrollVersion = '2.0.1' + billingclientVersion = '8.2.1' + // testing dependencies jUnitVersion = '5.11.4' @@ -202,7 +204,8 @@ ext { zxcvbn : "com.nulab-inc:zxcvbn:${zxcvbnVersion}", scaleImageView : "com.github.cryptomator:subsampling-scale-image-view:${scaleImageViewVersion}", lruFileCache : "com.github.solkin:disk-lru-cache:${lruFileCacheVersion}", - jsonWebToken : "com.auth0:java-jwt:${jsonWebTokenVersion}" + jsonWebToken : "com.auth0:java-jwt:${jsonWebTokenVersion}", + billing : "com.android.billingclient:billing:${billingclientVersion}" ] } diff --git a/data/build.gradle b/data/build.gradle index 71c24e195..58a17aeac 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -49,6 +49,10 @@ android { dimension "version" } + playstoreiap { + dimension "version" + } + apkstore { dimension "version" } @@ -71,6 +75,10 @@ android { java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/apkStorePlaystore/java/'] } + playstoreiap { + java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/apkStorePlaystore/java/'] + } + apkstore { java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/apkStorePlaystore/java/'] } @@ -89,7 +97,7 @@ android { } packagingOptions { resources { - excludes += ['META-INF/DEPENDENCIES', 'META-INF/NOTICE.md', 'META-INF/INDEX.LIST'] + excludes += ['META-INF/DEPENDENCIES', 'META-INF/NOTICE.md', 'META-INF/INDEX.LIST', 'META-INF/versions/9/OSGI-INF/MANIFEST.MF'] } } @@ -102,7 +110,7 @@ android { } greendao { - schemaVersion 13 + schemaVersion 14 } configurations.all { @@ -112,13 +120,20 @@ configurations.all { dependencies { def dependencies = rootProject.ext.dependencies + def cloudFlavors = ['playstore', 'playstoreiap', 'apkstore', 'fdroid', 'accrescent'] + def googleFlavors = ['playstore', 'playstoreiap', 'apkstore'] + def addToFlavors = { flavors, dep, Closure config = null -> + flavors.each { flavor -> + if (config) { + add("${flavor}Implementation", dep, config) + } else { + add("${flavor}Implementation", dep) + } + } + } implementation project(':domain') implementation project(':util') - playstoreImplementation dependencies.pcloud - apkstoreImplementation dependencies.pcloud - fdroidImplementation dependencies.pcloud - accrescentImplementation dependencies.pcloud coreLibraryDesugaring dependencies.coreDesugaring @@ -134,63 +149,30 @@ dependencies { implementation dependencies.jsonWebToken // cloud - playstoreImplementation dependencies.dropboxCore - playstoreImplementation dependencies.dropboxAndroid - apkstoreImplementation dependencies.dropboxCore - apkstoreImplementation dependencies.dropboxAndroid - fdroidImplementation dependencies.dropboxCore - fdroidImplementation dependencies.dropboxAndroid - accrescentImplementation dependencies.dropboxCore - accrescentImplementation dependencies.dropboxAndroid - - - playstoreImplementation dependencies.msgraphAuth - apkstoreImplementation dependencies.msgraphAuth - fdroidImplementation dependencies.msgraphAuth - accrescentImplementation dependencies.msgraphAuth - playstoreImplementation dependencies.msgraph - apkstoreImplementation dependencies.msgraph - fdroidImplementation dependencies.msgraph - accrescentImplementation dependencies.msgraph + addToFlavors(cloudFlavors, dependencies.dropboxCore) + addToFlavors(cloudFlavors, dependencies.dropboxAndroid) + + addToFlavors(cloudFlavors, dependencies.msgraphAuth) + addToFlavors(cloudFlavors, dependencies.msgraph) + + addToFlavors(cloudFlavors, dependencies.pcloud) implementation dependencies.stax api dependencies.minIo - playstoreImplementation(dependencies.googlePlayServicesAuth) { + addToFlavors(googleFlavors, dependencies.googlePlayServicesAuth) { exclude module: 'guava-jdk5' exclude module: 'httpclient' exclude module: 'googlehttpclient' exclude group: "com.google.http-client", module: "google-http-client" } - apkstoreImplementation(dependencies.googlePlayServicesAuth) { - exclude module: 'guava-jdk5' - exclude module: 'httpclient' - exclude module: "google-http-client" - exclude group: "com.google.http-client", module: "google-http-client" - } - - playstoreImplementation(dependencies.googleApiServicesDrive) { + addToFlavors(googleFlavors, dependencies.googleApiServicesDrive) { exclude module: 'guava-jdk5' exclude module: 'httpclient' exclude module: 'googlehttpclient' exclude group: "com.google.http-client", module: "google-http-client" } - apkstoreImplementation(dependencies.googleApiServicesDrive) { - exclude module: 'guava-jdk5' - exclude module: 'httpclient' - exclude module: "google-http-client" - exclude group: "com.google.http-client", module: "google-http-client" - } - - playstoreImplementation(dependencies.googleApiClientAndroid) { - exclude module: 'guava-jdk5' - exclude module: 'httpclient' - exclude module: "google-http-client" - exclude module: "jetified-google-http-client" - exclude group: "com.google.http-client", module: "google-http-client" - exclude group: "com.google.http-client", module: "jetified-google-http-client" - } - apkstoreImplementation(dependencies.googleApiClientAndroid) { + addToFlavors(googleFlavors + ['accrescent'], dependencies.googleApiClientAndroid) { exclude module: 'guava-jdk5' exclude module: 'httpclient' exclude module: "google-http-client" @@ -199,10 +181,8 @@ dependencies { exclude group: "com.google.http-client", module: "jetified-google-http-client" } - playstoreImplementation dependencies.trackingFreeGoogleCLient - apkstoreImplementation dependencies.trackingFreeGoogleCLient - playstoreImplementation dependencies.trackingFreeGoogleAndroidCLient - apkstoreImplementation dependencies.trackingFreeGoogleAndroidCLient + addToFlavors(googleFlavors, dependencies.trackingFreeGoogleCLient) + addToFlavors(googleFlavors + ['accrescent'], dependencies.trackingFreeGoogleAndroidCLient) // rest implementation dependencies.rxJava diff --git a/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt b/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt index b50776a8d..6c57345f1 100644 --- a/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt +++ b/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt @@ -6,10 +6,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import com.google.common.base.Optional +import org.cryptomator.data.BuildConfig import org.cryptomator.data.db.entities.CloudEntityDao import org.cryptomator.data.db.entities.UpdateCheckEntityDao import org.cryptomator.data.db.entities.VaultEntityDao import org.cryptomator.domain.CloudType +import org.cryptomator.util.FlavorConfig import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.crypto.CredentialCryptor import org.cryptomator.util.crypto.CryptoMode @@ -28,17 +30,19 @@ import org.junit.runner.RunWith class UpgradeDatabaseTest { private val context = InstrumentationRegistry.getInstrumentation().context - private val sharedPreferencesHandler = SharedPreferencesHandler(context) private lateinit var db: Database + private lateinit var sharedPreferencesHandler: SharedPreferencesHandler @Before fun setup() { db = StandardDatabase(SQLiteDatabase.create(null)) + sharedPreferencesHandler = SharedPreferencesHandler(context) } @After fun tearDown() { db.close() + sharedPreferencesHandler.removeAllEntries() } @Test @@ -56,6 +60,7 @@ class UpgradeDatabaseTest { Upgrade10To11().applyTo(db, 10) Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) Upgrade12To13(context).applyTo(db, 12) + Upgrade13To14(sharedPreferencesHandler).applyTo(db, 13) CloudEntityDao(DaoConfig(db, CloudEntityDao::class.java)).loadAll() VaultEntityDao(DaoConfig(db, VaultEntityDao::class.java)).loadAll() @@ -839,7 +844,6 @@ class UpgradeDatabaseTest { } } - @Test fun upgrade12To13OneDrive() { Upgrade0To1().applyTo(db, 0) @@ -951,4 +955,99 @@ class UpgradeDatabaseTest { Assert.assertThat(it.getString(it.getColumnIndex("ACCESS_TOKEN_CRYPTO_MODE")), CoreMatchers.`is`(CryptoMode.GCM.name)) } } + + @Test + fun upgrade13To14ExistingLicense() { + Upgrade0To1().applyTo(db, 0) + Upgrade1To2().applyTo(db, 1) + Upgrade2To3(context).applyTo(db, 2) + Upgrade3To4().applyTo(db, 3) + Upgrade4To5().applyTo(db, 4) + Upgrade5To6().applyTo(db, 5) + Upgrade6To7().applyTo(db, 6) + Upgrade7To8().applyTo(db, 7) + Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) + Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) + Upgrade10To11().applyTo(db, 10) + Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + Upgrade12To13(context).applyTo(db, 12) + + val licenseToken = "licenseToken" + val releaseNote = "releaseNote" + val version = "version" + val urlApk = "urlApk" + val apkSha256 = "apkSha256" + val urlReleaseNote = "urlReleaseNote" + + Sql.update("UPDATE_CHECK_ENTITY") + .set("LICENSE_TOKEN", Sql.toString(licenseToken)) + .set("RELEASE_NOTE", Sql.toString(releaseNote)) + .set("VERSION", Sql.toString(version)) + .set("URL_TO_APK", Sql.toString(urlApk)) + .set("APK_SHA256", Sql.toString(apkSha256)) + .set("URL_TO_RELEASE_NOTE", Sql.toString(urlReleaseNote)) + .executeOn(db) + + Upgrade13To14(sharedPreferencesHandler).applyTo(db, 13) + + Assert.assertThat(sharedPreferencesHandler.hasCompletedWelcomeFlow(), CoreMatchers.`is`(true)) + if (!FlavorConfig.isPremiumFlavor) { + Assert.assertThat(sharedPreferencesHandler.licenseToken(), CoreMatchers.`is`(licenseToken)) + } + + Sql.query("UPDATE_CHECK_ENTITY").executeOn(db).use { + it.moveToFirst() + Assert.assertThat(it.getString(it.getColumnIndex("RELEASE_NOTE")), CoreMatchers.`is`(releaseNote)) + Assert.assertThat(it.getString(it.getColumnIndex("VERSION")), CoreMatchers.`is`(version)) + Assert.assertThat(it.getString(it.getColumnIndex("URL_TO_APK")), CoreMatchers.`is`(urlApk)) + Assert.assertThat(it.getString(it.getColumnIndex("APK_SHA256")), CoreMatchers.`is`(apkSha256)) + Assert.assertThat(it.getString(it.getColumnIndex("URL_TO_RELEASE_NOTE")), CoreMatchers.`is`(urlReleaseNote)) + } + } + + + @Test + fun upgrade13To14NoLicense() { + Upgrade0To1().applyTo(db, 0) + Upgrade1To2().applyTo(db, 1) + Upgrade2To3(context).applyTo(db, 2) + Upgrade3To4().applyTo(db, 3) + Upgrade4To5().applyTo(db, 4) + Upgrade5To6().applyTo(db, 5) + Upgrade6To7().applyTo(db, 6) + Upgrade7To8().applyTo(db, 7) + Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) + Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) + Upgrade10To11().applyTo(db, 10) + Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + Upgrade12To13(context).applyTo(db, 12) + + val releaseNote = "releaseNote" + val version = "version" + val urlApk = "urlApk" + val apkSha256 = "apkSha256" + val urlReleaseNote = "urlReleaseNote" + + Sql.update("UPDATE_CHECK_ENTITY") + .set("RELEASE_NOTE", Sql.toString(releaseNote)) + .set("VERSION", Sql.toString(version)) + .set("URL_TO_APK", Sql.toString(urlApk)) + .set("APK_SHA256", Sql.toString(apkSha256)) + .set("URL_TO_RELEASE_NOTE", Sql.toString(urlReleaseNote)) + .executeOn(db) + + Upgrade13To14(sharedPreferencesHandler).applyTo(db, 13) + + Assert.assertThat(sharedPreferencesHandler.hasCompletedWelcomeFlow(), CoreMatchers.`is`(true)) + Assert.assertThat(sharedPreferencesHandler.licenseToken(), CoreMatchers.`is`("")) + + Sql.query("UPDATE_CHECK_ENTITY").executeOn(db).use { + it.moveToFirst() + Assert.assertThat(it.getString(it.getColumnIndex("RELEASE_NOTE")), CoreMatchers.`is`(releaseNote)) + Assert.assertThat(it.getString(it.getColumnIndex("VERSION")), CoreMatchers.`is`(version)) + Assert.assertThat(it.getString(it.getColumnIndex("URL_TO_APK")), CoreMatchers.`is`(urlApk)) + Assert.assertThat(it.getString(it.getColumnIndex("APK_SHA256")), CoreMatchers.`is`(apkSha256)) + Assert.assertThat(it.getString(it.getColumnIndex("URL_TO_RELEASE_NOTE")), CoreMatchers.`is`(urlReleaseNote)) + } + } } 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 52401e64f..57cef31ac 100644 --- a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java @@ -1,7 +1,5 @@ package org.cryptomator.data.db; -import static java.lang.String.format; - import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -12,6 +10,8 @@ import javax.inject.Inject; import javax.inject.Singleton; +import static java.lang.String.format; + @Singleton class DatabaseUpgrades { @@ -31,7 +31,8 @@ public DatabaseUpgrades( // Upgrade9To10 upgrade9To10, // Upgrade10To11 upgrade10To11, // Upgrade11To12 upgrade11To12, // - Upgrade12To13 upgrade12To13 + Upgrade12To13 upgrade12To13, // + Upgrade13To14 upgrade13To14 ) { availableUpgrades = defineUpgrades( // @@ -47,7 +48,8 @@ public DatabaseUpgrades( // upgrade9To10, // upgrade10To11, // upgrade11To12, // - upgrade12To13); + upgrade12To13, // + upgrade13To14); } private Map> defineUpgrades(DatabaseUpgrade... 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 new file mode 100644 index 000000000..85a18951c --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt @@ -0,0 +1,74 @@ +package org.cryptomator.data.db + +import org.cryptomator.util.FlavorConfig +import org.cryptomator.util.SharedPreferencesHandler +import org.greenrobot.greendao.database.Database +import javax.inject.Inject +import javax.inject.Singleton +import timber.log.Timber + +@Singleton +internal class Upgrade13To14 @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) : DatabaseUpgrade(13, 14) { + + override fun internalApplyTo(db: Database, origin: Int) { + if (origin > 0) { + // Any user going through a schema migration is an existing user — skip welcome + setWelcomeFlowCompleted() + if (!nonLicenseKeyVariant()) { + val licenseToken = getExistingLicenseToken(db) + if (licenseToken != null) { + sharedPreferencesHandler.setLicenseToken(licenseToken) + } + } + } + removeLicenseFromDb(db) + } + + private fun nonLicenseKeyVariant(): Boolean { + return FlavorConfig.isPremiumFlavor + } + + private fun removeLicenseFromDb(db: Database) { + db.beginTransaction() + try { + Sql.alterTable("UPDATE_CHECK_ENTITY").renameTo("UPDATE_CHECK_ENTITY_OLD").executeOn(db) + + Sql.createTable("UPDATE_CHECK_ENTITY") // + .id() // + .optionalText("RELEASE_NOTE") // + .optionalText("VERSION") // + .optionalText("URL_TO_APK") // + .optionalText("APK_SHA256") // + .optionalText("URL_TO_RELEASE_NOTE") // + .executeOn(db) + + Sql.insertInto("UPDATE_CHECK_ENTITY") // + .select("_id", "RELEASE_NOTE", "VERSION", "URL_TO_APK", "APK_SHA256", "URL_TO_RELEASE_NOTE") // + .columns("_id", "RELEASE_NOTE", "VERSION", "URL_TO_APK", "APK_SHA256", "URL_TO_RELEASE_NOTE") // + .from("UPDATE_CHECK_ENTITY_OLD") // + .executeOn(db) + + Sql.dropTable("UPDATE_CHECK_ENTITY_OLD").executeOn(db) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun getExistingLicenseToken(db: Database): String? { + Sql.query("UPDATE_CHECK_ENTITY") + .columns(listOf("LICENSE_TOKEN")) + .executeOn(db).use { + if (it.moveToNext()) { + return it.getString(it.getColumnIndex("LICENSE_TOKEN")) + } + } + return null + } + + 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 aec5f36bc..2d82b8c9e 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 @@ -10,8 +10,6 @@ public class UpdateCheckEntity extends DatabaseEntity { @Id private Long id; - private String licenseToken; - private String releaseNote; private String version; @@ -25,10 +23,9 @@ public class UpdateCheckEntity extends DatabaseEntity { public UpdateCheckEntity() { } - @Generated(hash = 67239496) - public UpdateCheckEntity(Long id, String licenseToken, String releaseNote, String version, String urlToApk, String apkSha256, String urlToReleaseNote) { + @Generated(hash = 867488251) + public UpdateCheckEntity(Long id, String releaseNote, String version, String urlToApk, String apkSha256, String urlToReleaseNote) { this.id = id; - this.licenseToken = licenseToken; this.releaseNote = releaseNote; this.version = version; this.urlToApk = urlToApk; @@ -45,14 +42,6 @@ public void setId(Long id) { this.id = id; } - public String getLicenseToken() { - return this.licenseToken; - } - - public void setLicenseToken(String licenseToken) { - this.licenseToken = licenseToken; - } - 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 8afb8784d..a6ee6025e 100644 --- a/data/src/main/java/org/cryptomator/data/repository/HubRepositoryImpl.java +++ b/data/src/main/java/org/cryptomator/data/repository/HubRepositoryImpl.java @@ -55,7 +55,7 @@ private Interceptor httpLoggingInterceptor(Context context) { } @Override - public String getVaultKeyJwe(UnverifiedHubVaultConfig unverifiedHubVaultConfig, String accessToken) throws BackendException { + public HubRepository.VaultAccess getVaultAccess(UnverifiedHubVaultConfig unverifiedHubVaultConfig, String accessToken) throws BackendException { var request = new Request.Builder().get() // .header("Authorization", "Bearer " + accessToken) // .header("Hub-Device-ID", getHubDeviceCryptor().getDeviceId()) // @@ -65,7 +65,9 @@ public String getVaultKeyJwe(UnverifiedHubVaultConfig unverifiedHubVaultConfig, switch (response.code()) { case HttpURLConnection.HTTP_OK: if (response.body() != null) { - return response.body().string(); + String subscriptionHeader = response.header("Hub-Subscription-State"); + HubRepository.SubscriptionState state = "ACTIVE".equalsIgnoreCase(subscriptionHeader) ? HubRepository.SubscriptionState.ACTIVE : HubRepository.SubscriptionState.INACTIVE; + return new HubRepository.VaultAccess(response.body().string(), state); } else { throw new FatalBackendException("Failed to load JWE, response code good but no body"); } 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 431438e41..a84d916ff 100644 --- a/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java +++ b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java @@ -32,7 +32,6 @@ import java.security.spec.InvalidKeySpecException; import java.security.spec.X509EncodedKeySpec; -import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Singleton; @@ -89,21 +88,6 @@ public Optional getUpdateCheck(final String appVersion) throws Back return Optional.of(updateCheck); } - @Nullable - @Override - public String getLicense() { - return database.load(UpdateCheckEntity.class, 1L).getLicenseToken(); - } - - @Override - public void setLicense(String license) { - final UpdateCheckEntity entity = database.load(UpdateCheckEntity.class, 1L); - - entity.setLicenseToken(license); - - database.store(entity); - } - @Override public void update(File file) throws GeneralUpdateErrorException { try { diff --git a/domain/build.gradle b/domain/build.gradle index 4bc237829..955238459 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -16,6 +16,8 @@ android { buildConfigField "String", "VERSION_NAME", "\"${globalConfiguration["androidVersionName"]}\"" testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + + missingDimensionStrategy 'version', 'playstore' } compileOptions { diff --git a/domain/src/main/java/org/cryptomator/domain/Vault.java b/domain/src/main/java/org/cryptomator/domain/Vault.java index 6be3fb4ea..6153b40a2 100644 --- a/domain/src/main/java/org/cryptomator/domain/Vault.java +++ b/domain/src/main/java/org/cryptomator/domain/Vault.java @@ -18,6 +18,8 @@ public class Vault implements Serializable { private final int format; private final int shorteningThreshold; private final int position; + private final boolean hubVault; + private final boolean hubPaidLicense; private Vault(Builder builder) { this.id = builder.id; @@ -31,6 +33,8 @@ private Vault(Builder builder) { this.format = builder.format; this.shorteningThreshold = builder.shorteningThreshold; this.position = builder.position; + this.hubVault = builder.hubVault; + this.hubPaidLicense = builder.hubPaidLicense; } public static Builder aVault() { @@ -48,7 +52,9 @@ public static Builder aCopyOf(Vault vault) { .withSavedPassword(vault.getPassword(), vault.getPasswordCryptoMode()) // .withFormat(vault.getFormat()) // .withShorteningThreshold(vault.getShorteningThreshold()) // - .withPosition(vault.getPosition()); + .withPosition(vault.getPosition()) // + .withHubVault(vault.isHubVault()) // + .withHubPaidLicense(vault.hasHubPaidLicense()); } public Long getId() { @@ -99,6 +105,14 @@ public boolean isReadOnly() { return false; //TODO Implement read-only check } + public boolean isHubVault() { + return hubVault; + } + + public boolean hasHubPaidLicense() { + return hubPaidLicense; + } + @Override public boolean equals(Object obj) { if (obj == null || getClass() != obj.getClass()) { @@ -132,6 +146,8 @@ public static class Builder { private int format = -1; private int shorteningThreshold = -1; private int position = -1; + private boolean hubVault; + private boolean hubPaidLicense; private Builder() { } @@ -208,6 +224,16 @@ public Builder withShorteningThreshold(int shorteningThreshold) { return this; } + public Builder withHubPaidLicense(boolean hubPaidLicense) { + this.hubPaidLicense = hubPaidLicense; + return this; + } + + public Builder withHubVault(boolean hubVault) { + this.hubVault = hubVault; + return this; + } + 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 02db0d8ed..aec32003d 100644 --- a/domain/src/main/java/org/cryptomator/domain/repository/HubRepository.kt +++ b/domain/src/main/java/org/cryptomator/domain/repository/HubRepository.kt @@ -6,7 +6,7 @@ import org.cryptomator.domain.exception.BackendException interface HubRepository { @Throws(BackendException::class) - fun getVaultKeyJwe(unverifiedHubVaultConfig: UnverifiedHubVaultConfig, accessToken: String): String + fun getVaultAccess(unverifiedHubVaultConfig: UnverifiedHubVaultConfig, accessToken: String): VaultAccess @Throws(BackendException::class) fun getUser(unverifiedHubVaultConfig: UnverifiedHubVaultConfig, accessToken: String): UserDto @@ -26,4 +26,10 @@ interface HubRepository { data class UserDto(val id: String, val name: String, val publicKey: String, val privateKey: String, val setupCode: String) + data class VaultAccess(val vaultKeyJwe: String, val subscriptionState: SubscriptionState) + + enum class SubscriptionState { + ACTIVE, INACTIVE + } + } 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 3ab1d3f28..07f263513 100644 --- a/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java +++ b/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java @@ -12,9 +12,5 @@ public interface UpdateCheckRepository { Optional getUpdateCheck(String version) throws BackendException; - String getLicense(); - - void setLicense(String license); - 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 a3d52e3c4..b95ffbf5b 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java @@ -6,7 +6,6 @@ import com.auth0.jwt.exceptions.SignatureVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.JWTVerifier; -import com.google.common.base.CharMatcher; import com.google.common.io.BaseEncoding; import org.cryptomator.domain.exception.BackendException; @@ -14,9 +13,9 @@ import org.cryptomator.domain.exception.license.DesktopSupporterCertificateException; import org.cryptomator.domain.exception.license.LicenseNotValidException; import org.cryptomator.domain.exception.license.NoLicenseAvailableException; -import org.cryptomator.domain.repository.UpdateCheckRepository; import org.cryptomator.generator.Parameter; import org.cryptomator.generator.UseCase; +import org.cryptomator.util.SharedPreferencesHandler; import java.security.Key; import java.security.KeyFactory; @@ -36,21 +35,21 @@ public class DoLicenseCheck { "fmnV2yv3eDjlDfGruBrqz9TtXBZV/eYWt31xu1osIqaT12lKBvZ511aaAkIBeOEV" + // "gwcBIlJr6kUw7NKzeJt7r2rrsOyQoOG2nWc/Of/NBqA3mIZRHk5Aq1YupFdD26QE" + // "r0DzRyj4ixPIt38CQB8="; - private final UpdateCheckRepository updateCheckRepository; + private final SharedPreferencesHandler sharedPreferencesHandler; private String license; - DoLicenseCheck(final UpdateCheckRepository updateCheckRepository, @Parameter final String license) { - this.updateCheckRepository = updateCheckRepository; + DoLicenseCheck(final SharedPreferencesHandler sharedPreferencesHandler, @Parameter final String license) { + this.sharedPreferencesHandler = sharedPreferencesHandler; this.license = license; } public LicenseCheck execute() throws BackendException { - license = useLicenseOrRetrieveFromDb(license); - license = CharMatcher.whitespace().removeFrom(license); + license = useLicenseOrRetrieveFromPreferences(license); try { Algorithm algorithm = Algorithm.ECDSA512(getPublicKey(ANDROID_PUB_KEY), null); JWTVerifier verifier = JWT.require(algorithm).build(); DecodedJWT jwt = verifier.verify(license); + sharedPreferencesHandler.setLicenseToken(license); return jwt::getSubject; } catch (SignatureVerificationException | JWTDecodeException | FatalBackendException e) { if (e instanceof SignatureVerificationException && isDesktopSupporterCertificate(license)) { @@ -62,12 +61,12 @@ public LicenseCheck execute() throws BackendException { } } - private String useLicenseOrRetrieveFromDb(String license) throws NoLicenseAvailableException { + private String useLicenseOrRetrieveFromPreferences(String license) throws NoLicenseAvailableException { if (!license.isEmpty()) { - updateCheckRepository.setLicense(license); + return license; } else { - license = updateCheckRepository.getLicense(); - if (license == null) { + license = sharedPreferencesHandler.licenseToken(); + if (license.isEmpty()) { throw new NoLicenseAvailableException(); } } 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 b9744b24a..78e4907e0 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 @@ -45,9 +45,14 @@ public Cloud execute() throws BackendException { if (config.getApiLevel() < HUB_MINIMUM_VERSION) { throw new HubInvalidVersionException("Version is " + config.getApiLevel() + " but minimum is " + HUB_MINIMUM_VERSION); } - String vaultKeyJwe = hubRepository.getVaultKeyJwe(unverifiedVaultConfig, accessToken); + HubRepository.VaultAccess vaultAccess = hubRepository.getVaultAccess(unverifiedVaultConfig, accessToken); HubRepository.DeviceDto device = hubRepository.getDevice(unverifiedVaultConfig, accessToken); - return cloudRepository.unlock(vault, unverifiedVaultConfig, vaultKeyJwe, device.getUserPrivateKey(), cancelledFlag); + boolean writeAllowed = vaultAccess.getSubscriptionState() == HubRepository.SubscriptionState.ACTIVE; + Vault targetVault = Vault.aCopyOf(vault) + .withHubVault(true) + .withHubPaidLicense(writeAllowed) + .build(); + return cloudRepository.unlock(targetVault, unverifiedVaultConfig, vaultAccess.getVaultKeyJwe(), device.getUserPrivateKey(), cancelledFlag); } } diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/DoLicenseCheckTest.java b/domain/src/test/java/org/cryptomator/domain/usecases/DoLicenseCheckTest.java new file mode 100644 index 000000000..69b9834a4 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/DoLicenseCheckTest.java @@ -0,0 +1,279 @@ +package org.cryptomator.domain.usecases; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; + +import org.cryptomator.domain.exception.license.DesktopSupporterCertificateException; +import org.cryptomator.domain.exception.license.LicenseNotValidException; +import org.cryptomator.domain.exception.license.NoLicenseAvailableException; +import org.cryptomator.util.SharedPreferencesHandler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class DoLicenseCheckTest { + + private final SharedPreferencesHandler sharedPreferencesHandler = mock(SharedPreferencesHandler.class); + + @Nested + @DisplayName("License retrieval from preferences") + class LicenseRetrieval { + + @Test + @DisplayName("Empty license + empty preference throws NoLicenseAvailableException") + void emptyLicenseAndEmptyPreference() { + when(sharedPreferencesHandler.licenseToken()).thenReturn(""); + + DoLicenseCheck inTest = testCandidate(""); + + assertThrows(NoLicenseAvailableException.class, inTest::execute); + } + + @Test + @DisplayName("Empty license + stored preference attempts verification with stored token") + void emptyLicenseWithStoredPreference() { + when(sharedPreferencesHandler.licenseToken()).thenReturn("some-stored-token"); + + DoLicenseCheck inTest = testCandidate(""); + + assertThrows(LicenseNotValidException.class, inTest::execute); + verify(sharedPreferencesHandler, never()).setLicenseToken(any()); + } + + @Test + @DisplayName("Non-empty license stores token in preferences") + void nonEmptyLicenseSetsPreference() throws Exception { + KeyPair keyPair = generateEcKeyPair(); + String token = signJwt(keyPair, "user@example.com"); + + try (MockedStatic algorithmMock = mockAlgorithmWithKey(keyPair)) { + DoLicenseCheck inTest = testCandidate(token); + inTest.execute(); + } + + verify(sharedPreferencesHandler).setLicenseToken(token); + } + + @Test + @DisplayName("Non-empty license does not read from preferences") + void nonEmptyLicenseSkipsPreferenceLookup() throws Exception { + KeyPair keyPair = generateEcKeyPair(); + String token = signJwt(keyPair, "user@example.com"); + + try (MockedStatic algorithmMock = mockAlgorithmWithKey(keyPair)) { + DoLicenseCheck inTest = testCandidate(token); + inTest.execute(); + } + + verify(sharedPreferencesHandler, never()).licenseToken(); + } + } + + @Nested + @DisplayName("Invalid token rejection") + class InvalidTokenRejection { + + @Test + @DisplayName("Random garbage string throws LicenseNotValidException") + void randomGarbageString() { + DoLicenseCheck inTest = testCandidate("this-is-not-a-jwt"); + + assertThrows(LicenseNotValidException.class, inTest::execute); + verify(sharedPreferencesHandler, never()).setLicenseToken(any()); + } + + @Test + @DisplayName("Well-formed JWT signed with wrong key throws LicenseNotValidException") + void jwtSignedWithWrongKey() throws Exception { + KeyPair wrongKeyPair = generateEcKeyPair(); + String token = signJwt(wrongKeyPair, "attacker@example.com"); + + DoLicenseCheck inTest = testCandidate(token); + + assertThrows(LicenseNotValidException.class, inTest::execute); + verify(sharedPreferencesHandler, never()).setLicenseToken(any()); + } + + @Test + @DisplayName("Corrupted base64 in JWT body throws LicenseNotValidException") + void corruptedBase64InJwtBody() throws Exception { + KeyPair keyPair = generateEcKeyPair(); + String validToken = signJwt(keyPair, "user@example.com"); + // Corrupt the payload section (second segment) + String[] parts = validToken.split("\\."); + parts[1] = "!!!invalid-base64!!!"; + String corruptedToken = parts[0] + "." + parts[1] + "." + parts[2]; + + DoLicenseCheck inTest = testCandidate(corruptedToken); + + assertThrows(LicenseNotValidException.class, inTest::execute); + verify(sharedPreferencesHandler, never()).setLicenseToken(any()); + } + + @Test + @DisplayName("Empty JWT segments throw LicenseNotValidException") + void emptyJwtSegments() { + DoLicenseCheck inTest = testCandidate("a.b.c"); + + assertThrows(LicenseNotValidException.class, inTest::execute); + verify(sharedPreferencesHandler, never()).setLicenseToken(any()); + } + } + + @Nested + @DisplayName("Valid token acceptance") + class ValidTokenAcceptance { + + @Test + @DisplayName("JWT signed with matching key returns LicenseCheck with correct mail") + void validJwtReturnsLicenseCheckWithMail() throws Exception { + KeyPair keyPair = generateEcKeyPair(); + String token = signJwt(keyPair, "user@example.com"); + + try (MockedStatic algorithmMock = mockAlgorithmWithKey(keyPair)) { + DoLicenseCheck inTest = testCandidate(token); + LicenseCheck result = inTest.execute(); + + assertThat(result.mail(), is("user@example.com")); + } + } + + @Test + @DisplayName("JWT subject with special characters is returned correctly") + void validJwtWithSpecialCharSubject() throws Exception { + KeyPair keyPair = generateEcKeyPair(); + String token = signJwt(keyPair, "user+tag@example.co.uk"); + + try (MockedStatic algorithmMock = mockAlgorithmWithKey(keyPair)) { + DoLicenseCheck inTest = testCandidate(token); + LicenseCheck result = inTest.execute(); + + assertThat(result.mail(), is("user+tag@example.co.uk")); + } + } + + @Test + @DisplayName("Valid token retrieved from preferences returns LicenseCheck") + void validTokenFromPreferencesReturnsLicenseCheck() throws Exception { + KeyPair keyPair = generateEcKeyPair(); + String token = signJwt(keyPair, "stored@example.com"); + when(sharedPreferencesHandler.licenseToken()).thenReturn(token); + + try (MockedStatic algorithmMock = mockAlgorithmWithKey(keyPair)) { + DoLicenseCheck inTest = testCandidate(""); + LicenseCheck result = inTest.execute(); + + assertThat(result.mail(), is("stored@example.com")); + } + } + } + + @Nested + @DisplayName("Desktop supporter certificate detection") + class DesktopSupporterCertificate { + + @Test + @DisplayName("JWT signed with desktop supporter key throws DesktopSupporterCertificateException") + void desktopSupporterCertificateDetected() throws Exception { + KeyPair desktopKeyPair = generateEcKeyPair(); + String token = signJwt(desktopKeyPair, "supporter@example.com"); + + // execute() calls ECDSA512 twice: first for android key (fails verification), + // then isDesktopSupporterCertificate calls it again for the desktop key (succeeds). + // We return the real desktop algorithm for both calls -- the first will fail + // signature verification (token was signed with the desktop key, not the android key) + // triggering the desktop supporter certificate check, which then succeeds. + try (MockedStatic algorithmMock = mockAlgorithmForDesktopCert(desktopKeyPair)) { + DoLicenseCheck inTest = testCandidate(token); + + DesktopSupporterCertificateException exception = assertThrows( + DesktopSupporterCertificateException.class, inTest::execute); + assertThat(exception.getLicense(), is(token)); + } + } + + @Test + @DisplayName("JWT signed with unknown key does not trigger desktop supporter detection") + void unknownKeyDoesNotTriggerDesktopSupporterDetection() { + DoLicenseCheck inTest = testCandidate("eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJub2JvZHlAZXhhbXBsZS5jb20ifQ.fakeSignature"); + + LicenseNotValidException exception = assertThrows(LicenseNotValidException.class, inTest::execute); + assertThat(exception.getClass().getSimpleName(), is("LicenseNotValidException")); + } + } + + private DoLicenseCheck testCandidate(String license) { + return new DoLicenseCheck(sharedPreferencesHandler, license); + } + + private static KeyPair generateEcKeyPair() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC"); + keyGen.initialize(new ECGenParameterSpec("secp521r1")); + return keyGen.generateKeyPair(); + } + + private static String signJwt(KeyPair keyPair, String subject) { + Algorithm algorithm = Algorithm.ECDSA512( + (ECPublicKey) keyPair.getPublic(), + (ECPrivateKey) keyPair.getPrivate()); + return JWT.create().withSubject(subject).sign(algorithm); + } + + /** + * Mocks {@code Algorithm.ECDSA512(ECPublicKey, ECPrivateKey)} to always return + * an algorithm configured with the given key pair's public key. This bypasses + * the compile-time-inlined production public key constants. + */ + private static MockedStatic mockAlgorithmWithKey(KeyPair keyPair) { + Algorithm testAlgorithm = Algorithm.ECDSA512((ECPublicKey) keyPair.getPublic(), null); + MockedStatic algorithmMock = mockStatic(Algorithm.class); + algorithmMock.when(() -> Algorithm.ECDSA512(any(ECPublicKey.class), any())) + .thenReturn(testAlgorithm); + return algorithmMock; + } + + /** + * Mocks {@code Algorithm.ECDSA512(ECPublicKey, ECPrivateKey)} to simulate the + * desktop supporter certificate flow: + *
    + *
  1. First call (android key check): returns an algorithm with a wrong key + * so signature verification fails
  2. + *
  3. Second call (desktop key check in {@code isDesktopSupporterCertificate}): + * returns an algorithm using the test desktop public key which succeeds
  4. + *
+ */ + private static MockedStatic mockAlgorithmForDesktopCert(KeyPair desktopKeyPair) throws Exception { + // Create both algorithms before mocking to avoid recursive mock interception + KeyPair wrongKeyPair = generateEcKeyPair(); + Algorithm wrongAlgorithm = Algorithm.ECDSA512((ECPublicKey) wrongKeyPair.getPublic(), null); + Algorithm desktopAlgorithm = Algorithm.ECDSA512((ECPublicKey) desktopKeyPair.getPublic(), null); + AtomicInteger callCount = new AtomicInteger(0); + MockedStatic algorithmMock = mockStatic(Algorithm.class); + algorithmMock.when(() -> Algorithm.ECDSA512(any(ECPublicKey.class), any())) + .thenAnswer(invocation -> { + if (callCount.incrementAndGet() == 1) { + return wrongAlgorithm; + } + return desktopAlgorithm; + }); + return algorithmMock; + } +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/vault/UnlockHubVaultTest.java b/domain/src/test/java/org/cryptomator/domain/usecases/vault/UnlockHubVaultTest.java new file mode 100644 index 000000000..f1cddf04e --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/vault/UnlockHubVaultTest.java @@ -0,0 +1,208 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.UnverifiedHubVaultConfig; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.hub.HubInvalidVersionException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.domain.repository.HubRepository; +import org.cryptomator.domain.usecases.cloud.Flag; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class UnlockHubVaultTest { + + private static final String ACCESS_TOKEN = "hub-access-token"; + private static final String VAULT_KEY_JWE = "vault-key-jwe"; + private static final String USER_PRIVATE_KEY = "user-private-key"; + + private CloudRepository cloudRepository; + private HubRepository hubRepository; + private UnverifiedHubVaultConfig unverifiedVaultConfig; + private Cloud cloud; + private Vault vault; + + @BeforeEach + public void setup() { + cloudRepository = mock(CloudRepository.class); + hubRepository = mock(HubRepository.class); + unverifiedVaultConfig = mock(UnverifiedHubVaultConfig.class); + cloud = mock(Cloud.class); + when(cloud.type()).thenReturn(CloudType.WEBDAV); + vault = Vault.aVault() // + .withId(1L) // + .withName("TestVault") // + .withPath("/vaults/test") // + .withCloud(cloud) // + .withPosition(0) // + .build(); + } + + @Test + @DisplayName("API level below minimum throws HubInvalidVersionException") + public void testApiLevelBelowMinimumThrowsException() throws BackendException { + when(hubRepository.getConfig(unverifiedVaultConfig, ACCESS_TOKEN)) // + .thenReturn(new HubRepository.ConfigDto(0)); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + + assertThrows(HubInvalidVersionException.class, inTest::execute); + } + + @Test + @DisplayName("Negative API level throws HubInvalidVersionException") + public void testNegativeApiLevelThrowsException() throws BackendException { + when(hubRepository.getConfig(unverifiedVaultConfig, ACCESS_TOKEN)) // + .thenReturn(new HubRepository.ConfigDto(-1)); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + + assertThrows(HubInvalidVersionException.class, inTest::execute); + } + + @Test + @DisplayName("API level at minimum proceeds without exception") + public void testApiLevelAtMinimumProceeds() throws BackendException { + setupHubResponses(1, HubRepository.SubscriptionState.ACTIVE); + Cloud expectedCloud = mock(Cloud.class); + when(cloudRepository.unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class))) // + .thenReturn(expectedCloud); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + Cloud result = inTest.execute(); + + assertThat(result, is(expectedCloud)); + } + + @Test + @DisplayName("API level above minimum proceeds without exception") + public void testApiLevelAboveMinimumProceeds() throws BackendException { + setupHubResponses(2, HubRepository.SubscriptionState.ACTIVE); + Cloud expectedCloud = mock(Cloud.class); + when(cloudRepository.unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class))) // + .thenReturn(expectedCloud); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + Cloud result = inTest.execute(); + + assertThat(result, is(expectedCloud)); + } + + @Test + @DisplayName("ACTIVE subscription sets hubPaidLicense to true") + public void testActiveSubscriptionSetsHubPaidLicenseTrue() throws BackendException { + setupHubResponses(1, HubRepository.SubscriptionState.ACTIVE); + when(cloudRepository.unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class))) // + .thenReturn(mock(Cloud.class)); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + inTest.execute(); + + ArgumentCaptor vaultCaptor = ArgumentCaptor.forClass(Vault.class); + verify(cloudRepository).unlock(vaultCaptor.capture(), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class)); + assertThat(vaultCaptor.getValue().hasHubPaidLicense(), is(true)); + } + + @Test + @DisplayName("INACTIVE subscription sets hubPaidLicense to false") + public void testInactiveSubscriptionSetsHubPaidLicenseFalse() throws BackendException { + setupHubResponses(1, HubRepository.SubscriptionState.INACTIVE); + when(cloudRepository.unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class))) // + .thenReturn(mock(Cloud.class)); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + inTest.execute(); + + ArgumentCaptor vaultCaptor = ArgumentCaptor.forClass(Vault.class); + verify(cloudRepository).unlock(vaultCaptor.capture(), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class)); + assertThat(vaultCaptor.getValue().hasHubPaidLicense(), is(false)); + } + + @Test + @DisplayName("Vault passed to unlock has isHubVault set to true") + public void testVaultPassedToUnlockHasHubVaultTrue() throws BackendException { + setupHubResponses(1, HubRepository.SubscriptionState.ACTIVE); + when(cloudRepository.unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class))) // + .thenReturn(mock(Cloud.class)); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + inTest.execute(); + + ArgumentCaptor vaultCaptor = ArgumentCaptor.forClass(Vault.class); + verify(cloudRepository).unlock(vaultCaptor.capture(), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class)); + assertThat(vaultCaptor.getValue().isHubVault(), is(true)); + } + + @Test + @DisplayName("Unlock delegates to cloudRepository with correct parameters") + public void testUnlockDelegatesToCloudRepositoryWithCorrectParameters() throws BackendException { + setupHubResponses(1, HubRepository.SubscriptionState.ACTIVE); + Cloud expectedCloud = mock(Cloud.class); + when(cloudRepository.unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class))) // + .thenReturn(expectedCloud); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + Cloud result = inTest.execute(); + + assertThat(result, is(expectedCloud)); + verify(cloudRepository).unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class)); + } + + @Test + @DisplayName("onCancel propagates to the cancelled flag passed to unlock") + public void testOnCancelPropagatesFlag() throws BackendException { + setupHubResponses(1, HubRepository.SubscriptionState.ACTIVE); + ArgumentCaptor flagCaptor = ArgumentCaptor.forClass(Flag.class); + when(cloudRepository.unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), flagCaptor.capture())) // + .thenReturn(mock(Cloud.class)); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + inTest.execute(); + + Flag cancelledFlag = flagCaptor.getValue(); + assertThat(cancelledFlag.get(), is(false)); + + inTest.onCancel(); + assertThat(cancelledFlag.get(), is(true)); + } + + @Test + @DisplayName("Vault passed to unlock preserves original vault properties") + public void testVaultPassedToUnlockPreservesOriginalProperties() throws BackendException { + setupHubResponses(1, HubRepository.SubscriptionState.ACTIVE); + when(cloudRepository.unlock(Mockito.any(Vault.class), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class))) // + .thenReturn(mock(Cloud.class)); + + UnlockHubVault inTest = new UnlockHubVault(cloudRepository, hubRepository, vault, unverifiedVaultConfig, ACCESS_TOKEN); + inTest.execute(); + + ArgumentCaptor vaultCaptor = ArgumentCaptor.forClass(Vault.class); + verify(cloudRepository).unlock(vaultCaptor.capture(), Mockito.eq(unverifiedVaultConfig), Mockito.eq(VAULT_KEY_JWE), Mockito.eq(USER_PRIVATE_KEY), Mockito.any(Flag.class)); + Vault capturedVault = vaultCaptor.getValue(); + assertThat(capturedVault.getName(), is("TestVault")); + assertThat(capturedVault.getPath(), is("/vaults/test")); + assertThat(capturedVault.getCloud(), is(cloud)); + } + + private void setupHubResponses(int apiLevel, HubRepository.SubscriptionState subscriptionState) throws BackendException { + when(hubRepository.getConfig(unverifiedVaultConfig, ACCESS_TOKEN)) // + .thenReturn(new HubRepository.ConfigDto(apiLevel)); + when(hubRepository.getVaultAccess(unverifiedVaultConfig, ACCESS_TOKEN)) // + .thenReturn(new HubRepository.VaultAccess(VAULT_KEY_JWE, subscriptionState)); + when(hubRepository.getDevice(unverifiedVaultConfig, ACCESS_TOKEN)) // + .thenReturn(new HubRepository.DeviceDto(USER_PRIVATE_KEY)); + } + +} diff --git a/presentation/build.gradle b/presentation/build.gradle index 56cc497ba..dbe0f45d8 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -79,7 +79,6 @@ android { manifestPlaceholders = [DROPBOX_API_KEY: getApiKey('DROPBOX_API_KEY_DEBUG'), ONEDRIVE_API_KEY_DECODED: getOnedriveApiKey()] - applicationIdSuffix ".debug" versionNameSuffix '-DEBUG' enableUnitTestCoverage false enableAndroidTestCoverage false @@ -93,6 +92,13 @@ android { dimension "version" } + playstoreiap { + dimension "version" + + applicationIdSuffix ".freemium" + resValue "string", "app_id", androidApplicationId + applicationIdSuffix + } + apkstore { dimension "version" } @@ -115,23 +121,27 @@ android { sourceSets { playstore { - java.srcDirs = ['src/main/java', 'src/apiKey/java/', 'src/apkStorePlaystore/java/'] + java.srcDirs = ['src/main/java', 'src/apiKey/java/', 'src/apkStorePlaystore/java/', 'src/nonplaystoreiap/java/'] + } + + playstoreiap { + java.srcDirs = ['src/main/java', 'src/apiKey/java/', 'src/apkStorePlaystore/java/', 'src/playstoreiap/java/'] } apkstore { - java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/apkStorePlaystore/java/'] + java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/apkStorePlaystore/java/', 'src/nonplaystoreiap/java/'] } fdroid { - java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/fdroid/java/', 'src/fdroidLiteAccrescent/java/'] + java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/fdroid/java/', 'src/fdroidLiteAccrescent/java/', 'src/nonplaystoreiap/java/'] } lite { - java.srcDirs = ['src/main/java/', 'src/lite/java/', 'src/fdroidLiteAccrescent/java/'] + java.srcDirs = ['src/main/java/', 'src/lite/java/', 'src/fdroidLiteAccrescent/java/', 'src/nonplaystoreiap/java/'] } accrescent { - java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/accrescent/java/', 'src/fdroidLiteAccrescent/java/'] + java.srcDirs = ['src/main/java/', 'src/apiKey/java/', 'src/accrescent/java/', 'src/fdroidLiteAccrescent/java/', 'src/nonplaystoreiap/java/'] } } @@ -157,6 +167,17 @@ android { dependencies { def dependencies = rootProject.ext.dependencies + def cloudFlavors = ['playstore', 'playstoreiap', 'apkstore', 'fdroid', 'accrescent'] + def googleFlavors = ['playstore', 'playstoreiap', 'apkstore'] + def addToFlavors = { flavors, dep, Closure config = null -> + flavors.each { flavor -> + if (config) { + add("${flavor}Implementation", dep, config) + } else { + add("${flavor}Implementation", dep) + } + } + } // custom code generators kapt project(':generator') @@ -189,45 +210,19 @@ dependencies { implementation dependencies.appauth // cloud - playstoreImplementation dependencies.dropboxCore - playstoreImplementation dependencies.dropboxAndroid - apkstoreImplementation dependencies.dropboxCore - apkstoreImplementation dependencies.dropboxAndroid - fdroidImplementation dependencies.dropboxCore - fdroidImplementation dependencies.dropboxAndroid - accrescentImplementation dependencies.dropboxCore - accrescentImplementation dependencies.dropboxAndroid - - playstoreImplementation dependencies.msgraphAuth - apkstoreImplementation dependencies.msgraphAuth - fdroidImplementation dependencies.msgraphAuth - accrescentImplementation dependencies.msgraphAuth - playstoreImplementation dependencies.msgraph - apkstoreImplementation dependencies.msgraph - fdroidImplementation dependencies.msgraph - accrescentImplementation dependencies.msgraph - - playstoreImplementation(dependencies.googleApiServicesDrive) { - exclude module: 'guava-jdk5' - exclude module: 'httpclient' - exclude module: 'googlehttpclient' - exclude group: "com.google.http-client", module: "google-http-client" - } - apkstoreImplementation(dependencies.googleApiServicesDrive) { - exclude module: 'guava-jdk5' - exclude module: 'httpclient' - exclude group: "com.google.http-client", module: "google-http-client" - } + addToFlavors(cloudFlavors, dependencies.dropboxCore) + addToFlavors(cloudFlavors, dependencies.dropboxAndroid) - playstoreImplementation(dependencies.googleApiClientAndroid) { + addToFlavors(cloudFlavors, dependencies.msgraphAuth) + addToFlavors(cloudFlavors, dependencies.msgraph) + + addToFlavors(googleFlavors, dependencies.googleApiServicesDrive) { exclude module: 'guava-jdk5' exclude module: 'httpclient' - exclude module: "google-http-client" - exclude module: "jetified-google-http-client" + exclude module: 'googlehttpclient' exclude group: "com.google.http-client", module: "google-http-client" - exclude group: "com.google.http-client", module: "jetified-google-http-client" } - apkstoreImplementation(dependencies.googleApiClientAndroid) { + addToFlavors(googleFlavors, dependencies.googleApiClientAndroid) { exclude module: 'guava-jdk5' exclude module: 'httpclient' exclude module: "google-http-client" @@ -236,10 +231,8 @@ dependencies { exclude group: "com.google.http-client", module: "jetified-google-http-client" } - playstoreImplementation dependencies.trackingFreeGoogleCLient - apkstoreImplementation dependencies.trackingFreeGoogleCLient - playstoreImplementation dependencies.trackingFreeGoogleAndroidCLient - apkstoreImplementation dependencies.trackingFreeGoogleAndroidCLient + addToFlavors(googleFlavors, dependencies.trackingFreeGoogleCLient) + addToFlavors(googleFlavors, dependencies.trackingFreeGoogleAndroidCLient) // rest implementation dependencies.rxJava @@ -248,6 +241,8 @@ dependencies { implementation dependencies.zxcvbn implementation dependencies.rxBinding + playstoreiapImplementation dependencies.billing + // multidex implementation dependencies.multidex @@ -292,6 +287,7 @@ dependencies { testRuntimeOnly dependencies.junit4Engine testImplementation dependencies.mockito + testImplementation dependencies.mockitoKotlin testImplementation dependencies.mockitoInline testImplementation dependencies.hamcrest } diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index cb407cbe2..ca2c57f1c 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -53,6 +53,11 @@ + + + android:exported="false" /> @@ -211,6 +216,10 @@ android:name=".service.AutoUploadService" android:enabled="true" /> + + @Volatile private var autoUploadServiceBinder: AutoUploadService.Binder? = null + @Volatile + private var iapBillingServiceBinder: IapBillingService.Binder? = null + + private val pendingProductDetailsCallbacks = mutableListOf<(List) -> Unit>() + override fun onCreate() { super.onCreate() setupLogging() @@ -51,6 +60,7 @@ class CryptomatorApp : MultiDexApplication(), HasComponent "fdroid" -> "F-Droid Edition" "lite" -> "F-Droid Main Repo Edition" "accrescent" -> "Accrescent Edition" + "playstoreiap" -> "IAP Google Play Edition" else -> "Google Play Edition" } Timber.tag("App").i( @@ -81,6 +91,11 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } catch (e: IllegalStateException) { Timber.tag("App").e(e, "Failed to launch cryptors service") } + try { + startIapBillingService() + } catch (e: IllegalStateException) { + Timber.tag("App").e(e, "Failed to launch IAP billing service") + } try { startAutoUploadService() } catch (e: IllegalStateException) { @@ -108,6 +123,66 @@ class CryptomatorApp : MultiDexApplication(), HasComponent }, BIND_AUTO_CREATE) } + private fun startIapBillingService() { + if (!FlavorConfig.isFreemiumFlavor) { + Timber.tag("App").d("IAP billing service skipped for flavor %s", BuildConfig.FLAVOR) + return + } + bindService(Intent(this, IapBillingService::class.java), object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Timber.tag("App").i("IAP Billing service connected") + iapBillingServiceBinder = service as IapBillingService.Binder + iapBillingServiceBinder?.init(Companion.applicationContext) + drainPendingProductDetailsCallbacks() + } + + override fun onServiceDisconnected(name: ComponentName) { + Timber.tag("App").i("IAP Billing service disconnected") + iapBillingServiceBinder = null + } + }, BIND_AUTO_CREATE) + } + + fun launchPurchaseFlow(activity: WeakReference, productId: String) { + if (FlavorConfig.isFreemiumFlavor) { + iapBillingServiceBinder?.startPurchaseFlow(activity, productId) + } + } + + fun queryProductDetails(callback: (List) -> Unit) { + if (FlavorConfig.isFreemiumFlavor) { + synchronized(pendingProductDetailsCallbacks) { + val binder = iapBillingServiceBinder + if (binder != null) { + binder.queryProductDetails(callback) + } else { + pendingProductDetailsCallbacks.add(callback) + } + } + } else { + callback(emptyList()) + } + } + + private fun drainPendingProductDetailsCallbacks() { + synchronized(pendingProductDetailsCallbacks) { + if (pendingProductDetailsCallbacks.isEmpty()) { + return + } + val callbacks = ArrayList(pendingProductDetailsCallbacks) + pendingProductDetailsCallbacks.clear() + iapBillingServiceBinder?.queryProductDetails { products -> + callbacks.forEach { it(products) } + } + } + } + + fun restorePurchases() { + if (FlavorConfig.isFreemiumFlavor) { + iapBillingServiceBinder?.restorePurchases() + } + } + private fun startAutoUploadService() { bindService(Intent(this, AutoUploadService::class.java), object : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { @@ -189,6 +264,7 @@ class CryptomatorApp : MultiDexApplication(), HasComponent private val serviceNotifier: ActivityLifecycleCallbacks = object : NoOpActivityLifecycleCallbacks() { override fun onActivityResumed(activity: Activity) { updateService(resumedActivities.incrementAndGet()) + restorePurchases() } override fun onActivityPaused(activity: Activity) { 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 ed3206b03..a47963aed 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 @@ -25,6 +25,7 @@ import org.cryptomator.presentation.ui.activity.TextEditorActivity; import org.cryptomator.presentation.ui.activity.UnlockVaultActivity; import org.cryptomator.presentation.ui.activity.VaultListActivity; +import org.cryptomator.presentation.ui.activity.WelcomeActivity; import org.cryptomator.presentation.ui.activity.WebDavAddOrChangeActivity; import org.cryptomator.presentation.ui.fragment.AutoUploadChooseVaultFragment; import org.cryptomator.presentation.ui.fragment.BiometricAuthSettingsFragment; @@ -40,6 +41,10 @@ import org.cryptomator.presentation.ui.fragment.UnlockVaultFragment; import org.cryptomator.presentation.ui.fragment.VaultListFragment; import org.cryptomator.presentation.ui.fragment.WebDavAddOrChangeFragment; +import org.cryptomator.presentation.ui.fragment.WelcomeIntroFragment; +import org.cryptomator.presentation.ui.fragment.WelcomeLicenseFragment; +import org.cryptomator.presentation.ui.fragment.WelcomeNotificationsFragment; +import org.cryptomator.presentation.ui.fragment.WelcomeScreenLockFragment; import org.cryptomator.presentation.workflow.AddExistingVaultWorkflow; import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow; @@ -127,4 +132,14 @@ public interface ActivityComponent { void inject(CryptomatorVariantsActivity cryptomatorVariantsActivity); + void inject(WelcomeActivity welcomeActivity); + + void inject(WelcomeIntroFragment welcomeIntroFragment); + + void inject(WelcomeLicenseFragment welcomeLicenseFragment); + + void inject(WelcomeNotificationsFragment welcomeNotificationsFragment); + + 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 new file mode 100644 index 000000000..d539bca85 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/LicenseCheckIntent.java @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.generator.Optional; +import org.cryptomator.presentation.ui.activity.LicenseCheckActivity; + +@Intent(LicenseCheckActivity.class) +public interface LicenseCheckIntent { + + @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 14b654af6..5eb4aac4f 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java @@ -1,6 +1,7 @@ package org.cryptomator.presentation.intent; import org.cryptomator.generator.Intent; +import org.cryptomator.generator.Optional; import org.cryptomator.presentation.model.CloudFileModel; import org.cryptomator.presentation.ui.activity.TextEditorActivity; @@ -9,4 +10,7 @@ public interface TextEditorIntent { CloudFileModel textFile(); + @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 new file mode 100644 index 000000000..24e784b24 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseEnforcer.kt @@ -0,0 +1,170 @@ +package org.cryptomator.presentation.licensing + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.annotation.StringRes +import org.cryptomator.domain.di.PerView +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.presenter.ContextHolder +import org.cryptomator.util.FlavorConfig +import org.cryptomator.util.SharedPreferencesHandler +import java.text.DateFormat +import java.util.Date +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@PerView +class LicenseEnforcer @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) { + + enum class LockedAction( + @StringRes val toastMessageRes: Int, + @StringRes val headerMessageRes: Int + ) { + CREATE_VAULT( + R.string.read_only_reason_create_vault, + R.string.screen_license_check_locked_create_vault, + ), + UPLOAD_FILES( + R.string.read_only_reason_add_file, + R.string.screen_license_check_locked_upload_files, + ), + CREATE_FOLDER( + R.string.read_only_reason_create_folder, + R.string.screen_license_check_locked_create_folder, + ), + CREATE_TEXT_FILE( + R.string.read_only_reason_create_text_file, + R.string.screen_license_check_locked_create_text_file, + ), + SHARE_NODE( + R.string.read_only_reason_share_node, + R.string.screen_license_check_locked_share_node, + ), + RENAME_NODE( + R.string.read_only_reason_rename_node, + R.string.screen_license_check_locked_rename_node, + ), + MOVE_NODE( + R.string.read_only_reason_move_node, + R.string.screen_license_check_locked_move_node, + ), + DELETE_NODE( + R.string.read_only_reason_delete_node, + R.string.screen_license_check_locked_delete_node, + ); + + companion object { + fun fromName(name: String?): LockedAction? { + return values().firstOrNull { it.name == name } + } + } + } + + fun hasWriteAccess(): Boolean { + return hasPaidLicense() || hasActiveTrial() + } + + fun hasPaidLicense(): Boolean { + if (FlavorConfig.isPremiumFlavor) { + return true + } + if (sharedPreferencesHandler.licenseToken().isNotEmpty()) { + return true + } + if (sharedPreferencesHandler.hasRunningSubscription()) { + return true + } + return false + } + + fun startTrial() { + if (sharedPreferencesHandler.trialExpirationDate() > 0) { + return + } + val trialExpiration = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30) + sharedPreferencesHandler.setTrialExpirationDate(trialExpiration) + } + + fun hasActiveTrial(): Boolean { + val trialExpiration = sharedPreferencesHandler.trialExpirationDate() + return trialExpiration > 0 && trialExpiration > System.currentTimeMillis() + } + + fun evaluateTrialState(): TrialState { + val trialExpiration = sharedPreferencesHandler.trialExpirationDate() + val now = System.currentTimeMillis() + val active = trialExpiration > 0 && trialExpiration > now + val expired = trialExpiration > 0 && trialExpiration <= now + val formattedDate = if (active || expired) { + DateFormat.getDateInstance().format(Date(trialExpiration)) + } else null + return TrialState(active, expired, formattedDate) + } + + data class TrialState(val isActive: Boolean, val isExpired: Boolean, val formattedExpirationDate: String?) + + data class LicenseUiState( + val hasWriteAccess: Boolean, + val hasPaidLicense: Boolean, + val trialState: TrialState, + val trialExpirationText: String? + ) + + fun evaluateUiState(context: Context): LicenseUiState { + val trialState = evaluateTrialState() + val expirationText = if (trialState.isActive || trialState.isExpired) { + context.getString(R.string.screen_license_check_trial_expiration, trialState.formattedExpirationDate) + } else null + return LicenseUiState( + hasWriteAccess = hasWriteAccess(), + hasPaidLicense = hasPaidLicense(), + trialState = trialState, + trialExpirationText = expirationText + ) + } + + @StringRes + fun defaultReasonRes(): Int = R.string.read_only_banner + + fun ensureWriteAccess(activity: Activity, action: LockedAction): Boolean { + if (hasWriteAccess()) { + return true + } + + Toast.makeText(activity, activity.getString(action.toastMessageRes), Toast.LENGTH_LONG).show() + + if (FlavorConfig.isPremiumFlavor) { + return false + } + + val intent = Intents.licenseCheckIntent() + .withLockedAction(action.name) + .build(activity as ContextHolder) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + activity.startActivity(intent) + return false + } + + fun hasWriteAccessForVault(vault: VaultModel?): Boolean { + if (vault?.isHubVault == true) { + return vault.hasHubPaidLicense || hasWriteAccess() + } + return hasWriteAccess() + } + + fun ensureWriteAccessForVault(activity: Activity, vault: VaultModel?, action: LockedAction): Boolean { + if (vault?.isHubVault == true) { + if (hasWriteAccessForVault(vault)) { + return true + } + Toast.makeText(activity, R.string.read_only_reason_hub_inactive, Toast.LENGTH_LONG).show() + return false + } + return ensureWriteAccess(activity, action) + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseStateOrchestrator.kt b/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseStateOrchestrator.kt new file mode 100644 index 000000000..2ba5e97b0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/licensing/LicenseStateOrchestrator.kt @@ -0,0 +1,44 @@ +package org.cryptomator.presentation.licensing + +import android.content.Context +import org.cryptomator.util.FlavorConfig +import org.cryptomator.util.SharedPreferencesHandler +import java.util.function.Consumer + +class LicenseStateOrchestrator( + private val sharedPreferencesHandler: SharedPreferencesHandler, + private val licenseEnforcer: LicenseEnforcer, + private val contextProvider: () -> Context, + private val target: Target, + private val priceLoader: (() -> Unit)? = null +) { + + interface Target { + fun onPurchaseStateChanged(hasWriteAccess: Boolean, hasPaidLicense: Boolean) + fun onTrialStateChanged(active: Boolean, expired: Boolean, expirationText: String?) + } + + private val licenseChangeListener = Consumer { _ -> updateState() } + + fun onResume() { + sharedPreferencesHandler.addLicenseChangedListeners(licenseChangeListener) + updateState() + if (FlavorConfig.isFreemiumFlavor) { + priceLoader?.invoke() + } + } + + fun onPause() { + sharedPreferencesHandler.removeLicenseChangedListeners(licenseChangeListener) + } + + fun updateState() { + val uiState = licenseEnforcer.evaluateUiState(contextProvider()) + target.onPurchaseStateChanged(uiState.hasWriteAccess, uiState.hasPaidLicense) + if (FlavorConfig.isFreemiumFlavor && !uiState.hasPaidLicense) { + target.onTrialStateChanged( + uiState.trialState.isActive, uiState.trialState.isExpired, uiState.trialExpirationText + ) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/VaultModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/VaultModel.kt index 0cdbcd460..d5e636159 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/model/VaultModel.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/model/VaultModel.kt @@ -14,6 +14,10 @@ class VaultModel(private val vault: Vault) : Serializable { get() = vault.path val isLocked: Boolean get() = !vault.isUnlocked + val hasHubPaidLicense: Boolean + get() = vault.hasHubPaidLicense() + val isHubVault: Boolean + get() = vault.isHubVault val position: Int get() = vault.position val format: Int diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BaseLicensePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BaseLicensePresenter.kt new file mode 100644 index 000000000..6e88b1357 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BaseLicensePresenter.kt @@ -0,0 +1,60 @@ +package org.cryptomator.presentation.presenter + +import android.net.Uri +import org.cryptomator.domain.usecases.DoLicenseCheckUseCase +import org.cryptomator.domain.usecases.LicenseCheck +import org.cryptomator.domain.usecases.NoOpResultHandler +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.ui.activity.view.UpdateLicenseView +import org.cryptomator.presentation.ui.dialog.AppIsObscuredInfoDialog +import org.cryptomator.util.SharedPreferencesHandler +import javax.inject.Inject + +open class BaseLicensePresenter @Inject internal constructor( + exceptionHandlers: ExceptionHandlers, + private val doLicenseCheckUseCase: DoLicenseCheckUseCase, + protected val sharedPreferencesHandler: SharedPreferencesHandler +) : Presenter(exceptionHandlers) { + + fun validate(data: Uri?) { + data?.let { + val license = it.fragment ?: it.lastPathSegment + if (license.isNullOrEmpty()) { + return + } + view?.showOrUpdateLicenseEntry(license) + doLicenseCheckUseCase + .withLicense(license) + .run(CheckLicenseStatusSubscriber()) + } + } + + fun validateDialogAware(license: String?) { + doLicenseCheckUseCase + .withLicense(license) + .run(CheckLicenseStatusSubscriber()) + } + + fun onFilteredTouchEventForSecurity() { + view?.showDialog(AppIsObscuredInfoDialog.newInstance()) + } + + private inner class CheckLicenseStatusSubscriber : NoOpResultHandler() { + + override fun onSuccess(licenseCheck: LicenseCheck) { + super.onSuccess(licenseCheck) + view?.closeDialog() + sharedPreferencesHandler.setMail(licenseCheck.mail()) + view?.showConfirmationDialog(licenseCheck.mail()) + } + + override fun onError(t: Throwable) { + super.onError(t) + showError(t) + } + } + + init { + unsubscribeOnDestroy(doLicenseCheckUseCase) + } +} 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 20909d5e1..913a93167 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -51,6 +51,7 @@ import org.cryptomator.presentation.intent.BrowseFilesIntent import org.cryptomator.presentation.intent.ChooseCloudNodeSettings import org.cryptomator.presentation.intent.IntentBuilder import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.licensing.LicenseEnforcer import org.cryptomator.presentation.model.CloudFileModel import org.cryptomator.presentation.model.CloudFolderModel import org.cryptomator.presentation.model.CloudModel @@ -125,6 +126,7 @@ class BrowseFilesPresenter @Inject constructor( // private val shareFileHelper: ShareFileHelper, // private val downloadFileUtil: DownloadFileUtil, // private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val licenseEnforcer: LicenseEnforcer, // exceptionMappings: ExceptionHandlers ) : Presenter(exceptionMappings) { @@ -507,10 +509,11 @@ class BrowseFilesPresenter @Inject constructor( // private fun viewFile(cloudFile: CloudFileModel) { val lowerFileName = cloudFile.name.lowercase() if (lowerFileName.endsWith(".txt") || lowerFileName.endsWith(".md") || lowerFileName.endsWith(".todo")) { - startIntent( - Intents.textEditorIntent() // - .withTextFile(cloudFile) - ) + val intent = Intents.textEditorIntent() + .withTextFile(cloudFile) + .withHubWriteAllowed(licenseEnforcer.hasWriteAccessForVault(view?.folder?.vault())) + .build(this) + startIntent(intent) } else if (!lowerFileName.endsWith(".gif") && isImageMediaType(cloudFile.name)) { val cloudFileNodes = previewCloudFileNodes val imagePreviewStore = ImagePreviewFilesStore( // @@ -548,7 +551,11 @@ class BrowseFilesPresenter @Inject constructor( // override fun onSuccess(hash: ByteArray) { openedCloudFileMd5 = hash viewFileIntent.setDataAndType(it, mimeTypes.fromFilename(cloudFile.name)?.toString()) - viewFileIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + var permissionFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION + if (licenseEnforcer.hasWriteAccessForVault(view?.folder?.vault())) { + permissionFlags = permissionFlags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } + viewFileIntent.addFlags(permissionFlags) if (sharedPreferencesHandler.keepUnlockedWhileEditing()) { openWritableFileNotification = OpenWritableFileNotification(context(), it) openWritableFileNotification?.show() @@ -1147,10 +1154,11 @@ class BrowseFilesPresenter @Inject constructor( // view?.hideProgress(textFile) } if (internalEditor) { - startIntent( - Intents.textEditorIntent() // - .withTextFile(textFile) - ) + val editorIntent = Intents.textEditorIntent() + .withTextFile(textFile) + .withHubWriteAllowed(licenseEnforcer.hasWriteAccessForVault(view?.folder?.vault())) + .build(this@BrowseFilesPresenter) + startIntent(editorIntent) } else { viewExternalFile(textFile) } 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 91aa027c2..d847a8cd5 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt @@ -6,8 +6,8 @@ import org.cryptomator.domain.di.PerView import org.cryptomator.domain.exception.FatalBackendException import org.cryptomator.domain.usecases.cloud.GetCloudsUseCase import org.cryptomator.generator.Callback -import org.cryptomator.presentation.BuildConfig import org.cryptomator.presentation.R +import org.cryptomator.util.FlavorConfig import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.model.CloudTypeModel @@ -37,9 +37,9 @@ class ChooseCloudServicePresenter @Inject constructor( // val cloudTypeModels: MutableList = ArrayList(listOf(*CloudTypeModel.values())) cloudTypeModels.remove(CloudTypeModel.CRYPTO) - if (BuildConfig.FLAVOR == "fdroid" || BuildConfig.FLAVOR == "accrescent") { + if (FlavorConfig.excludesGoogleDrive) { cloudTypeModels.remove(CloudTypeModel.GOOGLE_DRIVE) - } else if (BuildConfig.FLAVOR == "lite") { + } else if (FlavorConfig.isLiteFlavor) { cloudTypeModels.remove(CloudTypeModel.GOOGLE_DRIVE) cloudTypeModels.remove(CloudTypeModel.DROPBOX) cloudTypeModels.remove(CloudTypeModel.ONEDRIVE) @@ -95,7 +95,7 @@ class ChooseCloudServicePresenter @Inject constructor( // } fun showCloudMissingSnackbarHintInLiteVariant() { - if (BuildConfig.FLAVOR == "lite") { + if (FlavorConfig.isLiteFlavor) { view?.showSnackbar(R.string.snack_bar_cryptomator_variants_hint, object : SnackbarAction { override fun onClick(v: View?) { startIntent(Intents.cryptomatorVariantsIntent()) 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 155b71ac8..f72c032ea 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt @@ -12,8 +12,8 @@ import org.cryptomator.domain.usecases.cloud.GetAllCloudsUseCase import org.cryptomator.domain.usecases.cloud.GetCloudsUseCase import org.cryptomator.domain.usecases.cloud.LogoutCloudUseCase import org.cryptomator.generator.Callback -import org.cryptomator.presentation.BuildConfig import org.cryptomator.presentation.R +import org.cryptomator.util.FlavorConfig import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.model.CloudModel @@ -132,7 +132,7 @@ class CloudSettingsPresenter @Inject constructor( // override fun onSuccess(clouds: List) { val cloudModel = cloudModelMapper.toModels(clouds) // .filter { isSingleLoginCloud(it) } // - .filter { cloud -> !((BuildConfig.FLAVOR == "fdroid" || BuildConfig.FLAVOR == "accrescent") && cloud.cloudType() == CloudTypeModel.GOOGLE_DRIVE) } // + .filter { cloud -> !(FlavorConfig.excludesGoogleDrive && cloud.cloudType() == CloudTypeModel.GOOGLE_DRIVE) } // .toMutableList() // .also { it.add(aOnedriveCloud()) @@ -141,7 +141,7 @@ class CloudSettingsPresenter @Inject constructor( // it.add(aS3Cloud()) it.add(aLocalCloud()) } - .filter { cloud -> !(BuildConfig.FLAVOR == "lite" && excludeApiCloudsInLite(cloud.cloudType())) } // + .filter { cloud -> !(FlavorConfig.isLiteFlavor && excludeApiCloudsInLite(cloud.cloudType())) } // view?.render(cloudModel) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/LicenseCheckPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/LicenseCheckPresenter.kt index 6e69494be..49001d085 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/LicenseCheckPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/LicenseCheckPresenter.kt @@ -1,59 +1,13 @@ package org.cryptomator.presentation.presenter -import android.net.Uri import org.cryptomator.domain.usecases.DoLicenseCheckUseCase -import org.cryptomator.domain.usecases.LicenseCheck -import org.cryptomator.domain.usecases.NoOpResultHandler import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.ui.activity.view.UpdateLicenseView -import org.cryptomator.presentation.ui.dialog.AppIsObscuredInfoDialog import org.cryptomator.util.SharedPreferencesHandler import javax.inject.Inject -import timber.log.Timber class LicenseCheckPresenter @Inject internal constructor( - exceptionHandlers: ExceptionHandlers, // - private val doLicenseCheckUseCase: DoLicenseCheckUseCase, // - private val sharedPreferencesHandler: SharedPreferencesHandler -) : Presenter(exceptionHandlers) { - - fun validate(data: Uri?) { - data?.let { - val license = it.fragment ?: it.lastPathSegment ?: "" - view?.showOrUpdateLicenseDialog(license) - doLicenseCheckUseCase - .withLicense(license) - .run(CheckLicenseStatusSubscriber()) - } - } - - fun validateDialogAware(license: String?) { - doLicenseCheckUseCase - .withLicense(license) - .run(CheckLicenseStatusSubscriber()) - } - - fun onFilteredTouchEventForSecurity() { - view?.showDialog(AppIsObscuredInfoDialog.newInstance()) - } - - private inner class CheckLicenseStatusSubscriber : NoOpResultHandler() { - - override fun onSuccess(licenseCheck: LicenseCheck) { - super.onSuccess(licenseCheck) - view?.closeDialog() - Timber.tag("LicenseCheckPresenter").i("Your license is valid!") - sharedPreferencesHandler.setMail(licenseCheck.mail()) - view?.showConfirmationDialog(licenseCheck.mail()) - } - - override fun onError(t: Throwable) { - super.onError(t) - showError(t) - } - } - - init { - unsubscribeOnDestroy(doLicenseCheckUseCase) - } -} + exceptionHandlers: ExceptionHandlers, + doLicenseCheckUseCase: DoLicenseCheckUseCase, + sharedPreferencesHandler: SharedPreferencesHandler +) : BaseLicensePresenter(exceptionHandlers, doLicenseCheckUseCase, sharedPreferencesHandler) 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 247c1e42b..8b8ff77b1 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt @@ -90,6 +90,9 @@ class SettingsPresenter @Inject internal constructor( "accrescent" -> { "Accrescent" } + "playstoreiap" -> { + "Google Play IAP" + } else -> "Google Play" } return StringBuilder().append("## ").append(context().getString(R.string.error_report_subject)).append("\n\n") // 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 b9c87bf3a..bb8e0b9c6 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt @@ -20,6 +20,7 @@ import org.cryptomator.generator.Callback import org.cryptomator.generator.InstanceState import org.cryptomator.presentation.R import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.licensing.LicenseEnforcer import org.cryptomator.presentation.intent.ChooseCloudNodeSettings import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.intent.UnlockVaultIntent @@ -51,6 +52,7 @@ class SharedFilesPresenter @Inject constructor( // private val authenticationExceptionHandler: AuthenticationExceptionHandler, // private val cloudFolderModelMapper: CloudFolderModelMapper, // private val progressModelMapper: ProgressModelMapper, // + private val licenseEnforcer: LicenseEnforcer, // exceptionMappings: ExceptionHandlers ) : Presenter(exceptionMappings) { @@ -304,6 +306,12 @@ class SharedFilesPresenter @Inject constructor( // } fun onSaveButtonPressed(filesForUpload: List) { + if (selectedVault != null && !licenseEnforcer.hasWriteAccessForVault(selectedVault)) { + view?.let { v -> + licenseEnforcer.ensureWriteAccessForVault(v.activity(), selectedVault, LicenseEnforcer.LockedAction.UPLOAD_FILES) + } + return + } updateFileNames(filesForUpload) when { hasFileNameConflict() -> { @@ -380,6 +388,7 @@ class SharedFilesPresenter @Inject constructor( // fun onVaultSelected(vault: VaultModel?) { selectedVault = vault + view?.setUploadEnabled(vault != null) } private fun setAuthenticationState(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 4e6e2e1d4..cfdf43bf3 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -6,9 +6,9 @@ import android.app.admin.DevicePolicyManager import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.net.Uri +import android.content.pm.PackageManager import android.os.Build -import android.widget.Toast +import androidx.core.content.ContextCompat import com.google.common.base.Optional import org.cryptomator.data.cloud.crypto.CryptoCloud import org.cryptomator.data.util.NetworkConnectionCheck @@ -17,12 +17,9 @@ import org.cryptomator.domain.CloudFolder import org.cryptomator.domain.CloudType import org.cryptomator.domain.Vault import org.cryptomator.domain.di.PerView -import org.cryptomator.domain.exception.license.LicenseNotValidException -import org.cryptomator.domain.usecases.DoLicenseCheckUseCase import org.cryptomator.domain.usecases.DoUpdateCheckUseCase import org.cryptomator.domain.usecases.DoUpdateUseCase import org.cryptomator.domain.usecases.GetDecryptedCloudForVaultUseCase -import org.cryptomator.domain.usecases.LicenseCheck import org.cryptomator.domain.usecases.NoOpResultHandler import org.cryptomator.domain.usecases.UpdateCheck import org.cryptomator.domain.usecases.cloud.GetRootFolderUseCase @@ -43,17 +40,19 @@ import org.cryptomator.presentation.R import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.intent.UnlockVaultIntent +import org.cryptomator.presentation.licensing.LicenseEnforcer import org.cryptomator.presentation.model.CloudModel import org.cryptomator.presentation.model.CloudTypeModel import org.cryptomator.presentation.model.ProgressModel import org.cryptomator.presentation.model.VaultModel import org.cryptomator.presentation.model.mappers.CloudFolderModelMapper -import org.cryptomator.presentation.ui.activity.LicenseCheckActivity +import org.cryptomator.presentation.ui.activity.WelcomeActivity import org.cryptomator.presentation.ui.activity.view.VaultListView import org.cryptomator.presentation.ui.dialog.AppIsObscuredInfoDialog import org.cryptomator.presentation.ui.dialog.AskForLockScreenDialog import org.cryptomator.presentation.ui.dialog.CBCPasswordVaultsMigrationDialog import org.cryptomator.presentation.ui.dialog.EnterPasswordDialog +import org.cryptomator.presentation.ui.dialog.TrialExpiredDialog import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog import org.cryptomator.presentation.ui.dialog.UpdateAppDialog import org.cryptomator.presentation.ui.dialog.VaultsRemovedDuringMigrationDialog @@ -64,6 +63,7 @@ import org.cryptomator.presentation.workflow.AuthenticationExceptionHandler import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow import org.cryptomator.presentation.workflow.PermissionsResult import org.cryptomator.presentation.workflow.Workflow +import org.cryptomator.util.FlavorConfig import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.crypto.CryptoMode import javax.inject.Inject @@ -81,7 +81,6 @@ class VaultListPresenter @Inject constructor( // private val createNewVaultWorkflow: CreateNewVaultWorkflow, // private val saveVaultUseCase: SaveVaultUseCase, // private val moveVaultPositionUseCase: MoveVaultPositionUseCase, // - private val licenseCheckUseCase: DoLicenseCheckUseCase, // private val updateCheckUseCase: DoUpdateCheckUseCase, // private val updateUseCase: DoUpdateUseCase, // private val updateVaultParameterIfChangedRemotelyUseCase: UpdateVaultParameterIfChangedRemotelyUseCase, // @@ -92,16 +91,43 @@ class VaultListPresenter @Inject constructor( // private val fileUtil: FileUtil, // private val authenticationExceptionHandler: AuthenticationExceptionHandler, // private val cloudFolderModelMapper: CloudFolderModelMapper, // + private val licenseEnforcer: LicenseEnforcer, // private val sharedPreferencesHandler: SharedPreferencesHandler, // exceptionMappings: ExceptionHandlers ) : Presenter(exceptionMappings) { private var vaultAction: VaultAction? = null + private var hasShownTrialExpiredDialog = false override fun workflows(): Iterable> { return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) } + override fun resumed() { + if (launchWelcomeFlowIfNeeded()) { + return + } + + if (!hasShownTrialExpiredDialog && FlavorConfig.isFreemiumFlavor) { + val trialState = licenseEnforcer.evaluateTrialState() + if (trialState.isExpired && !licenseEnforcer.hasPaidLicense()) { + hasShownTrialExpiredDialog = true + view?.showDialog(TrialExpiredDialog.newInstance()) + } + } + } + + private fun launchWelcomeFlowIfNeeded(): Boolean { + if (!sharedPreferencesHandler.hasCompletedWelcomeFlow()) { + requestActivityResult( + ActivityResultCallbacks.welcomeFlowCompleted(), + Intent(context(), WelcomeActivity::class.java) + ) + return true + } + return false + } + fun onWindowFocusChanged(hasFocus: Boolean) { if (hasFocus) { loadVaultList() @@ -109,6 +135,10 @@ class VaultListPresenter @Inject constructor( // } fun prepareView() { + if (launchWelcomeFlowIfNeeded()) { + return + } + if (!sharedPreferencesHandler.isScreenLockDialogAlreadyShown) { val keyguardManager = context().getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager if (!keyguardManager.isKeyguardSecure) { @@ -123,43 +153,13 @@ class VaultListPresenter @Inject constructor( // sharedPreferencesHandler.vaultsRemovedDuringMigration(null) } - checkLicense() + if (FlavorConfig.isApkStoreFlavor && sharedPreferencesHandler.doUpdate()) { + checkForAppUpdates() + } checkPermissions() } - private fun checkLicense() { - if (BuildConfig.FLAVOR == "apkstore" || BuildConfig.FLAVOR == "fdroid" || BuildConfig.FLAVOR == "lite" || BuildConfig.FLAVOR == "accrescent") { - licenseCheckUseCase // - .withLicense("") // - .run(object : NoOpResultHandler() { - override fun onSuccess(licenseCheck: LicenseCheck) { - if (BuildConfig.FLAVOR == "apkstore" && sharedPreferencesHandler.doUpdate()) { - checkForAppUpdates() - } - } - - override fun onError(e: Throwable) { - val license = if (e is LicenseNotValidException) { - e.license - } else { - "" - } - val intent = Intent(context(), LicenseCheckActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - intent.data = Uri.parse(String.format("app://cryptomator/%s", license)) - - try { - context().startActivity(intent) - } catch (e: ActivityNotFoundException) { - Toast.makeText(context(), "Please contact the support.", Toast.LENGTH_LONG).show() - finish() - } - } - }) - } - } - private fun checkForAppUpdates() { if (networkConnectionCheck.isPresent) { updateCheckUseCase // @@ -226,15 +226,22 @@ class VaultListPresenter @Inject constructor( // } private fun checkNotificationPermission() { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2) { + if (shouldRequestNotificationPermission()) { requestPermissions( PermissionsResultCallbacks.requestNotificationPermission(), // R.string.permission_snackbar_notifications, // Manifest.permission.POST_NOTIFICATIONS ) + } else { + checkCBCEncryptedVaults() } } + private fun shouldRequestNotificationPermission(): Boolean { + return Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2 && + ContextCompat.checkSelfPermission(context(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + } + @Callback fun requestNotificationPermission(result: PermissionsResult) { if (!result.granted()) { @@ -503,6 +510,11 @@ class VaultListPresenter @Inject constructor( // } } + @Callback + fun welcomeFlowCompleted(result: ActivityResult) { + prepareView() + } + @Callback fun vaultUnlockedVaultList(result: ActivityResult) { val cloud = result.intent().getSerializableExtra(SINGLE_RESULT) as Cloud @@ -644,7 +656,6 @@ class VaultListPresenter @Inject constructor( // getVaultListUseCase, // saveVaultUseCase, // moveVaultPositionUseCase, // - licenseCheckUseCase, // updateCheckUseCase, // updateUseCase, // listCBCEncryptedPasswordVaultsUseCase, // diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/WelcomePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/WelcomePresenter.kt new file mode 100644 index 000000000..d1ccdcab5 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/WelcomePresenter.kt @@ -0,0 +1,56 @@ +package org.cryptomator.presentation.presenter + +import android.Manifest +import android.app.admin.DevicePolicyManager +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Build +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.DoLicenseCheckUseCase +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.ui.activity.view.WelcomeView +import org.cryptomator.presentation.workflow.PermissionsResult +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber +import javax.inject.Inject + +@PerView +class WelcomePresenter @Inject internal constructor( + exceptionHandlers: ExceptionHandlers, + doLicenseCheckUseCase: DoLicenseCheckUseCase, + sharedPreferencesHandler: SharedPreferencesHandler +) : BaseLicensePresenter(exceptionHandlers, doLicenseCheckUseCase, sharedPreferencesHandler) { + + fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + view?.onNotificationPermissionResult(true) + return + } + requestPermissions( + PermissionsResultCallbacks.requestWelcomeNotificationPermission(), + R.string.permission_snackbar_notifications, + Manifest.permission.POST_NOTIFICATIONS + ) + } + + @Callback + fun requestWelcomeNotificationPermission(result: PermissionsResult) { + if (!result.granted()) { + Timber.tag("WelcomePresenter").e("Notification permission not granted, notifications will not show") + } + view?.onNotificationPermissionResult(result.granted()) + } + + fun onSetScreenLock(setScreenLock: Boolean) { + if (setScreenLock) { + try { + view?.activity()?.startActivity(Intent(DevicePolicyManager.ACTION_SET_NEW_PASSWORD)) + } catch (e: ActivityNotFoundException) { + Timber.tag("WelcomePresenter").d(e, "Device Policy Manager not found") + } + } + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt b/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt new file mode 100644 index 000000000..0ef8e3b09 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/ProductInfo.kt @@ -0,0 +1,25 @@ +package org.cryptomator.presentation.service + +data class ProductInfo( + val productId: String, + val formattedPrice: String +) { + companion object { + const val PRODUCT_FULL_VERSION = "full_version" + const val PRODUCT_YEARLY_SUBSCRIPTION = "yearly_subscription" + } +} + +data class ProductPrices( + val subscriptionPrice: String?, + val lifetimePrice: String? +) + +fun List.resolveProductPrices(): ProductPrices { + val subscription = find { it.productId == ProductInfo.PRODUCT_YEARLY_SUBSCRIPTION } + val lifetime = find { it.productId == ProductInfo.PRODUCT_FULL_VERSION } + return ProductPrices( + subscriptionPrice = subscription?.formattedPrice, + lifetimePrice = lifetime?.formattedPrice + ) +} 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 f3d42fa02..efa90346d 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 @@ -21,6 +21,7 @@ import org.cryptomator.presentation.intent.ChooseCloudNodeSettings import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.BROWSE_FILES import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.MOVE_CLOUD_NODE import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.SELECT_ITEMS +import org.cryptomator.presentation.licensing.LicenseEnforcer import org.cryptomator.presentation.model.CloudFileModel import org.cryptomator.presentation.model.CloudFolderModel import org.cryptomator.presentation.model.CloudNodeModel @@ -70,6 +71,9 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi @Inject lateinit var browseFilesPresenter: BrowseFilesPresenter + @Inject + lateinit var licenseEnforcer: LicenseEnforcer + @InjectIntent lateinit var browseFilesIntent: BrowseFilesIntent @@ -184,14 +188,18 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi true } R.id.action_delete_items -> { - showConfirmDeleteNodeDialog(browseFilesFragment().selectedCloudNodes) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.DELETE_NODE)) { + showConfirmDeleteNodeDialog(browseFilesFragment().selectedCloudNodes) + } true } R.id.action_move_items -> { - browseFilesPresenter.onMoveNodesClicked( - folder, // - browseFilesFragment().selectedCloudNodes as ArrayList> - ) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.MOVE_NODE)) { + browseFilesPresenter.onMoveNodesClicked( + folder, // + browseFilesFragment().selectedCloudNodes as ArrayList> + ) + } true } R.id.action_export_items -> { @@ -202,7 +210,9 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi true } R.id.action_share_items -> { - browseFilesPresenter.onShareNodesClicked(browseFilesFragment().selectedCloudNodes) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.SHARE_NODE)) { + browseFilesPresenter.onShareNodesClicked(browseFilesFragment().selectedCloudNodes) + } true } R.id.action_sort_az -> { @@ -459,7 +469,9 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi } override fun onCreateNewFolderClicked() { - showCreateFolderDialog() + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.CREATE_FOLDER)) { + showCreateFolderDialog() + } } private fun showCreateFolderDialog() { @@ -467,19 +479,27 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi } override fun onUploadFilesClicked(folder: CloudFolderModel) { - browseFilesPresenter.onUploadFilesClicked(folder) + if (ensureWriteAccessForFolder(folder, LicenseEnforcer.LockedAction.UPLOAD_FILES)) { + browseFilesPresenter.onUploadFilesClicked(folder) + } } override fun onCreateNewTextFileClicked() { - browseFilesPresenter.onCreateNewTextFileClicked() + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.CREATE_TEXT_FILE)) { + browseFilesPresenter.onCreateNewTextFileClicked() + } } override fun onRenameFileClicked(cloudFile: CloudFileModel) { - onRenameCloudNodeClicked(cloudFile) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.RENAME_NODE)) { + onRenameCloudNodeClicked(cloudFile) + } } override fun onRenameFolderClicked(cloudFolderModel: CloudFolderModel) { - onRenameCloudNodeClicked(cloudFolderModel) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.RENAME_NODE)) { + onRenameCloudNodeClicked(cloudFolderModel) + } } private fun onRenameCloudNodeClicked(cloudNodeModel: CloudNodeModel<*>) { @@ -487,15 +507,21 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi } override fun onDeleteNodeClicked(cloudFile: CloudNodeModel<*>) { - showConfirmDeleteNodeDialog(listOf(cloudFile)) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.DELETE_NODE)) { + showConfirmDeleteNodeDialog(listOf(cloudFile)) + } } override fun onShareFileClicked(cloudFile: CloudFileModel) { - browseFilesPresenter.onShareFileClicked(cloudFile) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.SHARE_NODE)) { + browseFilesPresenter.onShareFileClicked(cloudFile) + } } override fun onMoveFileClicked(cloudFile: CloudFileModel) { - browseFilesPresenter.onMoveNodeClicked(folder, cloudFile) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.MOVE_NODE)) { + browseFilesPresenter.onMoveNodeClicked(folder, cloudFile) + } } override fun onOpenWithTextFileClicked(cloudFile: CloudFileModel) { @@ -507,7 +533,9 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi } override fun onMoveFolderClicked(cloudFolderModel: CloudFolderModel) { - browseFilesPresenter.onMoveNodeClicked(folder, cloudFolderModel) + if (ensureWriteAccessForCurrentVault(LicenseEnforcer.LockedAction.MOVE_NODE)) { + browseFilesPresenter.onMoveNodeClicked(folder, cloudFolderModel) + } } private fun createBackStackFor(sourceParent: CloudFolderModel) { @@ -555,6 +583,15 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi private fun browseFilesFragment(): BrowseFilesFragment = getCurrentFragment(R.id.fragment_container) as BrowseFilesFragment + private fun ensureWriteAccessForCurrentVault(action: LicenseEnforcer.LockedAction): Boolean { + return ensureWriteAccessForFolder(browseFilesFragment().folder, action) + } + + private fun ensureWriteAccessForFolder(folder: CloudFolderModel?, action: LicenseEnforcer.LockedAction): Boolean { + val targetFolder = folder ?: browseFilesFragment().folder + return licenseEnforcer.ensureWriteAccessForVault(this, targetFolder.vault(), action) + } + 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 95cc9e44b..2219c607b 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 @@ -3,73 +3,146 @@ package org.cryptomator.presentation.ui.activity import android.content.Intent import android.net.Uri import android.os.Bundle +import android.view.View import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R -import org.cryptomator.presentation.databinding.ActivityLayoutObscureAwareBinding +import org.cryptomator.presentation.databinding.ActivityLicenseCheckBinding import org.cryptomator.presentation.intent.Intents.vaultListIntent +import org.cryptomator.presentation.intent.LicenseCheckIntent +import org.cryptomator.presentation.licensing.LicenseEnforcer +import org.cryptomator.presentation.licensing.LicenseStateOrchestrator import org.cryptomator.presentation.presenter.LicenseCheckPresenter import org.cryptomator.presentation.ui.activity.view.UpdateLicenseView import org.cryptomator.presentation.ui.dialog.LicenseConfirmationDialog -import org.cryptomator.presentation.ui.dialog.UpdateLicenseDialog +import org.cryptomator.presentation.ui.layout.LicenseContentViewBinder import org.cryptomator.presentation.ui.layout.ObscuredAwareCoordinatorLayout +import org.cryptomator.util.FlavorConfig import javax.inject.Inject -import kotlin.system.exitProcess @Activity -class LicenseCheckActivity : BaseActivity(ActivityLayoutObscureAwareBinding::inflate), // - UpdateLicenseDialog.Callback, // +class LicenseCheckActivity : BaseActivity(ActivityLicenseCheckBinding::inflate), // LicenseConfirmationDialog.Callback, // UpdateLicenseView { @Inject lateinit var licenseCheckPresenter: LicenseCheckPresenter + @Inject + lateinit var licenseEnforcer: LicenseEnforcer + + @InjectIntent + lateinit var licenseCheckIntent: LicenseCheckIntent + + private var lockedAction: LicenseEnforcer.LockedAction? = null + private val licenseContentViewBinder by lazy { LicenseContentViewBinder(binding.licenseContent, FlavorConfig.isFreemiumFlavor) } + + private val orchestrator by lazy { + LicenseStateOrchestrator( + sharedPreferencesHandler, licenseEnforcer, { this }, + target = object : LicenseStateOrchestrator.Target { + override fun onPurchaseStateChanged(hasWriteAccess: Boolean, hasPaidLicense: Boolean) { + licenseContentViewBinder.bindPurchaseState(hasWriteAccess, hasPaidLicense) + } + override fun onTrialStateChanged(active: Boolean, expired: Boolean, expirationText: String?) { + licenseContentViewBinder.bindTrialState(active, expired, expirationText) + } + }, + priceLoader = { licenseContentViewBinder.loadAndBindPrices(application as CryptomatorApp) } + ) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + lockedAction = LicenseEnforcer.LockedAction.fromName(licenseCheckIntent.lockedAction()) binding.activityRootView.setOnFilteredTouchEventForSecurityListener(object : ObscuredAwareCoordinatorLayout.Listener { override fun onFilteredTouchEventForSecurity() { licenseCheckPresenter.onFilteredTouchEventForSecurity() } }) - + setupUpsellView() validate(intent) } - override fun setupView() { - setupToolbar() + override fun onResume() { + super.onResume() + orchestrator.onResume() } - override fun checkLicenseClicked(license: String?) { - licenseCheckPresenter.validateDialogAware(license) + override fun onPause() { + super.onPause() + orchestrator.onPause() } - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) + override fun setupView() { + // handled in onCreate via setupUpsellView + } - validate(intent) + private fun setupUpsellView() { + setSupportActionBar(binding.mtToolbar.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_clear) + binding.mtToolbar.toolbar.setNavigationOnClickListener { finish() } + + val action = lockedAction + if (action != null) { + binding.licenseContent.tvInfoText.visibility = View.VISIBLE + binding.licenseContent.tvInfoText.text = getString(action.headerMessageRes) + } else { + binding.licenseContent.tvInfoText.visibility = View.GONE + binding.licenseContent.tvInfoText.text = null + } + + if (FlavorConfig.isFreemiumFlavor) { + setupIapView() + } else { + setupLicenseEntryView() + } } - fun validate(intent: Intent) { - val data: Uri? = intent.data - licenseCheckPresenter.validate(data) + private fun setupIapView() { + supportActionBar?.title = getString(R.string.screen_license_check_title_full_version) + licenseContentViewBinder.bindInitialIapLayout() + licenseContentViewBinder.bindLegalLinks() + licenseContentViewBinder.bindPurchaseButtons( + activity = this, + app = application as CryptomatorApp, + onTrialClicked = { + licenseEnforcer.startTrial() + orchestrator.updateState() + } + ) } - override fun showOrUpdateLicenseDialog(license: String) { - showDialog(UpdateLicenseDialog.newInstance(license)) + private fun setupLicenseEntryView() { + supportActionBar?.title = getString(R.string.screen_license_check_title) + licenseContentViewBinder.bindInitialLicenseEntryLayout() + binding.licenseContent.btnPurchase.visibility = View.VISIBLE + binding.licenseContent.btnPurchase.text = getString(R.string.dialog_enter_license_ok_button) + binding.licenseContent.btnPurchase.setOnClickListener { onLicenseSubmit() } + binding.licenseContent.tvLicenseLink.setOnClickListener { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://cryptomator.org/android/"))) + } } - override fun onCheckLicenseCanceled() { - exitProcess(0) + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + Activities.setIntent(this) + lockedAction = LicenseEnforcer.LockedAction.fromName(licenseCheckIntent.lockedAction()) + setupUpsellView() + validate(intent) } - override fun appObscuredClosingEnterLicenseDialog() { - closeDialog() - licenseCheckPresenter.onFilteredTouchEventForSecurity() + private fun validate(intent: Intent) { + val data: Uri? = intent.data + licenseCheckPresenter.validate(data) } - private fun setupToolbar() { - binding.mtToolbar.toolbar.title = getString(R.string.app_name).uppercase() - setSupportActionBar(binding.mtToolbar.toolbar) + override fun showOrUpdateLicenseEntry(license: String) { + binding.licenseContent.etLicense.setText(license) + binding.licenseContent.licenseEntryGroup.visibility = View.VISIBLE } override fun showConfirmationDialog(mail: String) { @@ -81,4 +154,9 @@ class LicenseCheckActivity : BaseActivity(Act .preventGoingBackInHistory() // .startActivity(this) // } + + 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 6b318c880..2ce4615dd 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 @@ -179,6 +179,10 @@ class SharedFilesActivity : BaseActivity(ActivityLayoutBi finish() } + override fun setUploadEnabled(enabled: Boolean) { + sharedFilesFragment().setUploadEnabled(enabled) + } + 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 541a44a3c..10166e510 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 @@ -9,6 +9,7 @@ import org.cryptomator.generator.InjectIntent import org.cryptomator.presentation.R import org.cryptomator.presentation.databinding.ActivityLayoutBinding import org.cryptomator.presentation.intent.TextEditorIntent +import org.cryptomator.presentation.licensing.LicenseEnforcer import org.cryptomator.presentation.presenter.TextEditorPresenter import org.cryptomator.presentation.ui.activity.view.TextEditorView import org.cryptomator.presentation.ui.dialog.UnsavedChangesDialog @@ -24,9 +25,16 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin @Inject lateinit var textEditorPresenter: TextEditorPresenter + @Inject + lateinit var licenseEnforcer: LicenseEnforcer + @InjectIntent lateinit var textEditorIntent: TextEditorIntent + private fun hasWriteAccess(): Boolean { + return licenseEnforcer.hasWriteAccess() || textEditorIntent.hubWriteAllowed() == true + } + override val textFileContent: String get() = textEditorFragment().textFileContent @@ -38,6 +46,10 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin override fun createFragment(): Fragment = TextEditorFragment() override fun onBackPressed() { + if (!hasWriteAccess()) { + super.onBackPressed() + return + } textEditorPresenter.onBackPressed() } @@ -97,6 +109,8 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin val searchView = menu.findItem(R.id.action_search).actionView as SearchView searchView.setOnQueryTextListener(this) + menu.findItem(R.id.action_save_changes).isVisible = hasWriteAccess() + return super.onPrepareOptionsMenu(menu) } @@ -115,6 +129,9 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin override fun displayTextFileContent(textFileContent: String) { textEditorFragment().displayTextFileContent(textFileContent) + if (!hasWriteAccess()) { + textEditorFragment().setReadOnly() + } } override fun onSaveChangesClicked() { @@ -130,5 +147,4 @@ class TextEditorActivity : BaseActivity(ActivityLayoutBin } 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 409b867f2..3895ca633 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 @@ -12,9 +12,11 @@ import org.cryptomator.generator.InjectIntent import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R import org.cryptomator.presentation.databinding.ActivityLayoutObscureAwareBinding +import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.intent.Intents.browseFilesIntent import org.cryptomator.presentation.intent.Intents.settingsIntent import org.cryptomator.presentation.intent.VaultListIntent +import org.cryptomator.presentation.licensing.LicenseEnforcer import org.cryptomator.presentation.model.CloudFolderModel import org.cryptomator.presentation.model.ProgressModel import org.cryptomator.presentation.model.VaultModel @@ -27,6 +29,7 @@ import org.cryptomator.presentation.ui.callback.VaultListCallback import org.cryptomator.presentation.ui.dialog.AskForLockScreenDialog import org.cryptomator.presentation.ui.dialog.BetaConfirmationDialog import org.cryptomator.presentation.ui.dialog.CBCPasswordVaultsMigrationDialog +import org.cryptomator.presentation.ui.dialog.TrialExpiredDialog import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog import org.cryptomator.presentation.ui.dialog.UpdateAppDialog import org.cryptomator.presentation.ui.dialog.VaultDeleteConfirmationDialog @@ -45,11 +48,15 @@ class VaultListActivity : BaseActivity(Activi UpdateAppDialog.Callback, // BetaConfirmationDialog.Callback, // CBCPasswordVaultsMigrationDialog.Callback, // - BiometricAuthenticationMigration.Callback { + BiometricAuthenticationMigration.Callback, // + TrialExpiredDialog.Callback { @Inject lateinit var vaultListPresenter: VaultListPresenter + @Inject + lateinit var licenseEnforcer: LicenseEnforcer + @InjectIntent lateinit var vaultListIntent: VaultListIntent @@ -174,6 +181,9 @@ class VaultListActivity : BaseActivity(Activi } override fun onCreateVault() { + if (!licenseEnforcer.ensureWriteAccess(this, LicenseEnforcer.LockedAction.CREATE_VAULT)) { + return + } vaultListPresenter.onCreateVault() } @@ -206,6 +216,10 @@ class VaultListActivity : BaseActivity(Activi vaultListPresenter.onAskForLockScreenFinished(setScreenLock) } + override fun onUnlockFullVersionClicked() { + Intents.licenseCheckIntent().startActivity(this) + } + private fun vaultListFragment(): VaultListFragment = // getCurrentFragment(R.id.fragment_container) as VaultListFragment 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 new file mode 100644 index 000000000..00d59a2b6 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/WelcomeActivity.kt @@ -0,0 +1,327 @@ +package org.cryptomator.presentation.ui.activity + +import android.Manifest +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.viewpager2.widget.ViewPager2 +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.CryptomatorApp +import org.cryptomator.presentation.R +import org.cryptomator.presentation.databinding.ActivityWelcomeBinding +import org.cryptomator.presentation.licensing.LicenseEnforcer +import org.cryptomator.presentation.licensing.LicenseStateOrchestrator +import org.cryptomator.presentation.presenter.WelcomePresenter +import org.cryptomator.presentation.ui.activity.view.UpdateLicenseView +import org.cryptomator.presentation.ui.activity.view.WelcomeView +import org.cryptomator.presentation.ui.fragment.WelcomeIntroFragment +import org.cryptomator.presentation.ui.fragment.WelcomeLicenseFragment +import org.cryptomator.presentation.ui.fragment.WelcomeNotificationsFragment +import org.cryptomator.presentation.ui.fragment.WelcomeScreenLockFragment +import org.cryptomator.presentation.ui.layout.ObscuredAwareCoordinatorLayout +import org.cryptomator.util.FlavorConfig +import javax.inject.Inject + +@Activity +class WelcomeActivity : BaseActivity(ActivityWelcomeBinding::inflate), // + UpdateLicenseView, // + WelcomeView, // + WelcomeLicenseFragment.Listener, // + WelcomeNotificationsFragment.Listener, // + WelcomeScreenLockFragment.Listener { + + @Inject + lateinit var welcomePresenter: WelcomePresenter + + @Inject + lateinit var licenseEnforcer: LicenseEnforcer + + private val keyguardManager by lazy { getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager } + + private val orchestrator by lazy { + LicenseStateOrchestrator( + sharedPreferencesHandler, licenseEnforcer, { this }, + target = object : LicenseStateOrchestrator.Target { + override fun onPurchaseStateChanged(hasWriteAccess: Boolean, hasPaidLicense: Boolean) { + if (!this@WelcomeActivity::pagerAdapter.isInitialized) { + return + } + pagerAdapter.licenseFragment?.updateUnlocked(hasWriteAccess, hasPaidLicense) + } + override fun onTrialStateChanged(active: Boolean, expired: Boolean, expirationText: String?) { + if (!this@WelcomeActivity::pagerAdapter.isInitialized) { + return + } + pagerAdapter.licenseFragment?.updateTrialState(active, expired, expirationText) + } + }, + priceLoader = { + if (this@WelcomeActivity::pagerAdapter.isInitialized) { + pagerAdapter.licenseFragment?.loadAndBindPrices(application as CryptomatorApp) + } + } + ) + } + + private lateinit var pagerAdapter: WelcomePagerAdapter + private val pages = mutableListOf() + private var navBasePaddingBottom: Int = 0 + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + validate(intent) + } + + override fun setupView() { + if (sharedPreferencesHandler.hasCompletedWelcomeFlow()) { + openVaultList() + return + } + + setSupportActionBar(binding.mtToolbar.toolbar) + supportActionBar?.title = getString(R.string.screen_welcome_title) + supportActionBar?.setDisplayHomeAsUpEnabled(false) + binding.mtToolbar.toolbar.navigationIcon = null + + binding.activityRootView.setOnFilteredTouchEventForSecurityListener(object : ObscuredAwareCoordinatorLayout.Listener { + override fun onFilteredTouchEventForSecurity() { + welcomePresenter.onFilteredTouchEventForSecurity() + } + }) + navBasePaddingBottom = binding.navigationContainer.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(binding.navigationContainer) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + val extra = (8 * resources.displayMetrics.density).toInt() + view.updatePadding(bottom = navBasePaddingBottom + systemBars.bottom + extra) + insets + } + + setupPages() + setupPager() + + validate(intent) + updateNotificationPermissionState() + orchestrator.updateState() + updateScreenLockState() + } + + override fun onResume() { + super.onResume() + if (sharedPreferencesHandler.hasCompletedWelcomeFlow() && !isFinishing) { + openVaultList() + return + } + orchestrator.onResume() + updateNotificationPermissionState() + updateScreenLockState() + } + + override fun onPause() { + super.onPause() + orchestrator.onPause() + } + + private fun setupPages() { + pages.clear() + pages.add(FragmentPage.Intro) + if (!FlavorConfig.isPremiumFlavor) { + pages.add(FragmentPage.License) + } + pages.add(FragmentPage.Notifications) + pages.add(FragmentPage.ScreenLock) + } + + private fun setupPager() { + pagerAdapter = WelcomePagerAdapter(this, pages) + binding.welcomePager.adapter = pagerAdapter + binding.welcomePager.setCurrentItem(0, false) + binding.welcomePager.isUserInputEnabled = true + binding.welcomePager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + updateNavigationButtons(position) + if (FlavorConfig.isFreemiumFlavor && pages[position] is FragmentPage.License) { + orchestrator.updateState() + } + } + }) + updateNavigationButtons(0) + binding.btnBack.setOnClickListener { + val pos = binding.welcomePager.currentItem + if (pos > 0) { + binding.welcomePager.currentItem = pos - 1 + } + } + binding.btnNext.setOnClickListener { + advanceOrComplete() + } + } + + private fun updateNavigationButtons(position: Int) { + binding.btnBack.visibility = if (position == 0) View.INVISIBLE else View.VISIBLE + binding.btnNext.text = if (position == pagerAdapter.itemCount - 1) { + getString(R.string.screen_welcome_continue_button) + } else { + getString(R.string.next) + } + } + + private fun needsNotificationPermission(): Boolean { + return Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2 + } + + private fun hasNotificationPermission(): Boolean { + return !needsNotificationPermission() || ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + private fun updateNotificationPermissionState(grantedOverride: Boolean? = null) { + if (!this::pagerAdapter.isInitialized) { + return + } + if (!needsNotificationPermission()) { + return + } + val granted = grantedOverride ?: hasNotificationPermission() + pagerAdapter.notificationsFragment?.updatePermissionState(granted) + } + + private fun updateScreenLockState() { + if (!this::pagerAdapter.isInitialized) { + return + } + pagerAdapter.screenLockFragment?.updateScreenLockState(keyguardManager.isKeyguardSecure) + } + + private fun completeWelcomeFlow() { + sharedPreferencesHandler.setWelcomeFlowCompleted() + sharedPreferencesHandler.setScreenLockDialogAlreadyShown() + openVaultList() + } + + private fun openVaultList() { + setResult(RESULT_OK) + finish() + } + + private fun validate(intent: Intent?) { + val data = intent?.data + if (data != null && !FlavorConfig.isPremiumFlavor) { + welcomePresenter.validate(data) + } + } + + 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 + override fun showConfirmationDialog(mail: String) { + orchestrator.updateState() + autoAdvanceToNextPage() + } + + override fun onNotificationPermissionResult(granted: Boolean) { + updateNotificationPermissionState(granted) + } + + // WelcomeLicenseFragment.Listener + + override fun onLicenseTextChanged(license: String?) { + welcomePresenter.validateDialogAware(license) + } + + override fun onOpenLicenseLink() { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://cryptomator.org/android/"))) + } + + override fun onStartTrial() { + licenseEnforcer.startTrial() + orchestrator.updateState() + autoAdvanceToNextPage() + } + + override fun onSkipLicense() { + advanceOrComplete() + } + + // WelcomeNotificationsFragment.Listener + + override fun onRequestNotifications() { + welcomePresenter.requestNotificationPermission() + } + + // WelcomeScreenLockFragment.Listener + + override fun onSetScreenLock(setScreenLock: Boolean) { + welcomePresenter.onSetScreenLock(setScreenLock) + } + + private fun advanceOrComplete() { + val pos = binding.welcomePager.currentItem + if (pos < pagerAdapter.itemCount - 1) { + binding.welcomePager.currentItem = pos + 1 + } else { + completeWelcomeFlow() + } + } + + private fun autoAdvanceToNextPage() { + val sourcePage = binding.welcomePager.currentItem + binding.welcomePager.postDelayed({ + if (!isFinishing && binding.welcomePager.currentItem == sourcePage && sourcePage < pagerAdapter.itemCount - 1) { + binding.welcomePager.currentItem = sourcePage + 1 + } + }, AUTO_ADVANCE_DELAY_MS) + } + + private sealed class FragmentPage { + object Intro : FragmentPage() + object License : FragmentPage() + object Notifications : FragmentPage() + object ScreenLock : FragmentPage() + } + + private inner class WelcomePagerAdapter(activity: AppCompatActivity, private val pages: List) : androidx.viewpager2.adapter.FragmentStateAdapter(activity) { + + val licenseFragment: WelcomeLicenseFragment? + get() = findPageFragment() + + val notificationsFragment: WelcomeNotificationsFragment? + get() = findPageFragment() + + val screenLockFragment: WelcomeScreenLockFragment? + get() = findPageFragment() + + 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 + + override fun createFragment(position: Int): Fragment { + return when (pages[position]) { + is FragmentPage.Intro -> WelcomeIntroFragment() + is FragmentPage.License -> WelcomeLicenseFragment() + is FragmentPage.Notifications -> WelcomeNotificationsFragment() + is FragmentPage.ScreenLock -> WelcomeScreenLockFragment() + } + } + } + + companion object { + private const val AUTO_ADVANCE_DELAY_MS = 500L + } +} 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 f93948b36..2af11ea37 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 @@ -14,5 +14,6 @@ interface SharedFilesView : View { fun showReplaceDialog(existingFiles: List, size: Int) fun showChosenLocation(folder: CloudFolderModel) fun showUploadDialog(uploadingFiles: Int) + 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 6d4a192ab..bd3d5cf5b 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,7 @@ package org.cryptomator.presentation.ui.activity.view interface UpdateLicenseView : View { - fun showOrUpdateLicenseDialog(license: String) + fun showOrUpdateLicenseEntry(license: String) 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 new file mode 100644 index 000000000..764c96e61 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WelcomeView.kt @@ -0,0 +1,5 @@ +package org.cryptomator.presentation.ui.activity.view + +interface WelcomeView : UpdateLicenseView { + fun onNotificationPermissionResult(granted: Boolean) +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/TrialExpiredDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/TrialExpiredDialog.kt new file mode 100644 index 000000000..3bbff9364 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/TrialExpiredDialog.kt @@ -0,0 +1,36 @@ +package org.cryptomator.presentation.ui.dialog + +import android.content.DialogInterface +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import org.cryptomator.generator.Dialog +import org.cryptomator.presentation.R +import org.cryptomator.presentation.databinding.DialogTrialExpiredBinding + +@Dialog +class TrialExpiredDialog : BaseDialog(DialogTrialExpiredBinding::inflate) { + + interface Callback { + + fun onUnlockFullVersionClicked() + } + + public override fun setupDialog(builder: AlertDialog.Builder): android.app.Dialog { + builder // + .setTitle(R.string.dialog_trial_expired_title) // + .setPositiveButton(getString(R.string.dialog_trial_expired_unlock)) { _: DialogInterface, _: Int -> callback?.onUnlockFullVersionClicked() } // + .setNegativeButton(getString(R.string.dialog_trial_expired_continue_read_only)) { _: DialogInterface, _: Int -> } + return builder.create() + } + + public override fun setupView() { + // empty + } + + companion object { + + fun newInstance(): DialogFragment { + return TrialExpiredDialog() + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/UpdateLicenseDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/UpdateLicenseDialog.kt deleted file mode 100644 index 167b9ed0c..000000000 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/UpdateLicenseDialog.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.cryptomator.presentation.ui.dialog - -import android.content.DialogInterface -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import android.widget.Button -import android.widget.LinearLayout -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import org.cryptomator.generator.Dialog -import org.cryptomator.presentation.R -import org.cryptomator.presentation.databinding.DialogEnterLicenseBinding -import org.cryptomator.presentation.databinding.ViewDialogErrorBinding -import org.cryptomator.presentation.ui.layout.ObscuredAwareDialogCoordinatorLayout -import org.cryptomator.util.SharedPreferencesHandler - -@Dialog -class UpdateLicenseDialog : BaseProgressErrorDialog(DialogEnterLicenseBinding::inflate) { - - // positive button - private var checkLicenseButton: Button? = null - - interface Callback { - - fun checkLicenseClicked(license: String?) - fun onCheckLicenseCanceled() - fun appObscuredClosingEnterLicenseDialog() - } - - override fun onStart() { - super.onStart() - allowClosingDialog(false) - val dialog = dialog as AlertDialog? - dialog?.let { - checkLicenseButton = dialog.getButton(android.app.Dialog.BUTTON_POSITIVE) - checkLicenseButton?.setOnClickListener { - callback?.checkLicenseClicked(binding.etLicense.text.toString()) - onWaitForResponse(binding.etLicense) - } - checkLicenseButton?.let { button -> - binding.etLicense.nextFocusForwardId = button.id - } - binding.tvMessage.setOnClickListener { - Intent(Intent.ACTION_VIEW).let { - it.data = Uri.parse("https://cryptomator.org/android/") - startActivity(it) - } - } - } - - /* need to manually handle this in case of dialogs as otherwise the onFilterTouchEventForSecurity method of the ViewGroup - isn't called when filterTouchesWhenObscured is set to true in the BaseDialog and in contrast to if set in an Activity */ - dialog?.window?.decorView?.filterTouchesWhenObscured = false - binding.dssialogRootView.setOnFilteredTouchEventForSecurityListener(object : ObscuredAwareDialogCoordinatorLayout.Listener { - override fun onFilteredTouchEventForSecurity() { - callback?.appObscuredClosingEnterLicenseDialog() - } - }, SharedPreferencesHandler(requireContext()).disableAppWhenObscured()) - } - - override fun setupDialog(builder: AlertDialog.Builder): android.app.Dialog { - return builder // - .setTitle(getString(R.string.dialog_enter_license_title)) // - .setPositiveButton(getText(R.string.dialog_enter_license_ok_button)) { _: DialogInterface, _: Int -> } // - .setNegativeButton(getText(R.string.dialog_enter_license_decline_button)) { _: DialogInterface, _: Int -> callback?.onCheckLicenseCanceled() } // - .create() - } - - public override fun setupView() { - val license = requireArguments().getSerializable(LICENSE_ARG) as String? - license?.let { binding.etLicense.setText(it) } - binding.etLicense.requestFocus() - checkLicenseButton?.let { registerOnEditorDoneActionAndPerformButtonClick(binding.etLicense) { it } } - } - - - override fun dialogProgressLayout(): LinearLayout { - return binding.llDialogProgress.llProgress - } - - override fun dialogProgressTextView(): TextView { - return binding.llDialogProgress.tvProgress - } - - override fun dialogErrorBinding(): ViewDialogErrorBinding { - return binding.llDialogError - } - - override fun enableViewAfterError(): View { - return binding.etLicense - } - - companion object { - - private const val LICENSE_ARG = "LICENSE" - fun newInstance(license: String?): UpdateLicenseDialog { - val dialog = UpdateLicenseDialog() - val args = Bundle() - args.putSerializable(LICENSE_ARG, license) - dialog.arguments = args - return dialog - } - - fun newInstance(): UpdateLicenseDialog { - return UpdateLicenseDialog() - } - } -} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt index d049d3277..a1c8c8ca8 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt @@ -1,6 +1,7 @@ package org.cryptomator.presentation.ui.fragment import android.content.Intent +import android.net.Uri import android.os.Bundle import android.text.SpannableString import android.text.style.ForegroundColorSpan @@ -13,8 +14,14 @@ import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.SwitchPreference import org.cryptomator.presentation.BuildConfig +import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.licensing.LicenseEnforcer +import org.cryptomator.presentation.presenter.ContextHolder import org.cryptomator.presentation.service.PhotoContentJob +import org.cryptomator.presentation.service.ProductInfo +import org.cryptomator.presentation.service.resolveProductPrices import org.cryptomator.presentation.ui.activity.AutoUploadChooseVaultActivity import org.cryptomator.presentation.ui.activity.BiometricAuthSettingsActivity import org.cryptomator.presentation.ui.activity.CloudSettingsActivity @@ -26,12 +33,15 @@ import org.cryptomator.presentation.ui.dialog.DisableAppWhenObscuredDisclaimerDi import org.cryptomator.presentation.ui.dialog.DisableSecureScreenDisclaimerDialog import org.cryptomator.presentation.ui.dialog.MicrosoftWorkaroundDisclaimerDialog import org.cryptomator.presentation.ui.layout.PreferenceFragmentCompatLayout +import org.cryptomator.util.FlavorConfig import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.SharedPreferencesHandler.Companion.CRYPTOMATOR_VARIANTS import org.cryptomator.util.file.LruFileCacheUtil +import java.util.function.Consumer import java.lang.Boolean.FALSE import java.lang.Boolean.TRUE import java.lang.String.format +import java.lang.ref.WeakReference import java.text.DecimalFormat import kotlin.math.log10 import timber.log.Timber @@ -39,6 +49,7 @@ import timber.log.Timber class SettingsFragment : PreferenceFragmentCompatLayout() { private lateinit var sharedPreferencesHandler: SharedPreferencesHandler + private val licenseChangeListener = Consumer { _ -> setupLicense() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { sharedPreferencesHandler = SharedPreferencesHandler(activity()) @@ -87,9 +98,7 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { LruFileCacheUtil(requireContext()).clear() setupLruCacheSize() } - Toast.makeText(context, context?.getString(R.string.screen_settings_lru_cache_changed__restart_toast), Toast.LENGTH_SHORT).show() - true } @@ -175,18 +184,117 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { } private fun setupLicense() { - when (BuildConfig.FLAVOR) { - "apkstore" -> { - (findPreference(SharedPreferencesHandler.MAIL) as Preference?)?.title = format(getString(R.string.screen_settings_license_mail), sharedPreferencesHandler.mail()) - setupUpdateCheck() + val licenseCategory = findPreference(LICENSE_ITEM_KEY) as PreferenceCategory? + val licensePref = findPreference(SharedPreferencesHandler.MAIL) as Preference? + licenseCategory?.title = getString(R.string.screen_settings_license) + licensePref?.isEnabled = true + when { + FlavorConfig.isPremiumFlavor -> { + licensePref?.let { pref -> + pref.title = getString(R.string.screen_settings_license_title_unlocked) + pref.summary = getString(R.string.screen_settings_license_summary_write_access) + pref.onPreferenceClickListener = null + } + removeUpdateCheck() } - "fdroid", "lite", "accrescent" -> { - (findPreference(SharedPreferencesHandler.MAIL) as Preference?)?.title = format(getString(R.string.screen_settings_license_mail), sharedPreferencesHandler.mail()) + FlavorConfig.isFreemiumFlavor -> { + val licenseEnforcer = LicenseEnforcer(sharedPreferencesHandler) + val uiState = licenseEnforcer.evaluateUiState(requireContext()) + val hasSubscription = sharedPreferencesHandler.hasRunningSubscription() + licensePref?.let { pref -> + if (uiState.hasPaidLicense) { + pref.title = getString(R.string.screen_settings_license_title_unlocked) + pref.summary = getString(R.string.screen_settings_license_summary_write_access) + pref.onPreferenceClickListener = null + } else { + pref.title = getString(R.string.screen_settings_license_title_unlock) + pref.summary = if (uiState.trialState.isActive) { + getString(R.string.screen_settings_license_summary_trial_expires, uiState.trialState.formattedExpirationDate) + } else { + getString(R.string.screen_settings_license_summary_tap_to_unlock) + } + pref.setOnPreferenceClickListener { + Intents.licenseCheckIntent().startActivity(activity() as ContextHolder) + true + } + } + } + licenseCategory?.let { category -> + val hasLifetimeLicense = sharedPreferencesHandler.licenseToken().isNotEmpty() + if (hasSubscription) { + if (category.findPreference(MANAGE_SUBSCRIPTION_KEY) == null) { + category.addPreference(Preference(requireContext()).apply { + key = MANAGE_SUBSCRIPTION_KEY + title = getString(R.string.screen_settings_manage_subscription) + setOnPreferenceClickListener { + val url = "https://play.google.com/store/account/subscriptions" + + "?sku=${ProductInfo.PRODUCT_YEARLY_SUBSCRIPTION}" + + "&package=${requireContext().packageName}" + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + true + } + }) + } + } else { + category.findPreference(MANAGE_SUBSCRIPTION_KEY)?.let { + category.removePreference(it) + } + } + if (hasSubscription && !hasLifetimeLicense) { + if (category.findPreference(UPGRADE_LIFETIME_KEY) == null) { + category.addPreference(Preference(requireContext()).apply { + key = UPGRADE_LIFETIME_KEY + title = getString(R.string.screen_settings_upgrade_to_lifetime) + setOnPreferenceClickListener { + val app = requireActivity().application as CryptomatorApp + app.launchPurchaseFlow(WeakReference(requireActivity()), ProductInfo.PRODUCT_FULL_VERSION) + true + } + }) + val app = requireActivity().application as CryptomatorApp + app.queryProductDetails { products -> + val prices = products.resolveProductPrices() + activity?.runOnUiThread { + if (!isAdded) { + return@runOnUiThread + } + category.findPreference(UPGRADE_LIFETIME_KEY)?.let { pref -> + if (!prices.lifetimePrice.isNullOrEmpty()) { + pref.summary = prices.lifetimePrice + } + } + } + } + } + } else { + category.findPreference(UPGRADE_LIFETIME_KEY)?.let { + category.removePreference(it) + } + } + } removeUpdateCheck() } else -> { - (findPreference(LICENSE_ITEM_KEY) as Preference?)?.let { preferenceScreen.removePreference(it) } - removeUpdateCheck() + licensePref?.let { pref -> + val mail = sharedPreferencesHandler.mail() + if (mail.isEmpty()) { + pref.title = getString(R.string.screen_settings_license_title_unlock) + pref.summary = getString(R.string.screen_settings_license_summary_tap_to_unlock) + pref.setOnPreferenceClickListener { + Intents.licenseCheckIntent().startActivity(activity() as ContextHolder) + true + } + } else { + pref.title = getString(R.string.screen_settings_license_title_unlocked) + pref.summary = format(getString(R.string.screen_settings_license_summary_write_access_mail), mail) + pref.onPreferenceClickListener = null + } + } + if (FlavorConfig.isApkStoreFlavor) { + setupUpdateCheck() + } else { + removeUpdateCheck() + } } } } @@ -221,7 +329,7 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { } private fun setupCryptomatorVariants() { - if (BuildConfig.FLAVOR == "playstore" || BuildConfig.FLAVOR == "accrescent") { + if (FlavorConfig.isPremiumFlavor) { (findPreference(CRYPTOMATOR_VARIANTS) as Preference?)?.let { preference -> (findPreference(getString(R.string.screen_settings_section_general)) as PreferenceCategory?)?.removePreference(preference) } @@ -241,17 +349,23 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { (findPreference(SharedPreferencesHandler.USE_LRU_CACHE) as Preference?)?.onPreferenceChangeListener = useLruChangedListener (findPreference(SharedPreferencesHandler.LRU_CACHE_SIZE) as Preference?)?.onPreferenceChangeListener = useLruChangedListener (findPreference(SharedPreferencesHandler.MICROSOFT_WORKAROUND) as Preference?)?.onPreferenceChangeListener = microsoftWorkaroundChangeListener - if (BuildConfig.FLAVOR == "apkstore") { + if (FlavorConfig.isApkStoreFlavor) { (findPreference(UPDATE_CHECK_ITEM_KEY) as Preference?)?.onPreferenceClickListener = updateCheckClickListener } (findPreference(SharedPreferencesHandler.CLOUD_SETTINGS) as Preference?)?.intent = Intent(context, CloudSettingsActivity::class.java) (findPreference(SharedPreferencesHandler.BIOMETRIC_AUTHENTICATION) as Preference?)?.intent = Intent(context, BiometricAuthSettingsActivity::class.java) - if (BuildConfig.FLAVOR != "playstore") { + if (!FlavorConfig.isPremiumFlavor) { (findPreference(SharedPreferencesHandler.CRYPTOMATOR_VARIANTS) as Preference?)?.intent = Intent(context, CryptomatorVariantsActivity::class.java) } (findPreference(SharedPreferencesHandler.PHOTO_UPLOAD_VAULT) as Preference?)?.intent = Intent(context, AutoUploadChooseVaultActivity::class.java) (findPreference(SharedPreferencesHandler.LICENSES_ACTIVITY) as Preference?)?.intent = Intent(context, LicensesActivity::class.java) + sharedPreferencesHandler.addLicenseChangedListeners(licenseChangeListener) + } + + override fun onPause() { + sharedPreferencesHandler.removeLicenseChangedListeners(licenseChangeListener) + super.onPause() } fun deactivateDebugMode() { @@ -330,6 +444,8 @@ class SettingsFragment : PreferenceFragmentCompatLayout() { private const val SEND_ERROR_REPORT_ITEM_KEY = "sendErrorReport" private const val BIOMETRIC_AUTHENTICATION_ITEM_KEY = "biometricAuthentication" private const val LICENSE_ITEM_KEY = "license" + private const val MANAGE_SUBSCRIPTION_KEY = "manageSubscription" + private const val UPGRADE_LIFETIME_KEY = "upgradeLifetime" private const val UPDATE_CHECK_ITEM_KEY = "updateCheck" private const val UPDATE_INTERVAL_ITEM_KEY = "updateInterval" private const val DISPLAY_LRU_CACHE_SIZE_ITEM_KEY = "displayLruCacheSize" 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 bf72811b0..e3003067b 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 @@ -88,4 +88,8 @@ class SharedFilesFragment : BaseFragment(FragmentSha locationsAdapter.setSelectedLocation(if (folder.path.isEmpty()) "/" else folder.path) } + 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 6945552d9..00ee27626 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 @@ -35,6 +35,12 @@ class TextEditorFragment : BaseFragment(FragmentTextE binding.textEditor.setText(textFileContent) } + fun setReadOnly() { + binding.textEditor.isFocusable = false + binding.textEditor.isFocusableInTouchMode = false + binding.textEditor.isCursorVisible = false + } + 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 new file mode 100644 index 000000000..5733b4064 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeIntroFragment.kt @@ -0,0 +1,12 @@ +package org.cryptomator.presentation.ui.fragment + +import org.cryptomator.generator.Fragment +import org.cryptomator.presentation.databinding.FragmentWelcomeIntroBinding + +@Fragment +class WelcomeIntroFragment : BaseFragment(FragmentWelcomeIntroBinding::inflate) { + + 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 new file mode 100644 index 000000000..388a5a5c1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeLicenseFragment.kt @@ -0,0 +1,116 @@ +package org.cryptomator.presentation.ui.fragment + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import org.cryptomator.generator.Fragment +import org.cryptomator.presentation.CryptomatorApp +import org.cryptomator.presentation.R +import org.cryptomator.presentation.databinding.FragmentWelcomeLicenseBinding +import org.cryptomator.presentation.ui.layout.LicenseContentViewBinder +import org.cryptomator.util.FlavorConfig + +@Fragment +class WelcomeLicenseFragment : BaseFragment(FragmentWelcomeLicenseBinding::inflate) { + + interface Listener { + fun onLicenseTextChanged(license: String?) + fun onOpenLicenseLink() + fun onStartTrial() + fun onSkipLicense() + } + + private val isFreemiumFlavor: Boolean + get() = FlavorConfig.isFreemiumFlavor + private val licenseContentViewBinder by lazy { LicenseContentViewBinder(binding.licenseContent, isFreemiumFlavor) } + private var listener: Listener? = null + private val debounceHandler = Handler(Looper.getMainLooper()) + private var debounceRunnable: Runnable? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = context as? Listener + } + + override fun setupView() { + setupUi() + } + + override fun onDestroyView() { + debounceRunnable?.let { debounceHandler.removeCallbacks(it) } + super.onDestroyView() + } + + private fun setupUi() { + if (isFreemiumFlavor) { + setupIapUi() + } else { + setupLicenseEntryUi() + } + } + + private fun setupIapUi() { + licenseContentViewBinder.bindInitialIapLayout() + licenseContentViewBinder.bindLegalLinks() + licenseContentViewBinder.bindPurchaseButtons( + activity = requireActivity(), + app = requireActivity().application as CryptomatorApp, + onTrialClicked = { listener?.onStartTrial() } + ) + } + + private fun setupLicenseEntryUi() { + licenseContentViewBinder.bindInitialLicenseEntryLayout() + binding.licenseContent.tvLicenseLink.setOnClickListener { listener?.onOpenLicenseLink() } + binding.licenseContent.btnPurchase.visibility = View.GONE + binding.licenseContent.etLicense.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + debounceRunnable?.let { debounceHandler.removeCallbacks(it) } + val text = s?.toString() + if (!text.isNullOrBlank()) { + val runnable = Runnable { listener?.onLicenseTextChanged(text) } + debounceRunnable = runnable + debounceHandler.postDelayed(runnable, DEBOUNCE_DELAY_MS) + } + } + }) + } + + fun updateUnlocked(unlocked: Boolean, hasPaidLicense: Boolean) { + if (!isAdded) { + return + } + licenseContentViewBinder.bindPurchaseState(unlocked, hasPaidLicense) + } + + fun updateTrialState(active: Boolean, expired: Boolean, expirationText: String?) { + if (!isAdded) { + return + } + licenseContentViewBinder.bindTrialState(active, expired, expirationText) + } + + fun loadAndBindPrices(app: CryptomatorApp) { + if (!isAdded) { + return + } + licenseContentViewBinder.loadAndBindPrices(app) + } + + fun prefillLicense(license: String) { + if (!isAdded) { + return + } + binding.licenseContent.etLicense.setText(license) + binding.licenseContent.licenseEntryGroup.visibility = if (isFreemiumFlavor) View.GONE else View.VISIBLE + } + + companion object { + private const val DEBOUNCE_DELAY_MS = 600L + } +} 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 new file mode 100644 index 000000000..89727e4b9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeNotificationsFragment.kt @@ -0,0 +1,40 @@ +package org.cryptomator.presentation.ui.fragment + +import android.content.Context +import android.view.View +import org.cryptomator.generator.Fragment +import org.cryptomator.presentation.databinding.FragmentWelcomeNotificationsBinding + +@Fragment +class WelcomeNotificationsFragment : BaseFragment(FragmentWelcomeNotificationsBinding::inflate) { + + interface Listener { + fun onRequestNotifications() + } + + private var listener: Listener? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = context as? Listener + } + + override fun setupView() { + setupUi() + } + + private fun setupUi() { + binding.btnNotificationPermission.setOnClickListener { + listener?.onRequestNotifications() + } + } + + fun updatePermissionState(granted: Boolean) { + if (!isAdded) { + return + } + binding.btnNotificationPermission.isEnabled = !granted + binding.btnNotificationPermission.visibility = View.VISIBLE + binding.tvNotificationStatus.visibility = if (granted) View.VISIBLE else View.GONE + } +} 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 new file mode 100644 index 000000000..63e7d8543 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WelcomeScreenLockFragment.kt @@ -0,0 +1,43 @@ +package org.cryptomator.presentation.ui.fragment + +import android.content.Context +import android.view.View +import org.cryptomator.generator.Fragment +import org.cryptomator.presentation.databinding.FragmentWelcomeScreenLockBinding + +@Fragment +class WelcomeScreenLockFragment : BaseFragment(FragmentWelcomeScreenLockBinding::inflate) { + + interface Listener { + fun onSetScreenLock(setScreenLock: Boolean) + } + + private var listener: Listener? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = context as? Listener + } + + override fun setupView() { + setupUi() + } + + private fun setupUi() { + binding.btnSetScreenLock.setOnClickListener { + listener?.onSetScreenLock(binding.cbSetScreenLock.isChecked) + } + } + + fun updateScreenLockState(isSecure: Boolean) { + if (!isAdded) { + return + } + binding.btnSetScreenLock.isEnabled = !isSecure + binding.cbSetScreenLock.isEnabled = !isSecure + if (isSecure) { + binding.cbSetScreenLock.isChecked = false + } + binding.tvScreenLockStatus.visibility = if (isSecure) View.VISIBLE else View.GONE + } +} 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 new file mode 100644 index 000000000..e74f185f7 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/layout/LicenseContentViewBinder.kt @@ -0,0 +1,135 @@ +package org.cryptomator.presentation.ui.layout + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.view.View +import android.widget.Toast +import org.cryptomator.presentation.CryptomatorApp +import org.cryptomator.presentation.R +import org.cryptomator.presentation.databinding.ViewLicenseCheckContentBinding +import org.cryptomator.presentation.service.ProductInfo +import org.cryptomator.presentation.service.resolveProductPrices +import java.lang.ref.WeakReference + +/** Shared visibility-toggling logic for the license check content included layout. */ +class LicenseContentViewBinder( + private val binding: ViewLicenseCheckContentBinding, + private val isFreemiumFlavor: Boolean +) { + + private val context get() = binding.root.context + + /** Sets the initial visibility state and button defaults for IAP mode. */ + fun bindInitialIapLayout() { + binding.licenseEntryGroup.visibility = View.GONE + binding.btnPurchase.visibility = View.GONE + binding.tvLicenseLink.visibility = View.GONE + binding.purchaseOptionsGroup.visibility = View.VISIBLE + binding.tvRestorePurchase.visibility = View.VISIBLE + binding.legalLinksGroup.visibility = View.VISIBLE + binding.btnSubscription.isEnabled = false + binding.btnLifetime.isEnabled = false + } + + /** Sets the initial visibility state for license-entry (non-IAP) mode. */ + fun bindInitialLicenseEntryLayout() { + binding.licenseEntryGroup.visibility = View.VISIBLE + binding.purchaseOptionsGroup.visibility = View.GONE + binding.tvRestorePurchase.visibility = View.GONE + binding.legalLinksGroup.visibility = View.GONE + binding.tvLicenseLink.visibility = View.VISIBLE + binding.tvLicenseLink.text = context.getString(R.string.dialog_enter_license_content) + } + + /** Sets click listeners on Terms and Privacy links. */ + fun bindLegalLinks() { + binding.tvTerms.setOnClickListener { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://cryptomator.org/terms/"))) + } + binding.tvPrivacy.setOnClickListener { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://cryptomator.org/privacy/"))) + } + } + + /** Wires trial, subscription, lifetime, and restore button click listeners. */ + fun bindPurchaseButtons( + activity: Activity, + app: CryptomatorApp, + onTrialClicked: () -> Unit + ) { + binding.btnTrial.setOnClickListener { onTrialClicked() } + binding.btnSubscription.setOnClickListener { + app.launchPurchaseFlow(WeakReference(activity), ProductInfo.PRODUCT_YEARLY_SUBSCRIPTION) + } + binding.btnLifetime.setOnClickListener { + app.launchPurchaseFlow(WeakReference(activity), ProductInfo.PRODUCT_FULL_VERSION) + } + binding.tvRestorePurchase.setOnClickListener { + app.restorePurchases() + Toast.makeText(activity, R.string.screen_license_check_restore_purchase, Toast.LENGTH_SHORT).show() + } + } + + /** Queries product details and updates price buttons on the UI thread. */ + fun loadAndBindPrices(app: CryptomatorApp) { + app.queryProductDetails { products -> + val prices = products.resolveProductPrices() + binding.root.post { + bindProductPrices(prices.subscriptionPrice, prices.lifetimePrice) + } + } + } + + /** Updates subscription and lifetime button text and enabled state from resolved prices. */ + fun bindProductPrices(subscriptionPrice: String?, lifetimePrice: String?) { + if (!subscriptionPrice.isNullOrEmpty()) { + binding.btnSubscription.text = subscriptionPrice + binding.btnSubscription.isEnabled = true + } + if (!lifetimePrice.isNullOrEmpty()) { + binding.btnLifetime.text = lifetimePrice + binding.btnLifetime.isEnabled = true + } + } + + /** Updates purchase-related view visibility based on license state. */ + fun bindPurchaseState(unlocked: Boolean, hasPaidLicense: Boolean) { + if (isFreemiumFlavor) { + binding.purchaseOptionsGroup.visibility = if (hasPaidLicense) View.GONE else View.VISIBLE + binding.tvRestorePurchase.visibility = if (hasPaidLicense) View.GONE else View.VISIBLE + if (hasPaidLicense) { + binding.tvInfoText.visibility = View.GONE + binding.tvTrialStatusBadge.visibility = View.GONE + binding.tvTrialExpiration.visibility = View.GONE + } + } else { + binding.btnPurchase.isEnabled = !unlocked + } + } + + /** Updates trial-related view visibility based on trial state. */ + fun bindTrialState(active: Boolean, expired: Boolean, expirationText: String?) { + if (active || expired) { + binding.trialButtonGroup.visibility = View.GONE + binding.tvTrialStatusBadge.visibility = View.VISIBLE + binding.tvTrialStatusBadge.text = context.getString( + if (active) R.string.screen_license_check_trial_status_active + else R.string.screen_license_check_trial_status_expired + ) + binding.tvTrialExpiration.visibility = View.VISIBLE + binding.tvTrialExpiration.text = expirationText + if (expired) { + binding.tvInfoText.visibility = View.VISIBLE + binding.tvInfoText.text = context.getString(R.string.screen_license_check_trial_expired_info) + } else { + binding.tvInfoText.visibility = View.GONE + } + } else { + binding.trialButtonGroup.visibility = View.VISIBLE + binding.tvTrialStatusBadge.visibility = View.GONE + binding.tvTrialExpiration.visibility = View.GONE + binding.btnTrial.isEnabled = true + } + } +} diff --git a/presentation/src/main/res/drawable-night/ic_checkmark.xml b/presentation/src/main/res/drawable-night/ic_checkmark.xml new file mode 100644 index 000000000..faa52ddd2 --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_checkmark.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/presentation/src/main/res/drawable-night/ic_cryptomator_bot.xml b/presentation/src/main/res/drawable-night/ic_cryptomator_bot.xml new file mode 100644 index 000000000..01c494f5a --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_cryptomator_bot.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/drawable-night/ic_info_circle.xml b/presentation/src/main/res/drawable-night/ic_info_circle.xml new file mode 100644 index 000000000..5b1c088ea --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_info_circle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/presentation/src/main/res/drawable/ic_checkmark.xml b/presentation/src/main/res/drawable/ic_checkmark.xml new file mode 100644 index 000000000..2d224b8ff --- /dev/null +++ b/presentation/src/main/res/drawable/ic_checkmark.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/presentation/src/main/res/drawable/ic_cryptomator_bot.xml b/presentation/src/main/res/drawable/ic_cryptomator_bot.xml new file mode 100644 index 000000000..910d6a837 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_cryptomator_bot.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_info_circle.xml b/presentation/src/main/res/drawable/ic_info_circle.xml new file mode 100644 index 000000000..a3425f1f1 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_info_circle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/presentation/src/main/res/layout/activity_license_check.xml b/presentation/src/main/res/layout/activity_license_check.xml new file mode 100644 index 000000000..2e53b4a83 --- /dev/null +++ b/presentation/src/main/res/layout/activity_license_check.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/activity_welcome.xml b/presentation/src/main/res/layout/activity_welcome.xml new file mode 100644 index 000000000..8b951aa07 --- /dev/null +++ b/presentation/src/main/res/layout/activity_welcome.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + +