diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b96c1f53..e3e3af3f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,5 @@ default: - image: ${CI_REGISTRY}/android/shared/docker-android:v1.0.0 + image: ${CI_REGISTRY}/android/shared/docker-android:v1.0.3 variables: # Use fastzip to improve cache times @@ -111,25 +111,25 @@ deploy:review: build dev debug: extends: [.build] needs: - - job: "detekt analysis" - job: "prepare-environment" + - job: "prepare-build" script: - ./gradlew assembleDevDebug artifacts: paths: - ./app/**/*.apk - rules: - - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH - - if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_REF_NAME =~ /^test/ - when: manual - allow_failure: true build dynamic debug: extends: [.build] + needs: + - job: "prepare-environment" + - job: "prepare-build" script: - export $(cat deploy.env) - echo HOST="$DYNAMIC_DOMAIN" >> private.properties - - ./gradlew assembleDynamicDebug assembleDynamicDebugAndroidTest assembleDebugAndroidTest + - ./gradlew assembleDynamicDebug --max-workers=4 + - ./gradlew assembleDynamicDebugAndroidTest --max-workers=4 + - ./gradlew assembleDebugAndroidTest --max-workers=4 rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_REF_NAME =~ /^test/ - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH @@ -253,6 +253,7 @@ upload to firebase: --platform android --job-name $CI_JOB_NAME --slack-channel drive-android-ci-reports + --push-metrics rules: # allow failure so non-run tests don't block pipeline - allow_failure: true @@ -380,13 +381,11 @@ publish to firebase app distribution: - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH startReview: - rules: - - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH - variables: - PRODUCT_FLAVOR: "dev" - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - variables: - PRODUCT_FLAVOR: "dynamic" + needs: + - job: "prepare-build" + - job: "build dev debug" + variables: + PRODUCT_FLAVOR: "dev" before_script: - if [[ -f /load-env.sh ]]; then source /load-env.sh; fi - export REVIEW_APP_ARTIFACT_PATH="app/build/outputs/apk/$PRODUCT_FLAVOR/debug/"${ARCHIVES_BASE_NAME}-${PRODUCT_FLAVOR}-debug.apk diff --git a/.idea/scopes/proton_core.xml b/.idea/scopes/proton_core.xml index f78e65cf..6b9c9132 100644 --- a/.idea/scopes/proton_core.xml +++ b/.idea/scopes/proton_core.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/app-lock/build.gradle.kts b/app-lock/build.gradle.kts new file mode 100644 index 00000000..5f3ee6cd --- /dev/null +++ b/app-lock/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +plugins { + id("com.android.library") +} + +driveModule(includeSubmodules = true) diff --git a/app-lock/data/build.gradle.kts b/app-lock/data/build.gradle.kts new file mode 100644 index 00000000..529fc862 --- /dev/null +++ b/app-lock/data/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +plugins { + id("com.android.library") +} + +driveModule( + hilt = true, + room = true, + serialization = true, + workManager = true, +) { + api(project(":app-lock:domain")) + api(project(":drive:base")) + api(libs.androidx.biometric) +} diff --git a/app-lock/data/src/main/AndroidManifest.xml b/app-lock/data/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5acdff14 --- /dev/null +++ b/app-lock/data/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/KeyStore.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/KeyStore.kt new file mode 100644 index 00000000..886cc50d --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/KeyStore.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.crypto + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import me.proton.core.drive.base.domain.log.LogTag +import me.proton.core.util.kotlin.CoreLogger +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +object Config { + const val KEY_STORE_TYPE = "AndroidKeyStore" + const val DEFAULT_CIPHER_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + const val DEFAULT_CIPHER_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + const val DEFAULT_CIPHER_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE + const val DEFAULT_CIPHER_GCM_TAG_LENGTH = 128 + const val DEFAULT_CIPHER_IV_BYTES = 12 + const val DEFAULT_KEY_SIZE = 256 + const val DEFAULT_USER_AUTHENTICATION_REQUIRED = true +} + +data class SecretKeyProperties( + val keyAlias: String, + val keyStoreType: String = Config.KEY_STORE_TYPE, + val cipherAlgorithm: String = Config.DEFAULT_CIPHER_ALGORITHM, + val cipherBlockMode: String = Config.DEFAULT_CIPHER_BLOCK_MODE, + val cipherPadding: String = Config.DEFAULT_CIPHER_PADDING, + val cipherKeySize: Int = Config.DEFAULT_KEY_SIZE, + val userAuthenticationRequired: Boolean = Config.DEFAULT_USER_AUTHENTICATION_REQUIRED, +) + +val SecretKeyProperties.transformation: String get() = "$cipherAlgorithm/$cipherBlockMode/$cipherPadding" + +fun SecretKeyProperties.getOrCreateSecretKey( + invalidateKeyByBiometricEnrollment: Boolean = true, +): SecretKey { + val keyStore = KeyStore.getInstance(keyStoreType) + keyStore.load(null) + return if (keyStore.containsAlias(keyAlias)) { + keyStore.getKey(keyAlias, null) as SecretKey + } else { + KeyGenerator.getInstance( + cipherAlgorithm, + keyStoreType, + ).let { keyGenerator -> + keyGenerator.init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(cipherBlockMode) + .setEncryptionPaddings(cipherPadding) + .setKeySize(cipherKeySize) + .defaultKeyGenParameterSpecBuilder( + userAuthenticationRequired = userAuthenticationRequired, + invalidateKeyByBiometricEnrollment = invalidateKeyByBiometricEnrollment, + ) + .build() + ) + keyGenerator.generateKey() + } + } +} + +fun KeyGenParameterSpec.Builder.defaultKeyGenParameterSpecBuilder( + userAuthenticationRequired: Boolean, + invalidateKeyByBiometricEnrollment: Boolean, +): KeyGenParameterSpec.Builder { + setUserAuthenticationRequired(userAuthenticationRequired) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters( + 0, + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL, + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + setInvalidatedByBiometricEnrollment(invalidateKeyByBiometricEnrollment) + } + return this +} + +fun SecretKeyProperties.getInitializedCipherForDecryption( + initializationVector: ByteArray? = null, + invalidateKeyByBiometricEnrollment: Boolean = true, + cipherGcmTagLength: Int = Config.DEFAULT_CIPHER_GCM_TAG_LENGTH, +): Cipher = getCipher(transformation).apply { + init( + Cipher.DECRYPT_MODE, + getOrCreateSecretKey( + invalidateKeyByBiometricEnrollment = invalidateKeyByBiometricEnrollment, + ), + GCMParameterSpec(cipherGcmTagLength, initializationVector), + ) +} + +fun SecretKeyProperties.getInitializedCipherForEncryption( + invalidateKeyByBiometricEnrollment: Boolean = true, +): Cipher = getCipher(transformation).apply { + init( + Cipher.ENCRYPT_MODE, + getOrCreateSecretKey( + invalidateKeyByBiometricEnrollment = invalidateKeyByBiometricEnrollment, + ), + ) +} + +private fun getCipher(transformation: String): Cipher = Cipher.getInstance(transformation) + +fun removeKey(keyProperties: SecretKeyProperties) { + try { + KeyStore.getInstance(keyProperties.keyStoreType).run { + load(null) + deleteEntry(keyProperties.keyAlias) + } + } catch (e: Exception) { + CoreLogger.d(LogTag.DEFAULT, e, e.message.orEmpty()) + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/KeyStoreSecretKey.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/KeyStoreSecretKey.kt new file mode 100644 index 00000000..21765617 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/KeyStoreSecretKey.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.crypto + +import android.util.Base64 +import me.proton.android.drive.lock.domain.entity.SecretKey +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.EncryptedString +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.crypto.common.keystore.use + +class KeyStoreSecretKey( + private val keyProperties: SecretKeyProperties, + private val invalidateKeyByBiometricEnrollment: Boolean = true, +) : SecretKey { + + override fun encrypt(value: String): EncryptedString = + value.encodeToByteArray().use { plainByteArray -> + Base64.encodeToString( + encrypt(plainByteArray).array, + Base64.NO_WRAP, + ) + } + + override fun encrypt(value: PlainByteArray): EncryptedByteArray { + val cipher = keyProperties.getInitializedCipherForEncryption(invalidateKeyByBiometricEnrollment) + val cipherByteArray = cipher.doFinal(value.array) + return EncryptedByteArray(cipher.iv + cipherByteArray) + } + + override fun decrypt(value: EncryptedString): String { + val encryptedByteArray = Base64.decode(value, Base64.NO_WRAP) + return decrypt(EncryptedByteArray(encryptedByteArray)).use { plainByteArray -> + plainByteArray.array.decodeToString() + } + } + + override fun decrypt(value: EncryptedByteArray): PlainByteArray { + val initializationVector = value.array.copyOf(Config.DEFAULT_CIPHER_IV_BYTES) + val cipher = keyProperties.getInitializedCipherForDecryption( + initializationVector = initializationVector, + invalidateKeyByBiometricEnrollment = invalidateKeyByBiometricEnrollment, + ) + val cipherByteArray = value.array.copyOfRange(Config.DEFAULT_CIPHER_IV_BYTES, value.array.size) + return PlainByteArray(cipher.doFinal(cipherByteArray)) + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/PgpSecretKey.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/PgpSecretKey.kt new file mode 100644 index 00000000..ef95b32a --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/crypto/PgpSecretKey.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.crypto + +import me.proton.android.drive.lock.domain.entity.SecretKey +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.EncryptedString +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.crypto.common.pgp.Armored +import me.proton.core.crypto.common.pgp.Unarmored + +class PgpSecretKey( + passphrase: PlainByteArray, + val lockedKey: Armored, + cryptoContext: CryptoContext, +) : SecretKey { + private val pgpCrypto = cryptoContext.pgpCrypto + private val unlockedKey: Unarmored + private val publicKey: Armored + + init { + passphrase.use { + unlockedKey = pgpCrypto.unlock(lockedKey, passphrase.array).value + publicKey = pgpCrypto.getPublicKey(lockedKey) + } + } + + override fun encrypt(value: String): EncryptedString = pgpCrypto.encryptText(value, publicKey) + + override fun decrypt(value: EncryptedString): String = pgpCrypto.decryptText(value, unlockedKey) + + override fun encrypt(value: PlainByteArray): EncryptedByteArray = + EncryptedByteArray(pgpCrypto.encryptData(value.array, publicKey).toByteArray()) + + override fun decrypt(value: EncryptedByteArray): PlainByteArray = + PlainByteArray(pgpCrypto.decryptData(pgpCrypto.getArmored(value.array), unlockedKey)) +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/AppLockDatabase.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/AppLockDatabase.kt new file mode 100644 index 00000000..cc781c47 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/AppLockDatabase.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.db + +import me.proton.android.drive.lock.data.db.dao.AppLockDao +import me.proton.android.drive.lock.data.db.dao.AutoLockDurationDao +import me.proton.android.drive.lock.data.db.dao.EnableAppLockDao +import me.proton.android.drive.lock.data.db.dao.LockDao +import me.proton.core.data.room.db.Database + +interface AppLockDatabase : Database { + val appLockDao: AppLockDao + val lockDao: LockDao + val autoLockDurationDao: AutoLockDurationDao + val enableAppLockDao: EnableAppLockDao +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/AppLockDao.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/AppLockDao.kt new file mode 100644 index 00000000..75ed3918 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/AppLockDao.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import me.proton.android.drive.lock.data.db.entity.AppLockEntity +import me.proton.core.data.room.db.BaseDao + +@Dao +abstract class AppLockDao : BaseDao() { + @Query(""" + SELECT EXISTS(SELECT * FROM AppLockEntity) + """) + abstract suspend fun hasAppLock(): Boolean + + @Query(""" + SELECT EXISTS(SELECT * FROM AppLockEntity) + """) + abstract fun hasAppLockFlow(): Flow + + @Query(""" + SELECT * FROM AppLockEntity LIMIT 1 + """) + abstract suspend fun getAppLock(): AppLockEntity + + @Query(""" + DELETE FROM AppLockEntity + """) + abstract suspend fun deleteAppLock() +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/AutoLockDurationDao.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/AutoLockDurationDao.kt new file mode 100644 index 00000000..f3118a68 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/AutoLockDurationDao.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import me.proton.android.drive.lock.data.db.entity.AutoLockDurationEntity +import me.proton.core.data.room.db.BaseDao + +@Dao +abstract class AutoLockDurationDao : BaseDao() { + @Query(""" + SELECT EXISTS(SELECT * FROM AutoLockDurationEntity WHERE `key` = :key) + """) + abstract suspend fun hasAutoLockDuration(key: String): Boolean + + @Query(""" + SELECT * FROM AutoLockDurationEntity WHERE `key` = :key + """) + abstract fun getAutoLockDuration(key: String): Flow +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/EnableAppLockDao.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/EnableAppLockDao.kt new file mode 100644 index 00000000..6fcb8ac5 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/EnableAppLockDao.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import me.proton.android.drive.lock.data.db.entity.EnableAppLockEntity +import me.proton.core.data.room.db.BaseDao + +@Dao +abstract class EnableAppLockDao : BaseDao() { + @Query( + """ + SELECT EXISTS(SELECT * FROM EnableAppLockEntity WHERE `key` = :key) + """ + ) + abstract suspend fun hasEnableAppLock(key: String): Boolean +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/LockDao.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/LockDao.kt new file mode 100644 index 00000000..b9512544 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/dao/LockDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import me.proton.android.drive.lock.data.db.entity.LockEntity +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.core.data.room.db.BaseDao + +@Dao +abstract class LockDao : BaseDao() { + @Query(""" + SELECT EXISTS(SELECT * FROM LockEntity WHERE type = :type) + """) + abstract suspend fun hasLock(type: AppLockType): Boolean + + @Query(""" + SELECT * FROM LockEntity WHERE type = :type LIMIT 1 + """) + abstract suspend fun getLock(type: AppLockType): LockEntity + + @Query(""" + DELETE FROM LockEntity WHERE type = :type + """) + abstract suspend fun deleteLock(type: AppLockType) +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/AppLockEntity.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/AppLockEntity.kt new file mode 100644 index 00000000..6db81871 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/AppLockEntity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.core.drive.base.data.db.Column.KEY +import me.proton.core.drive.base.data.db.Column.TYPE + +@Entity( + primaryKeys = [KEY], +) +data class AppLockEntity( + @ColumnInfo(name = KEY) + val key: String, + @ColumnInfo(name = TYPE) + val type: AppLockType, +) diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/AutoLockDurationEntity.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/AutoLockDurationEntity.kt new file mode 100644 index 00000000..a88d6c3b --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/AutoLockDurationEntity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import me.proton.core.drive.base.data.db.Column.DURATION +import me.proton.core.drive.base.data.db.Column.KEY + +@Entity( + primaryKeys = [KEY], +) +data class AutoLockDurationEntity( + @ColumnInfo(name = KEY) + val key: String, + @ColumnInfo(name = DURATION) + val durationInSeconds: Long, +) diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/EnableAppLockEntity.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/EnableAppLockEntity.kt new file mode 100644 index 00000000..05512d10 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/EnableAppLockEntity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import me.proton.core.drive.base.data.db.Column.KEY +import me.proton.core.drive.base.data.db.Column.LAST_ACCESS_TIME + +@Entity( + primaryKeys = [KEY], +) +data class EnableAppLockEntity( + @ColumnInfo(name = KEY) + val key: String, + @ColumnInfo(name = LAST_ACCESS_TIME) + val timestamp: Long, +) diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/LockEntity.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/LockEntity.kt new file mode 100644 index 00000000..d148e91f --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/db/entity/LockEntity.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.core.drive.base.data.db.Column.KEY +import me.proton.core.drive.base.data.db.Column.PASSPHRASE +import me.proton.core.drive.base.data.db.Column.TYPE + +@Entity( + primaryKeys = [PASSPHRASE], + foreignKeys = [ + ForeignKey( + entity = AppLockEntity::class, + parentColumns = [KEY], + childColumns = [KEY], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = [KEY]), + ], +) +data class LockEntity( + @ColumnInfo(name = PASSPHRASE) + val appKeyPassphrase: String, + @ColumnInfo(name = KEY) + val appKey: String, + @ColumnInfo(name = TYPE) + val type: AppLockType, +) diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/di/AppLockBindModule.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/di/AppLockBindModule.kt new file mode 100644 index 00000000..8a89a3d5 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/di/AppLockBindModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.android.drive.lock.data.usecase.BuildAppKeyImpl +import me.proton.android.drive.lock.data.usecase.GeneratePgpSecretKey +import me.proton.android.drive.lock.data.usecase.GetAppLockImpl +import me.proton.android.drive.lock.domain.usecase.BuildAppKey +import me.proton.android.drive.lock.domain.usecase.GenerateSecretKey +import me.proton.android.drive.lock.domain.usecase.GetAppLock +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +interface AppLockBindModule { + @Binds + @Singleton + fun bindsGetAppLockImpl(impl: GetAppLockImpl): GetAppLock + + @Binds + @Singleton + fun bindsGeneratePgpSecretKey(impl: GeneratePgpSecretKey): GenerateSecretKey + + @Binds + @Singleton + fun bindsBuildAppKeyImpl(impl: BuildAppKeyImpl): BuildAppKey +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/di/AppLockModule.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/di/AppLockModule.kt new file mode 100644 index 00000000..443ff579 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/di/AppLockModule.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2021 Proton Technologies AG + * This file is part of Proton Technologies AG and ProtonCore. + * + * ProtonCore is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonCore is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonCore. If not, see . + */ +package me.proton.android.drive.lock.data.di + +import android.content.Context +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.work.WorkManager +import dagger.MapKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import me.proton.android.drive.lock.data.db.AppLockDatabase +import me.proton.android.drive.lock.data.lock.CryptoSystemLock +import me.proton.android.drive.lock.data.lock.SystemLock +import me.proton.android.drive.lock.data.manager.AppLockManagerImpl +import me.proton.android.drive.lock.data.manager.AutoLockManagerImpl +import me.proton.android.drive.lock.data.provider.BiometricPromptProvider +import me.proton.android.drive.lock.data.provider.BiometricPromptProviderImpl +import me.proton.android.drive.lock.data.repository.AppLockRepositoryImpl +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.lock.Lock +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.android.drive.lock.domain.manager.AutoLockManager +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import me.proton.android.drive.lock.domain.usecase.GetAutoLockDuration +import me.proton.android.drive.lock.domain.usecase.LockApp +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppLockModule { + @Provides + @Singleton + fun provideBiometricManager(@ApplicationContext context: Context): BiometricManager = + BiometricManager.from(context) + + @Provides + @Singleton + fun provideBiometricPromptProvider(biometricManager: BiometricManager): BiometricPromptProvider = + BiometricPromptProviderImpl(biometricManager) + + @Singleton + @Provides + fun provideAppLockKeyRepository( + appLockDatabase: AppLockDatabase, + ): AppLockRepository = + AppLockRepositoryImpl(appLockDatabase) + + @Singleton + @Provides + fun provideAppLockManager( + appLockRepository: AppLockRepository, + ): AppLockManager = + AppLockManagerImpl(appLockRepository, Dispatchers.Main + Job()) + + @Singleton + @Provides + fun provideAutoLockManager( + workManager: WorkManager, + lockApp: LockApp, + getAutoLockDuration: GetAutoLockDuration, + ): AutoLockManager = + AutoLockManagerImpl(workManager, lockApp, getAutoLockDuration) + + @MapKey + annotation class AppLockTypeKey(val value: AppLockType) + + @Singleton + @Provides @IntoMap + @AppLockTypeKey(AppLockType.SYSTEM) + fun provideSystemLock( + @ApplicationContext appContext: Context, + appLockRepository: AppLockRepository, + biometricPromptProvider: BiometricPromptProvider, + biometricManager: BiometricManager, + ): Lock = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && biometricManager.hasBiometricHardware) { + CryptoSystemLock( + appContext = appContext, + appLockRepository = appLockRepository, + biometricPromptProvider = biometricPromptProvider, + ) + } else { + SystemLock( + appContext = appContext, + appLockRepository = appLockRepository, + biometricPromptProvider = biometricPromptProvider, + ) + } + + private val BiometricManager.hasBiometricHardware: Boolean get() = + canAuthenticate(BIOMETRIC_WEAK) != BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/AppLock.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/AppLock.kt new file mode 100644 index 00000000..f0d71385 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/AppLock.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.extension + +import me.proton.android.drive.lock.data.db.entity.AppLockEntity +import me.proton.android.drive.lock.domain.entity.AppLock + +fun AppLock.toAppLockEntity(): AppLockEntity = AppLockEntity( + key = this.key, + type = this.type, +) diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/AppLockEntity.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/AppLockEntity.kt new file mode 100644 index 00000000..aaf9696a --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/AppLockEntity.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.extension + +import me.proton.android.drive.lock.data.db.entity.AppLockEntity +import me.proton.android.drive.lock.domain.entity.AppLock + +fun AppLockEntity.toAppLock() = AppLock( + key = this.key, + type = this.type, +) diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockEntity.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockEntity.kt new file mode 100644 index 00000000..094eb3b3 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockEntity.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.extension + +import android.util.Base64 +import me.proton.android.drive.lock.data.db.entity.LockEntity +import me.proton.android.drive.lock.domain.entity.LockKey + +fun LockEntity.toLock(): LockKey = LockKey( + appKeyPassphrase = Base64.decode(this.appKeyPassphrase, Base64.NO_WRAP), + appKey = this.appKey, + type = this.type, +) diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockKey.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockKey.kt new file mode 100644 index 00000000..6721681b --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockKey.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.extension + +import android.util.Base64 +import me.proton.android.drive.lock.data.db.entity.LockEntity +import me.proton.android.drive.lock.domain.entity.LockKey + +fun LockKey.toLockEntity(): LockEntity = LockEntity( + appKeyPassphrase = Base64.encodeToString(this.appKeyPassphrase, Base64.NO_WRAP), + appKey = this.appKey, + type = this.type, +) diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockState.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockState.kt new file mode 100644 index 00000000..025af919 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/extension/LockState.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.data.extension + +import me.proton.android.drive.lock.domain.lock.LockState + + +fun LockState.onNotAvailable(action: () -> Unit): LockState = this.also { + if (this is LockState.NotAvailable) action() +} + +fun LockState.onSetupRequired(action: () -> Unit): LockState = this.also { + if (this is LockState.SetupRequired) action() +} + +fun LockState.onReady(action: () -> Unit): LockState = this.also { + if (this is LockState.Ready) action() +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/lock/CryptoSystemLock.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/lock/CryptoSystemLock.kt new file mode 100644 index 00000000..699f685c --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/lock/CryptoSystemLock.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.lock + +import android.content.Context +import androidx.biometric.BiometricPrompt +import dagger.hilt.android.qualifiers.ApplicationContext +import me.proton.android.drive.lock.data.crypto.Config +import me.proton.android.drive.lock.data.crypto.SecretKeyProperties +import me.proton.android.drive.lock.data.crypto.getInitializedCipherForDecryption +import me.proton.android.drive.lock.data.crypto.getInitializedCipherForEncryption +import me.proton.android.drive.lock.data.crypto.removeKey +import me.proton.android.drive.lock.data.provider.BiometricPromptProvider +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.entity.LockKey +import me.proton.android.drive.lock.domain.lock.Lock +import me.proton.android.drive.lock.domain.lock.LockState +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import me.proton.core.drive.base.domain.util.coRunCatching +import javax.inject.Inject +import me.proton.core.drive.base.presentation.R as BasePresentation + +class CryptoSystemLock @Inject constructor( + @ApplicationContext private val appContext: Context, + private val appLockRepository: AppLockRepository, + private val biometricPromptProvider: BiometricPromptProvider, +) : Lock { + private val keyProperties = SecretKeyProperties(keyAlias = SYSTEM_KEY_ALIAS) + + override suspend fun unlock( + key: String, + block: suspend (passphrase: ByteArray) -> T, + ): Result = coRunCatching { + val systemLockKey = appLockRepository.getLockKey(AppLockType.SYSTEM) + require(systemLockKey.appKey == key) + val initializationVector = systemLockKey.appKeyPassphrase.copyOf(Config.DEFAULT_CIPHER_IV_BYTES) + val cipher = biometricPromptProvider.authenticate( + title = appContext.getString(BasePresentation.string.app_lock_biometric_title_app_locked), + subtitle = appContext.getString(BasePresentation.string.app_lock_biometric_subtitle_app_locked), + cryptoObject = BiometricPrompt.CryptoObject( + keyProperties.getInitializedCipherForDecryption( + initializationVector = initializationVector, + ) + ) + ).getOrThrow().cryptoObject?.cipher + block( + requireNotNull(cipher).doFinal( + systemLockKey.appKeyPassphrase.copyOfRange( + Config.DEFAULT_CIPHER_IV_BYTES, + systemLockKey.appKeyPassphrase.size, + ) + ) + ) + } + + override suspend fun lock(passphrase: ByteArray): Result = coRunCatching { + val cipher = requireNotNull( + biometricPromptProvider.authenticate( + title = appContext.getString(BasePresentation.string.app_lock_biometric_title_confirmation), + subtitle = appContext.getString( + BasePresentation.string.app_lock_biometric_subtitle_confirmation_enable + ), + cryptoObject = BiometricPrompt.CryptoObject( + keyProperties.getInitializedCipherForEncryption() + ) + ).getOrThrow().cryptoObject?.cipher + ) + cipher.iv + cipher.doFinal(passphrase) + } + + override fun getLockState(): LockState = biometricPromptProvider.getLockState() + + override suspend fun disable(userAuthenticationRequired: Boolean) { + if (userAuthenticationRequired) { + biometricPromptProvider.authenticate( + title = appContext.getString(BasePresentation.string.app_lock_biometric_title_confirmation), + subtitle = appContext.getString( + BasePresentation.string.app_lock_biometric_subtitle_confirmation_disable + ), + cryptoObject = null, + ).getOrThrow() + } + appLockRepository.deleteLockKey(AppLockType.SYSTEM) + removeKey(keyProperties) + } + + override suspend fun enable(lockKey: LockKey) { + appLockRepository.insertLockKey(lockKey) + } + + companion object { + private const val SYSTEM_KEY_ALIAS = "system_lock_key" + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/lock/SystemLock.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/lock/SystemLock.kt new file mode 100644 index 00000000..4371cc1f --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/lock/SystemLock.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.lock + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import me.proton.android.drive.lock.data.crypto.KeyStoreSecretKey +import me.proton.android.drive.lock.data.crypto.SecretKeyProperties +import me.proton.android.drive.lock.data.crypto.removeKey +import me.proton.android.drive.lock.data.provider.BiometricPromptProvider +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.entity.LockKey +import me.proton.android.drive.lock.domain.entity.SecretKey +import me.proton.android.drive.lock.domain.lock.Lock +import me.proton.android.drive.lock.domain.lock.LockState +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.drive.base.domain.util.coRunCatching +import javax.inject.Inject +import me.proton.core.drive.base.presentation.R as BasePresentation + +class SystemLock @Inject constructor( + @ApplicationContext private val appContext: Context, + private val appLockRepository: AppLockRepository, + private val biometricPromptProvider: BiometricPromptProvider, +) : Lock { + private val keyProperties = SecretKeyProperties( + keyAlias = SYSTEM_KEY_ALIAS, + userAuthenticationRequired = false, + ) + private val secretKey: SecretKey = KeyStoreSecretKey(keyProperties) + + override suspend fun unlock( + key: String, + block: suspend (passphrase: ByteArray) -> T, + ): Result = coRunCatching { + val lockKey = appLockRepository.getLockKey(AppLockType.SYSTEM) + require(lockKey.appKey == key) + biometricPromptProvider.authenticate( + title = appContext.getString(BasePresentation.string.app_lock_biometric_title_app_locked), + subtitle = appContext.getString(BasePresentation.string.app_lock_biometric_subtitle_app_locked), + cryptoObject = null, + ).getOrThrow() + block(secretKey.decrypt(EncryptedByteArray(lockKey.appKeyPassphrase)).array) + } + + override suspend fun lock(passphrase: ByteArray): Result = coRunCatching { + biometricPromptProvider.authenticate( + title = appContext.getString(BasePresentation.string.app_lock_biometric_title_confirmation), + subtitle = appContext.getString(BasePresentation.string.app_lock_biometric_subtitle_confirmation_enable), + cryptoObject = null, + ).getOrThrow() + secretKey.encrypt(PlainByteArray(passphrase)).array + } + + override suspend fun disable(userAuthenticationRequired: Boolean) { + if (userAuthenticationRequired) { + biometricPromptProvider.authenticate( + title = appContext.getString(BasePresentation.string.app_lock_biometric_title_confirmation), + subtitle = appContext.getString( + BasePresentation.string.app_lock_biometric_subtitle_confirmation_disable + ), + cryptoObject = null, + ).getOrThrow() + } + appLockRepository.deleteLockKey(AppLockType.SYSTEM) + removeKey(keyProperties) + } + + override suspend fun enable(lockKey: LockKey) { + appLockRepository.insertLockKey(lockKey) + } + + override fun getLockState(): LockState = biometricPromptProvider.getLockState() + + companion object { + private const val SYSTEM_KEY_ALIAS = "system_lock_key" + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/manager/AppLockManagerImpl.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/manager/AppLockManagerImpl.kt new file mode 100644 index 00000000..4dcc43f1 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/manager/AppLockManagerImpl.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.manager + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import me.proton.android.drive.lock.domain.entity.AppLock +import me.proton.android.drive.lock.domain.entity.SecretKey +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import me.proton.core.drive.base.domain.util.coRunCatching +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext + +class AppLockManagerImpl @Inject constructor( + private val appLockRepository: AppLockRepository, + coroutineContext: CoroutineContext, +) : AppLockManager { + private val coroutineScope = CoroutineScope(coroutineContext) + override val enabled: StateFlow = appLockRepository.hasAppLockKeyFlow() + .stateIn(coroutineScope, SharingStarted.Eagerly, false) + private val appKey = MutableStateFlow(null) + private val _locked: Flow = appKey.map { secretKey -> secretKey == null } + override val locked: StateFlow = combine(enabled, _locked) { + _, isLocked -> + appLockRepository.hasAppLockKey() && isLocked + }.distinctUntilChanged().stateIn(coroutineScope, SharingStarted.Eagerly, false) + + override suspend fun isLocked(): Boolean = isEnabled() && appKey.value == null + + override suspend fun isEnabled(): Boolean = appLockRepository.hasAppLockKey() + + override suspend fun unlock(appKey: SecretKey) { + this.appKey.value = appKey + } + + override suspend fun lock() { + appKey.value = null + } + + override suspend fun enable( + secretKey: SecretKey, + appLock: AppLock, + ): Result = coRunCatching { + unlock(secretKey) + appLockRepository.insertAppLockKey(appLock) + appLockRepository.insertOrUpdateEnableAppLockTimestamp(System.currentTimeMillis()) + true + } + + override suspend fun disable(): Result = coRunCatching { + appLockRepository.deleteAppLockKey() + lock() + true + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/manager/AutoLockManagerImpl.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/manager/AutoLockManagerImpl.kt new file mode 100644 index 00000000..96f079a4 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/manager/AutoLockManagerImpl.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.data.manager + +import android.os.Build +import androidx.work.WorkManager +import kotlinx.coroutines.flow.first +import me.proton.android.drive.lock.data.worker.AppLockWorker +import me.proton.android.drive.lock.domain.manager.AutoLockManager +import me.proton.android.drive.lock.domain.usecase.GetAutoLockDuration +import me.proton.android.drive.lock.domain.usecase.LockApp +import javax.inject.Inject + +class AutoLockManagerImpl @Inject constructor( + private val workManager: WorkManager, + private val lockApp: LockApp, + private val getAutoLockDuration: GetAutoLockDuration, +) : AutoLockManager { + + override suspend fun autoLock() { + val lockAfter = getAutoLockDuration().first() + if (lockAfter.inWholeSeconds == 0L || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + lockApp() + } else { + workManager.enqueue( + AppLockWorker.getWorkRequest(lockAfter, listOf(TAG)) + ) + } + } + + override fun cancelAutoLock() { + workManager.cancelAllWorkByTag(TAG) + } + + companion object { + private const val TAG = "auto-lock" + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/provider/BiometricPromptProvider.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/provider/BiometricPromptProvider.kt new file mode 100644 index 00000000..7cb9f5a7 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/provider/BiometricPromptProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.provider + +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.CryptoObject +import androidx.fragment.app.FragmentActivity +import me.proton.android.drive.lock.domain.lock.LockState + +interface BiometricPromptProvider { + fun bindToActivity(activity: FragmentActivity) + suspend fun authenticate( + title: String, + subtitle: String, + cryptoObject: CryptoObject?, + ): Result + fun getLockState(): LockState +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/provider/BiometricPromptProviderImpl.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/provider/BiometricPromptProviderImpl.kt new file mode 100644 index 00000000..f4b33514 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/provider/BiometricPromptProviderImpl.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.provider + +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED +import androidx.biometric.BiometricManager.BIOMETRIC_STATUS_UNKNOWN +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.CryptoObject +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import me.proton.android.drive.lock.domain.exception.LockException +import me.proton.android.drive.lock.domain.lock.LockState +import me.proton.core.drive.base.domain.util.coRunCatching +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +class BiometricPromptProviderImpl @Inject constructor( + private val biometricManager: BiometricManager, +) : BiometricPromptProvider { + private val listeners = ConcurrentHashMap() + private var activity: WeakReference = WeakReference(null) + private val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + listeners.keys().toList().forEach { listener -> + listener.onError(LockException.BiometricAuthenticationError(errString.toString())) + } + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + listeners.keys().toList().forEach { listener -> + listener.onError(LockException.BiometricAuthenticationFailed) + } + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + listeners.keys().toList().forEach { listener -> listener.onSuccess(result) } + } + } + + interface AuthenticationListener { + fun onSuccess(result: BiometricPrompt.AuthenticationResult) + fun onError(error: LockException) + } + + override fun bindToActivity(activity: FragmentActivity) { + this.activity = WeakReference(activity) + } + + private fun buildBiometricPrompt(): BiometricPrompt { + val activity = this.activity.get() + requireNotNull(activity) + val executor = ContextCompat.getMainExecutor(activity) + return BiometricPrompt( + activity, + executor, + callback, + ) + } + + override suspend fun authenticate( + title: String, + subtitle: String, + cryptoObject: CryptoObject? + ): Result = coRunCatching { + cryptoObject?.let { + buildBiometricPrompt().authenticate(biometricPromptInfo(title, subtitle), cryptoObject) + } ?: buildBiometricPrompt().authenticate(biometricPromptInfo(title, subtitle)) + + // await result + suspendCancellableCoroutine> { continuation -> + val listener = object : AuthenticationListener { + override fun onSuccess(result: BiometricPrompt.AuthenticationResult) { + listeners.remove(this) + continuation.resume(Result.success(result)) + } + + override fun onError(error: LockException) { + listeners.remove(this) + continuation.resume(Result.failure(error)) + } + + } + listeners[listener] = Unit + continuation.invokeOnCancellation { + listeners.remove(listener) + } + }.getOrThrow() + } + + override fun getLockState(): LockState = + when (val result = biometricManager.canAuthenticate(allowedAuthenticators)) { + BIOMETRIC_SUCCESS -> LockState.Ready + BIOMETRIC_STATUS_UNKNOWN -> LockState.Ready + BIOMETRIC_ERROR_UNSUPPORTED -> LockState.NotAvailable + BIOMETRIC_ERROR_HW_UNAVAILABLE -> LockState.Ready + BIOMETRIC_ERROR_NONE_ENROLLED -> LockState.SetupRequired + BIOMETRIC_ERROR_NO_HARDWARE -> LockState.NotAvailable + BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> LockState.NotAvailable + else -> error("Unhandled BiometricManager.canAuthenticate result $result") + } + + private fun biometricPromptInfo(title: String, subtitle: String): BiometricPrompt.PromptInfo { + return BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(allowedAuthenticators) + .build() + } + + private val allowedAuthenticators: Int get() = when (Build.VERSION.SDK_INT) { + Build.VERSION_CODES.P, Build.VERSION_CODES.Q -> BIOMETRIC_WEAK or DEVICE_CREDENTIAL + else -> BIOMETRIC_STRONG or DEVICE_CREDENTIAL + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/repository/AppLockRepositoryImpl.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/repository/AppLockRepositoryImpl.kt new file mode 100644 index 00000000..887ab049 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/repository/AppLockRepositoryImpl.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import me.proton.android.drive.lock.data.db.AppLockDatabase +import me.proton.android.drive.lock.data.db.entity.AutoLockDurationEntity +import me.proton.android.drive.lock.data.db.entity.EnableAppLockEntity +import me.proton.android.drive.lock.data.extension.toAppLock +import me.proton.android.drive.lock.data.extension.toAppLockEntity +import me.proton.android.drive.lock.data.extension.toLock +import me.proton.android.drive.lock.data.extension.toLockEntity +import me.proton.android.drive.lock.domain.entity.AppLock +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.entity.LockKey +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class AppLockRepositoryImpl @Inject constructor( + private val db: AppLockDatabase, +) : AppLockRepository { + + override fun hasAppLockKeyFlow(): Flow = db.appLockDao.hasAppLockFlow() + override suspend fun hasAppLockKey(): Boolean = db.appLockDao.hasAppLock() + override suspend fun getAppLockKey(): AppLock = db.appLockDao.getAppLock().toAppLock() + override suspend fun insertAppLockKey(appLock: AppLock) = db.appLockDao.insertOrUpdate(appLock.toAppLockEntity()) + override suspend fun deleteAppLockKey() = db.appLockDao.deleteAppLock() + + override suspend fun hasLockKey(lockType: AppLockType): Boolean = db.lockDao.hasLock(lockType) + override suspend fun getLockKey(lockType: AppLockType): LockKey = db.lockDao.getLock(lockType).toLock() + override suspend fun insertLockKey(lockKey: LockKey) = db.lockDao.insertOrUpdate(lockKey.toLockEntity()) + override suspend fun deleteLockKey(lockType: AppLockType) = db.lockDao.deleteLock(lockType) + + override suspend fun hasAutoLockDuration(): Boolean = db.autoLockDurationDao.hasAutoLockDuration( + AUTO_LOCK_DURATION_KEY + ) + + override fun getAutoLockDuration(): Flow = db.autoLockDurationDao.getAutoLockDuration( + AUTO_LOCK_DURATION_KEY + ).filterNotNull().map { autoLockDurationEntity -> + autoLockDurationEntity.durationInSeconds.seconds + } + + override suspend fun insertOrUpdateAutoLockDuration(duration: Duration) = db.autoLockDurationDao.insertOrUpdate( + AutoLockDurationEntity( + key = AUTO_LOCK_DURATION_KEY, + durationInSeconds = duration.inWholeSeconds, + ) + ) + + override suspend fun hasEnableAppLockTimestamp(): Boolean = db.enableAppLockDao.hasEnableAppLock(ENABLE_LOCK_KEY) + + override suspend fun insertOrUpdateEnableAppLockTimestamp(timestamp: Long) = db.enableAppLockDao.insertOrUpdate( + EnableAppLockEntity( + key = ENABLE_LOCK_KEY, + timestamp = timestamp, + ) + ) + + companion object { + private const val AUTO_LOCK_DURATION_KEY = "auto-lock" + private const val ENABLE_LOCK_KEY = "enable-lock" + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/BuildAppKeyImpl.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/BuildAppKeyImpl.kt new file mode 100644 index 00000000..905e65f9 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/BuildAppKeyImpl.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.usecase + +import me.proton.android.drive.lock.data.crypto.PgpSecretKey +import me.proton.android.drive.lock.domain.entity.SecretKey +import me.proton.android.drive.lock.domain.lock.Lock +import me.proton.android.drive.lock.domain.usecase.BuildAppKey +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.drive.base.domain.util.coRunCatching +import javax.inject.Inject + +class BuildAppKeyImpl @Inject constructor( + private val cryptoContext: CryptoContext, +) : BuildAppKey { + override suspend operator fun invoke(key: String, lock: Lock): Result = coRunCatching { + lock.unlock(key) { passphrase -> + PgpSecretKey( + passphrase = PlainByteArray(passphrase), + lockedKey = key, + cryptoContext = cryptoContext, + ) + }.getOrThrow() + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/GeneratePgpSecretKey.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/GeneratePgpSecretKey.kt new file mode 100644 index 00000000..ebbd82a9 --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/GeneratePgpSecretKey.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.usecase + +import me.proton.android.drive.lock.data.crypto.PgpSecretKey +import me.proton.android.drive.lock.domain.entity.SecretKey +import me.proton.android.drive.lock.domain.usecase.GenerateSecretKey +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.drive.base.domain.util.coRunCatching +import javax.inject.Inject + +class GeneratePgpSecretKey @Inject constructor( + private val cryptoContext: CryptoContext, +) : GenerateSecretKey { + + override suspend fun invoke( + passphrase: ByteArray, + ): Result = coRunCatching { + cryptoContext.pgpCrypto.generateRandomBytes() + PgpSecretKey( + passphrase = PlainByteArray(passphrase.clone()), + lockedKey = cryptoContext.pgpCrypto.generateNewPrivateKey(DEFAULT_USERNAME, DEFAULT_DOMAIN, passphrase), + cryptoContext = cryptoContext, + ) + } + + companion object { + private const val DEFAULT_USERNAME = "drive-app-lock-key" + private const val DEFAULT_DOMAIN = "proton.me" + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/GetAppLockImpl.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/GetAppLockImpl.kt new file mode 100644 index 00000000..fdaf52ee --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/usecase/GetAppLockImpl.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.data.usecase + +import me.proton.android.drive.lock.data.crypto.PgpSecretKey +import me.proton.android.drive.lock.domain.entity.AppLock +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.entity.SecretKey +import me.proton.android.drive.lock.domain.usecase.GetAppLock +import me.proton.core.drive.base.domain.util.coRunCatching +import javax.inject.Inject + +class GetAppLockImpl @Inject constructor() : GetAppLock { + override operator fun invoke(secretKey: SecretKey, appLockType: AppLockType): Result = coRunCatching { + require(secretKey is PgpSecretKey) + AppLock( + key = secretKey.lockedKey, + type = appLockType, + ) + } +} diff --git a/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/worker/AppLockWorker.kt b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/worker/AppLockWorker.kt new file mode 100644 index 00000000..4768f64d --- /dev/null +++ b/app-lock/data/src/main/kotlin/me/proton/android/drive/lock/data/worker/AppLockWorker.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.data.worker + +import android.annotation.TargetApi +import android.content.Context +import android.os.Build +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.proton.android.drive.lock.domain.usecase.LockApp +import me.proton.core.drive.base.data.workmanager.addTags +import java.util.concurrent.TimeUnit +import kotlin.time.Duration + +@HiltWorker +@TargetApi(Build.VERSION_CODES.O) +class AppLockWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val lockApp: LockApp, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + lockApp() + return Result.success() + } + + companion object { + fun getWorkRequest( + runAfter: Duration, + tags: List = emptyList(), + ): OneTimeWorkRequest = + OneTimeWorkRequest.Builder(AppLockWorker::class.java) + .setInitialDelay(runAfter.inWholeSeconds, TimeUnit.SECONDS) + .addTags(tags) + .build() + } +} diff --git a/app-lock/domain/build.gradle.kts b/app-lock/domain/build.gradle.kts new file mode 100644 index 00000000..07d41c2e --- /dev/null +++ b/app-lock/domain/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +plugins { + id("com.android.library") +} + +driveModule( + hilt = true, + serialization = true, +) { + api(project(":drive:link:domain")) + api(project(":drive:crypto-base:domain")) +} diff --git a/app-lock/domain/src/main/AndroidManifest.xml b/app-lock/domain/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4a0a4237 --- /dev/null +++ b/app-lock/domain/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/AppLock.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/AppLock.kt new file mode 100644 index 00000000..927ed0b4 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/AppLock.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.entity + +data class AppLock( + val key: String, + val type: AppLockType, +) diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/AppLockType.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/AppLockType.kt new file mode 100644 index 00000000..c80f72fa --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/AppLockType.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.entity + +enum class AppLockType { + SYSTEM, +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/LockKey.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/LockKey.kt new file mode 100644 index 00000000..2d2b77a1 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/LockKey.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.entity + +data class LockKey( + val appKeyPassphrase: ByteArray, + val appKey: String, + val type: AppLockType, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LockKey + + if (!appKeyPassphrase.contentEquals(other.appKeyPassphrase)) return false + if (appKey != other.appKey) return false + if (type != other.type) return false + + return true + } + + override fun hashCode(): Int { + var result = appKeyPassphrase.contentHashCode() + result = 31 * result + appKey.hashCode() + result = 31 * result + type.hashCode() + return result + } +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/SecretKey.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/SecretKey.kt new file mode 100644 index 00000000..4e64ad34 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/entity/SecretKey.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.entity + +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.EncryptedString +import me.proton.core.crypto.common.keystore.PlainByteArray + +interface SecretKey { + /** + * Encrypt a [String] [value] and return an [EncryptedString]. + */ + fun encrypt(value: String): EncryptedString + + /** + * Decrypt an [EncryptedString] [value] and return a [String]. + */ + fun decrypt(value: EncryptedString): String + + /** + * Encrypt a [PlainByteArray] [value] and return an [EncryptedByteArray]. + */ + fun encrypt(value: PlainByteArray): EncryptedByteArray + + /** + * Decrypt an [EncryptedByteArray] [value] and return a [PlainByteArray]. + */ + fun decrypt(value: EncryptedByteArray): PlainByteArray +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/exception/LockException.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/exception/LockException.kt new file mode 100644 index 00000000..19066bf3 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/exception/LockException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.domain.exception + +import me.proton.core.drive.base.domain.exception.DriveException + +sealed class LockException : DriveException() { + object BiometricAuthenticationFailed : LockException() + data class BiometricAuthenticationError(val errorMessage: String) : LockException() +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/lock/Lock.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/lock/Lock.kt new file mode 100644 index 00000000..9ca9a8c4 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/lock/Lock.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.domain.lock + +import me.proton.android.drive.lock.domain.entity.LockKey + +interface Lock { + /** + * Unlocks passphrase for a given [key] and provides it to the [block]. + * After [block] is done, passphrase should not be available anymore. + */ + suspend fun unlock(key: String, block: suspend (passphrase: ByteArray) -> T): Result + + /** + * Locks [passphrase] so that it's safe to store. + */ + suspend fun lock(passphrase: ByteArray): Result + + /** + * Called when [Lock] should not be used anymore. If [userAuthenticationRequired] is true then user authentication + * is required before disabling can be done. + */ + suspend fun disable(userAuthenticationRequired: Boolean) + + /** + * Called when [Lock] should protect [lockKey] + */ + suspend fun enable(lockKey: LockKey) + + /** + * Provides current [Lock] state. See [LockState]. + */ + fun getLockState(): LockState +} + +sealed interface LockState { + object NotAvailable : LockState + object SetupRequired : LockState + object Ready : LockState +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/manager/AppLockManager.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/manager/AppLockManager.kt new file mode 100644 index 00000000..c7ff6b97 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/manager/AppLockManager.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.manager + +import kotlinx.coroutines.flow.StateFlow +import me.proton.android.drive.lock.domain.entity.AppLock +import me.proton.android.drive.lock.domain.entity.SecretKey + +interface AppLockManager { + val locked: StateFlow + val enabled: StateFlow + suspend fun isLocked(): Boolean + suspend fun isEnabled(): Boolean + suspend fun unlock(appKey: SecretKey) + suspend fun lock() + suspend fun enable(secretKey: SecretKey, appLock: AppLock): Result + suspend fun disable(): Result +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/manager/AutoLockManager.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/manager/AutoLockManager.kt new file mode 100644 index 00000000..fd26a9ea --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/manager/AutoLockManager.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.domain.manager + +interface AutoLockManager { + suspend fun autoLock() + fun cancelAutoLock() +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/repository/AppLockRepository.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/repository/AppLockRepository.kt new file mode 100644 index 00000000..7bf6ba5a --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/repository/AppLockRepository.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.repository + +import kotlinx.coroutines.flow.Flow +import me.proton.android.drive.lock.domain.entity.AppLock +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.entity.LockKey +import kotlin.time.Duration + +interface AppLockRepository { + fun hasAppLockKeyFlow(): Flow + suspend fun hasAppLockKey(): Boolean + suspend fun getAppLockKey(): AppLock + suspend fun insertAppLockKey(appLock: AppLock) + suspend fun deleteAppLockKey() + + suspend fun hasLockKey(lockType: AppLockType): Boolean + suspend fun getLockKey(lockType: AppLockType): LockKey + suspend fun insertLockKey(lockKey: LockKey) + suspend fun deleteLockKey(lockType: AppLockType) + + suspend fun hasAutoLockDuration(): Boolean + fun getAutoLockDuration(): Flow + suspend fun insertOrUpdateAutoLockDuration(duration: Duration) + + suspend fun hasEnableAppLockTimestamp(): Boolean + suspend fun insertOrUpdateEnableAppLockTimestamp(timestamp: Long) +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/BuildAppKey.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/BuildAppKey.kt new file mode 100644 index 00000000..558b66a0 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/BuildAppKey.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.entity.SecretKey +import me.proton.android.drive.lock.domain.lock.Lock + +interface BuildAppKey { + suspend operator fun invoke(key: String, lock: Lock): Result +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/DisableAppLock.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/DisableAppLock.kt new file mode 100644 index 00000000..ad61ae86 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/DisableAppLock.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.lock.Lock +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.core.drive.base.domain.util.coRunCatching +import me.proton.core.util.kotlin.CoreLogger +import javax.inject.Inject + +class DisableAppLock @Inject constructor( + private val appLockManager: AppLockManager, + private val locks: @JvmSuppressWildcards Map, +) { + suspend operator fun invoke( + lockType: AppLockType = AppLockType.SYSTEM, + userAuthenticationRequired: Boolean = true, + ): Result = coRunCatching { + if (appLockManager.isEnabled()) { + requireNotNull(locks[lockType]).disable(userAuthenticationRequired) + appLockManager.disable().getOrThrow() + } + } +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/EnableAppLock.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/EnableAppLock.kt new file mode 100644 index 00000000..4fe0a1af --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/EnableAppLock.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.entity.LockKey +import me.proton.android.drive.lock.domain.lock.Lock +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.core.drive.base.domain.util.coRunCatching +import me.proton.core.drive.cryptobase.domain.usecase.GeneratePassphrase +import javax.inject.Inject + +class EnableAppLock @Inject constructor( + private val generatePassphrase: GeneratePassphrase, + private val generateSecretKey: GenerateSecretKey, + private val getAppLock: GetAppLock, + private val appLockManager: AppLockManager, + private val locks: @JvmSuppressWildcards Map, +) { + + suspend operator fun invoke(lockType: AppLockType = AppLockType.SYSTEM) = coRunCatching { + val passphrase = generatePassphrase() + val secretKey = generateSecretKey(passphrase).getOrThrow() + val appLock = getAppLock(secretKey, lockType).getOrThrow() + val lock = requireNotNull(locks[lockType]) + val lockKey = LockKey( + appKeyPassphrase = lock.lock(passphrase).getOrThrow(), + appKey = appLock.key, + type = appLock.type, + ) + appLockManager.enable(secretKey, appLock).getOrThrow() + lock.enable(lockKey) + } +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GenerateSecretKey.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GenerateSecretKey.kt new file mode 100644 index 00000000..7764419a --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GenerateSecretKey.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.entity.SecretKey + +interface GenerateSecretKey { + suspend operator fun invoke(passphrase: ByteArray): Result +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetAppLock.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetAppLock.kt new file mode 100644 index 00000000..ce57ae45 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetAppLock.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.entity.AppLock +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.entity.SecretKey + +interface GetAppLock { + operator fun invoke(secretKey: SecretKey, appLockType: AppLockType): Result +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetAutoLockDuration.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetAutoLockDuration.kt new file mode 100644 index 00000000..55bb8617 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetAutoLockDuration.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.domain.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import javax.inject.Inject +import kotlin.time.Duration + +class GetAutoLockDuration @Inject constructor( + private val appLockRepository: AppLockRepository, + private val configurationProvider: ConfigurationProvider, +) { + operator fun invoke(): Flow = flow { + if (appLockRepository.hasAutoLockDuration().not()) { + emit(configurationProvider.autoLockDurations.first()) + } + emitAll(appLockRepository.getAutoLockDuration()) + } +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetLockState.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetLockState.kt new file mode 100644 index 00000000..6644041b --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/GetLockState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.lock.Lock +import me.proton.android.drive.lock.domain.lock.LockState +import javax.inject.Inject + +class GetLockState @Inject constructor( + private val locks: @JvmSuppressWildcards Map, +) { + operator fun invoke(appLockType: AppLockType = AppLockType.SYSTEM): LockState = + requireNotNull(locks[appLockType]).getLockState() +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/HasEnableAppLockTimestamp.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/HasEnableAppLockTimestamp.kt new file mode 100644 index 00000000..690fa471 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/HasEnableAppLockTimestamp.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import javax.inject.Inject + +class HasEnableAppLockTimestamp @Inject constructor( + private val appLockRepository: AppLockRepository, +) { + suspend operator fun invoke(): Boolean = + appLockRepository.hasEnableAppLockTimestamp() +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/LockApp.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/LockApp.kt new file mode 100644 index 00000000..642f8da9 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/LockApp.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.manager.AppLockManager +import javax.inject.Inject + +class LockApp @Inject constructor( + private val appLockManager: AppLockManager, +) { + suspend operator fun invoke() { + if (appLockManager.isEnabled() && appLockManager.isLocked().not()) { + appLockManager.lock() + } + } +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/UnlockApp.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/UnlockApp.kt new file mode 100644 index 00000000..d440cb54 --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/UnlockApp.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.lock.Lock +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import me.proton.core.drive.base.domain.util.coRunCatching +import javax.inject.Inject + +class UnlockApp @Inject constructor( + private val appLockManager: AppLockManager, + private val appLockRepository: AppLockRepository, + private val locks: @JvmSuppressWildcards Map, + private val buildAppKey: BuildAppKey, +) { + suspend operator fun invoke(): Result = coRunCatching { + if (appLockManager.isEnabled() && appLockManager.isLocked()) { + val appLockKey = appLockRepository.getAppLockKey() + appLockManager.unlock( + buildAppKey(appLockKey.key, requireNotNull(locks[appLockKey.type])).getOrThrow() + ) + } + } +} diff --git a/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/UpdateAutoLockDuration.kt b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/UpdateAutoLockDuration.kt new file mode 100644 index 00000000..eab69a4e --- /dev/null +++ b/app-lock/domain/src/main/kotlin/me/proton/android/drive/lock/domain/usecase/UpdateAutoLockDuration.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.domain.usecase + +import me.proton.android.drive.lock.domain.repository.AppLockRepository +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.util.coRunCatching +import javax.inject.Inject +import kotlin.time.Duration + +class UpdateAutoLockDuration @Inject constructor( + private val appLockRepository: AppLockRepository, + private val configurationProvider: ConfigurationProvider, +) { + suspend operator fun invoke(duration: Duration): Result = coRunCatching { + require(configurationProvider.autoLockDurations.contains(duration)) { + "Only values from ConfigurationProvider#autoLockDurations are allowed" + } + appLockRepository.insertOrUpdateAutoLockDuration(duration) + } +} diff --git a/app-lock/presentation/build.gradle.kts b/app-lock/presentation/build.gradle.kts new file mode 100644 index 00000000..559b9a0d --- /dev/null +++ b/app-lock/presentation/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +plugins { + id("com.android.library") +} + +driveModule( + hilt = true, + compose = true, +) { + api(project(":app-lock:domain")) + implementation(project(":drive:base:presentation")) + implementation(libs.accompanist.drawablepainter) +} diff --git a/app-lock/presentation/src/main/AndroidManifest.xml b/app-lock/presentation/src/main/AndroidManifest.xml new file mode 100644 index 00000000..80f095b1 --- /dev/null +++ b/app-lock/presentation/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/component/AppLock.kt b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/component/AppLock.kt new file mode 100644 index 00000000..a662cac0 --- /dev/null +++ b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/component/AppLock.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.presentation.component + +import androidx.compose.animation.Crossfade +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.proton.core.account.domain.entity.Account +import me.proton.core.domain.entity.UserId + +@Composable +fun AppLock( + locked: Flow, + primaryAccount: Flow, + content: @Composable () -> Unit, +) { + var isLocked by remember { mutableStateOf(false) } + var userId by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + locked + .onEach { locked -> + isLocked = locked + } + .launchIn(this) + primaryAccount + .onEach { account -> + userId = account?.userId + } + .launchIn(this) + } + Crossfade(targetState = isLocked) { appLocked -> + if (appLocked) { + Unlock( + userId = userId, + modifier = Modifier, + ) + } else { + content() + } + } +} diff --git a/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/component/Unlock.kt b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/component/Unlock.kt new file mode 100644 index 00000000..73cac280 --- /dev/null +++ b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/component/Unlock.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.presentation.component + +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import me.proton.android.drive.lock.presentation.R +import me.proton.android.drive.lock.presentation.viewevent.UnlockViewEvent +import me.proton.android.drive.lock.presentation.viewmodel.UnlockViewModel +import me.proton.core.compose.component.ProtonSolidButton +import me.proton.core.compose.component.ProtonTextButton +import me.proton.core.compose.theme.ProtonDimens.LargeSpacing +import me.proton.core.compose.theme.ProtonDimens.ListItemHeight +import me.proton.core.compose.theme.ProtonDimens.SmallSpacing +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.presentation.extension.conditional +import me.proton.core.drive.base.presentation.extension.isLandscape +import me.proton.core.drive.base.presentation.extension.isPortrait +import me.proton.core.drive.base.presentation.extension.shadow +import me.proton.core.drive.base.presentation.R as BasePresentation +import me.proton.core.presentation.R as CorePresentation + +@Composable +fun Unlock( + userId: UserId?, + modifier: Modifier = Modifier, +) { + val viewModel = hiltViewModel() + Unlock( + userId = userId, + viewEvent = viewModel.viewEvent, + modifier = modifier, + ) +} + +@Composable +fun Unlock( + userId: UserId?, + viewEvent: UnlockViewEvent, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { + viewEvent.onShowBiometric() + } + Column( + modifier = modifier + .fillMaxSize() + .conditional(isPortrait) { + navigationBarsPadding() + } + ) { + LogoHeader( + modifier = Modifier + .weight(LogoHeaderWeight) + ) + Actions( + modifier = Modifier + .weight(1f - LogoHeaderWeight), + onUnlock = { viewEvent.onShowBiometric() }, + onSignOut = { viewEvent.onSignOut(userId) }, + ) + } + +} + +@Composable +private fun LogoHeader( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + ) { + Image( + painter = rememberDrawablePainter( + drawable = getDrawable( + light = R.drawable.welcome_header_light, + dark = R.drawable.welcome_header_dark, + dayNight = R.drawable.welcome_header, + ) + ), + contentDescription = null, + contentScale = ContentScale.None, + ) + val y = LogoTranslationY + Image( + painter = painterResource(id = CorePresentation.drawable.ic_logo_drive), + contentDescription = null, + modifier = Modifier + .size(LogoSize) + .align(Alignment.BottomCenter) + .graphicsLayer { + translationX = 0f + translationY = y + } + .shadow( + color = ShadowColor, + alpha = DriveLogoShadowAlpha, + cornersRadius = DriveLogoShadowCornerRadius, + blurRadius = DriveLogoShadowBlurRadius, + offsetY = DriveLogoShadowOffsetY, + ) + ) + } +} + +@Composable +private fun Actions( + modifier: Modifier = Modifier, + onUnlock: () -> Unit, + onSignOut: () -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth(), + ) { + Image( + painter = rememberDrawablePainter( + drawable = getDrawable( + light = CorePresentation.drawable.logo_drive_dark, + dark = CorePresentation.drawable.logo_drive_light, + dayNight = CorePresentation.drawable.logo_drive_daylight, + ) + ), + contentDescription = null, + modifier = Modifier + .padding(top = DriveLogoTopPadding) + .heightIn(max = DriveLogoHeight) + .align(Alignment.TopCenter) + ) + val buttonModifier = Modifier + .conditional(isPortrait) { + fillMaxWidth() + } + .conditional(isLandscape) { + widthIn(min = ButtonMinWidth) + } + .heightIn(min = ListItemHeight) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(all = LargeSpacing) + .align(Alignment.BottomCenter) + ) { + ProtonSolidButton( + onClick = onUnlock, + modifier = buttonModifier, + ) { + Text(text = stringResource(id = BasePresentation.string.app_lock_unlock_the_app)) + } + Spacer( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = SmallSpacing) + ) + ProtonTextButton( + onClick = onSignOut, + modifier = buttonModifier, + ) { + Text(text = stringResource(id = BasePresentation.string.title_sign_out)) + } + } + } +} + +@Composable +private fun getDrawable(@DrawableRes light: Int, @DrawableRes dark: Int, @DrawableRes dayNight: Int): Drawable? = + AppCompatResources.getDrawable( + LocalContext.current, + when (AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.MODE_NIGHT_YES -> dark + AppCompatDelegate.MODE_NIGHT_NO -> light + else -> dayNight + } + ) + +@Preview +@Composable +private fun UnlockPreview() { + ProtonTheme { + Unlock( + userId = null, + viewEvent = object : UnlockViewEvent { + override val onShowBiometric = {} + override val onSignOut: (UserId?) -> Unit = {} + } + ) + } +} + +private val ShadowColor = Color(0xFF0D052E) +private val LogoSize = 106.dp +private val LogoTranslationY: Float @Composable get() = if (isPortrait) { + LocalDensity.current.run { 44.dp.toPx() } +} else { + LocalDensity.current.run { 42.dp.toPx() } +} +private val LogoHeaderWeight: Float @Composable get() = if (isPortrait) 0.3f else 0.25f +private val ButtonMinWidth = 300.dp +private val DriveLogoHeight = 32.dp +private val DriveLogoTopPadding = 60.dp +private const val DriveLogoShadowAlpha = 0.07f +private val DriveLogoShadowCornerRadius = 24.dp +private val DriveLogoShadowBlurRadius = 16.dp +private val DriveLogoShadowOffsetY = 8.dp diff --git a/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/extension/LockException.kt b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/extension/LockException.kt new file mode 100644 index 00000000..79fc8a0d --- /dev/null +++ b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/extension/LockException.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.lock.presentation.extension + +import android.content.Context +import me.proton.android.drive.lock.domain.exception.LockException +import me.proton.core.drive.base.presentation.R as BasePresentation + +fun LockException.getDefaultMessage(context: Context): String = when (this) { + is LockException.BiometricAuthenticationFailed -> context.getString( + BasePresentation.string.app_lock_system_biometrics_authentication_failed + ) + is LockException.BiometricAuthenticationError -> errorMessage + else -> error("Default message for exception is missing") +} diff --git a/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/viewevent/UnlockViewEvent.kt b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/viewevent/UnlockViewEvent.kt new file mode 100644 index 00000000..a106cb27 --- /dev/null +++ b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/viewevent/UnlockViewEvent.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.presentation.viewevent + +import me.proton.core.domain.entity.UserId + +interface UnlockViewEvent { + val onShowBiometric: () -> Unit + val onSignOut: (UserId?) -> Unit +} diff --git a/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/viewmodel/UnlockViewModel.kt b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/viewmodel/UnlockViewModel.kt new file mode 100644 index 00000000..c1a85e23 --- /dev/null +++ b/app-lock/presentation/src/main/kotlin/me/proton/android/drive/lock/presentation/viewmodel/UnlockViewModel.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.lock.presentation.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import me.proton.android.drive.lock.domain.exception.LockException +import me.proton.android.drive.lock.domain.usecase.UnlockApp +import me.proton.android.drive.lock.presentation.viewevent.UnlockViewEvent +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.usecase.SignOut +import me.proton.android.drive.lock.presentation.extension.getDefaultMessage +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.usecase.BroadcastMessages +import me.proton.core.drive.base.presentation.extension.getDefaultMessage +import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage +import javax.inject.Inject + +@Suppress("StaticFieldLeak") +@HiltViewModel +class UnlockViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + private val signOut: SignOut, + private val unlockApp: UnlockApp, + private val configurationProvider: ConfigurationProvider, + private val broadcastMessages: BroadcastMessages, + private val accountManager: AccountManager, +) : ViewModel() { + + val viewEvent = object : UnlockViewEvent { + override val onShowBiometric: () -> Unit = { showBiometrics() } + override val onSignOut: (userId: UserId?) -> Unit = { userId -> userId?.let { doSignOut(userId) }} + } + + private fun showBiometrics() = viewModelScope.launch { + unlockApp() + .onFailure { error -> + broadcastMessages( + userId = accountManager.getAccounts().first().first().userId, + message = when (error) { + is LockException -> error.getDefaultMessage(appContext) + else -> error.getDefaultMessage(appContext, configurationProvider.useExceptionMessage) + }, + type = BroadcastMessage.Type.WARNING, + ) + } + } + + private fun doSignOut(userId: UserId) = viewModelScope.launch { + signOut(userId) + } +} diff --git a/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_dark.webp b/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_dark.webp new file mode 100644 index 00000000..ff4acd8d Binary files /dev/null and b/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_dark.webp differ diff --git a/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_dark_land.webp b/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_dark_land.webp new file mode 100644 index 00000000..93fc8598 Binary files /dev/null and b/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_dark_land.webp differ diff --git a/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_light.webp b/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_light.webp new file mode 100644 index 00000000..4329da25 Binary files /dev/null and b/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_light.webp differ diff --git a/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_light_land.webp b/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_light_land.webp new file mode 100644 index 00000000..5cb8502b Binary files /dev/null and b/app-lock/presentation/src/main/res/drawable-nodpi/welcome_header_light_land.webp differ diff --git a/app-lock/presentation/src/main/res/values-land-night/drawable.xml b/app-lock/presentation/src/main/res/values-land-night/drawable.xml new file mode 100644 index 00000000..f0553bb3 --- /dev/null +++ b/app-lock/presentation/src/main/res/values-land-night/drawable.xml @@ -0,0 +1,21 @@ + + + + @drawable/welcome_header_dark_land + diff --git a/app-lock/presentation/src/main/res/values-land/drawable.xml b/app-lock/presentation/src/main/res/values-land/drawable.xml new file mode 100644 index 00000000..c680ff7d --- /dev/null +++ b/app-lock/presentation/src/main/res/values-land/drawable.xml @@ -0,0 +1,23 @@ + + + + @drawable/welcome_header_light_land + @drawable/welcome_header_light_land + @drawable/welcome_header_dark_land + diff --git a/app-lock/presentation/src/main/res/values-night/drawable.xml b/app-lock/presentation/src/main/res/values-night/drawable.xml new file mode 100644 index 00000000..7b219490 --- /dev/null +++ b/app-lock/presentation/src/main/res/values-night/drawable.xml @@ -0,0 +1,21 @@ + + + + @drawable/welcome_header_dark + diff --git a/app-lock/presentation/src/main/res/values/drawable.xml b/app-lock/presentation/src/main/res/values/drawable.xml new file mode 100644 index 00000000..85250111 --- /dev/null +++ b/app-lock/presentation/src/main/res/values/drawable.xml @@ -0,0 +1,21 @@ + + + + @drawable/welcome_header_light + diff --git a/app-lock/src/main/AndroidManifest.xml b/app-lock/src/main/AndroidManifest.xml new file mode 100644 index 00000000..707ffdcb --- /dev/null +++ b/app-lock/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a00aea3f..96080a54 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,6 @@ * along with Proton Drive. If not, see . */ -import java.io.FileInputStream import java.util.Properties plugins { @@ -49,6 +48,7 @@ driveModule( serialization = true, ) { implementation(files("../../proton-libs/gopenpgp/gopenpgp.aar")) + implementation(project(":app-lock")) implementation(project(":app-ui-settings")) implementation(project(":drive")) @@ -67,13 +67,14 @@ driveModule( implementation(libs.timber) androidTestImplementation(libs.androidx.navigation.compose) + androidTestImplementation(libs.fusion) coreLibraryDesugaring(libs.desugar.jdk.libs) } val privateProperties = Properties().apply { try { - load(FileInputStream("private.properties")) + load(rootDir.resolve("private.properties").inputStream()) } catch (exception: java.io.FileNotFoundException) { // Provide empty properties to allow the app to be built without secrets logger.warn("private.properties file not found", exception) diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/1.json b/app/schemas/me.proton.android.drive.db.AppDatabase/1.json index 8d06834d..995a4108 100644 --- a/app/schemas/me.proton.android.drive.db.AppDatabase/1.json +++ b/app/schemas/me.proton.android.drive.db.AppDatabase/1.json @@ -2,1662 +2,12 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "1848c5aa24ef749bea72f819f081cfe5", + "identityHash": "be4e11f65691008e811c02bbbb906104", "entities": [ { - "tableName": "AccountEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "tableName": "AppLockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`key`))", "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "username", - "columnName": "username", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sessionId", - "columnName": "sessionId", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "sessionState", - "columnName": "sessionState", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "userId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_AccountEntity_sessionId", - "unique": false, - "columnNames": [ - "sessionId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" - }, - { - "name": "index_AccountEntity_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" - } - ], - "foreignKeys": [ - { - "table": "SessionEntity", - "onDelete": "NO ACTION", - "onUpdate": "NO ACTION", - "columns": [ - "sessionId" - ], - "referencedColumns": [ - "sessionId" - ] - } - ] - }, - { - "tableName": "AccountMetadataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "product", - "columnName": "product", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "primaryAtUtc", - "columnName": "primaryAtUtc", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "migrations", - "columnName": "migrations", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "userId", - "product" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_AccountMetadataEntity_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" - }, - { - "name": "index_AccountMetadataEntity_product", - "unique": false, - "columnNames": [ - "product" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" - }, - { - "name": "index_AccountMetadataEntity_primaryAtUtc", - "unique": false, - "columnNames": [ - "primaryAtUtc" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" - } - ], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "SessionEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sessionId", - "columnName": "sessionId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessToken", - "columnName": "accessToken", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "refreshToken", - "columnName": "refreshToken", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scopes", - "columnName": "scopes", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "product", - "columnName": "product", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "sessionId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_SessionEntity_sessionId", - "unique": false, - "columnNames": [ - "sessionId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" - }, - { - "name": "index_SessionEntity_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" - } - ], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "SessionDetailsEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sessionId", - "columnName": "sessionId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "initialEventId", - "columnName": "initialEventId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "requiredAccountType", - "columnName": "requiredAccountType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "secondFactorEnabled", - "columnName": "secondFactorEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "twoPassModeEnabled", - "columnName": "twoPassModeEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password", - "columnName": "password", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "sessionId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_SessionDetailsEntity_sessionId", - "unique": false, - "columnNames": [ - "sessionId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" - } - ], - "foreignKeys": [ - { - "table": "SessionEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sessionId" - ], - "referencedColumns": [ - "sessionId" - ] - } - ] - }, - { - "tableName": "UserEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "currency", - "columnName": "currency", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "credit", - "columnName": "credit", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "usedSpace", - "columnName": "usedSpace", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "maxSpace", - "columnName": "maxSpace", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "maxUpload", - "columnName": "maxUpload", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "role", - "columnName": "role", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "private", - "columnName": "private", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subscribed", - "columnName": "subscribed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "services", - "columnName": "services", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "delinquent", - "columnName": "delinquent", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "passphrase", - "columnName": "passphrase", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "userId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_UserEntity_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" - } - ], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "UserKeyEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keyId", - "columnName": "keyId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "privateKey", - "columnName": "privateKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isPrimary", - "columnName": "isPrimary", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isUnlockable", - "columnName": "isUnlockable", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "fingerprint", - "columnName": "fingerprint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "activation", - "columnName": "activation", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "active", - "columnName": "active", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "keyId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_UserKeyEntity_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" - }, - { - "name": "index_UserKeyEntity_keyId", - "unique": false, - "columnNames": [ - "keyId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" - } - ], - "foreignKeys": [ - { - "table": "UserEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "AddressEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "addressId", - "columnName": "addressId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "signature", - "columnName": "signature", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "domainId", - "columnName": "domainId", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "canSend", - "columnName": "canSend", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canReceive", - "columnName": "canReceive", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "order", - "columnName": "order", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "signedKeyList.data", - "columnName": "signedKeyList_data", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "signedKeyList.signature", - "columnName": "signedKeyList_signature", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "addressId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_AddressEntity_addressId", - "unique": false, - "columnNames": [ - "addressId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" - }, - { - "name": "index_AddressEntity_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" - } - ], - "foreignKeys": [ - { - "table": "UserEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "AddressKeyEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "addressId", - "columnName": "addressId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keyId", - "columnName": "keyId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "privateKey", - "columnName": "privateKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isPrimary", - "columnName": "isPrimary", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isUnlockable", - "columnName": "isUnlockable", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "flags", - "columnName": "flags", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "passphrase", - "columnName": "passphrase", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "token", - "columnName": "token", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "signature", - "columnName": "signature", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "fingerprint", - "columnName": "fingerprint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "fingerprints", - "columnName": "fingerprints", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "activation", - "columnName": "activation", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "active", - "columnName": "active", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "keyId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_AddressKeyEntity_addressId", - "unique": false, - "columnNames": [ - "addressId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" - }, - { - "name": "index_AddressKeyEntity_keyId", - "unique": false, - "columnNames": [ - "keyId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" - } - ], - "foreignKeys": [ - { - "table": "AddressEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "addressId" - ], - "referencedColumns": [ - "addressId" - ] - } - ] - }, - { - "tableName": "KeySaltEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keyId", - "columnName": "keyId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keySalt", - "columnName": "keySalt", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "userId", - "keyId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_KeySaltEntity_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" - }, - { - "name": "index_KeySaltEntity_keyId", - "unique": false, - "columnNames": [ - "keyId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "PublicAddressEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`email`))", - "fields": [ - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "recipientType", - "columnName": "recipientType", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "signedKeyListEntity.data", - "columnName": "signedKeyList_data", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "signedKeyListEntity.signature", - "columnName": "signedKeyList_signature", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "email" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_PublicAddressEntity_email", - "unique": false, - "columnNames": [ - "email" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "PublicAddressKeyEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "flags", - "columnName": "flags", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "publicKey", - "columnName": "publicKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isPrimary", - "columnName": "isPrimary", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "email", - "publicKey" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_PublicAddressKeyEntity_email", - "unique": false, - "columnNames": [ - "email" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" - } - ], - "foreignKeys": [ - { - "table": "PublicAddressEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "email" - ], - "referencedColumns": [ - "email" - ] - } - ] - }, - { - "tableName": "HumanVerificationEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `captchaVerificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", - "fields": [ - { - "fieldPath": "clientId", - "columnName": "clientId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "clientIdType", - "columnName": "clientIdType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "verificationMethods", - "columnName": "verificationMethods", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "captchaVerificationToken", - "columnName": "captchaVerificationToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "humanHeaderTokenType", - "columnName": "humanHeaderTokenType", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "humanHeaderTokenCode", - "columnName": "humanHeaderTokenCode", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "clientId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "UserSettingsEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_u2fKeys` TEXT, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "news", - "columnName": "news", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "logAuth", - "columnName": "logAuth", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "invoiceText", - "columnName": "invoiceText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "density", - "columnName": "density", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "theme", - "columnName": "theme", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "themeType", - "columnName": "themeType", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "weekStart", - "columnName": "weekStart", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "dateFormat", - "columnName": "dateFormat", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "timeFormat", - "columnName": "timeFormat", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "welcome", - "columnName": "welcome", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "earlyAccess", - "columnName": "earlyAccess", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "email.value", - "columnName": "email_value", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "email.status", - "columnName": "email_status", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "email.notify", - "columnName": "email_notify", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "email.reset", - "columnName": "email_reset", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "phone.value", - "columnName": "phone_value", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "phone.status", - "columnName": "phone_status", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "phone.notify", - "columnName": "phone_notify", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "phone.reset", - "columnName": "phone_reset", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "password.mode", - "columnName": "password_mode", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "password.expirationTime", - "columnName": "password_expirationTime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "twoFA.enabled", - "columnName": "twoFA_enabled", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "twoFA.allowed", - "columnName": "twoFA_allowed", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "twoFA.expirationTime", - "columnName": "twoFA_expirationTime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "twoFA.u2fKeys", - "columnName": "twoFA_u2fKeys", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "flags.welcomed", - "columnName": "flags_welcomed", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "userId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "UserEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "OrganizationEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `vpnPlanName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "planName", - "columnName": "planName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "vpnPlanName", - "columnName": "vpnPlanName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "twoFactorGracePeriod", - "columnName": "twoFactorGracePeriod", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "theme", - "columnName": "theme", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "maxDomains", - "columnName": "maxDomains", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "maxAddresses", - "columnName": "maxAddresses", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "maxSpace", - "columnName": "maxSpace", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "maxMembers", - "columnName": "maxMembers", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "maxVPN", - "columnName": "maxVPN", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "features", - "columnName": "features", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "flags", - "columnName": "flags", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "usedDomains", - "columnName": "usedDomains", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "usedAddresses", - "columnName": "usedAddresses", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "usedSpace", - "columnName": "usedSpace", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "assignedSpace", - "columnName": "assignedSpace", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "usedMembers", - "columnName": "usedMembers", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "usedVPN", - "columnName": "usedVPN", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "hasKeys", - "columnName": "hasKeys", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "toMigrate", - "columnName": "toMigrate", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "userId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "UserEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "OrganizationKeysEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicKey", - "columnName": "publicKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "privateKey", - "columnName": "privateKey", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "userId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "UserEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "EventMetadataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "config", - "columnName": "config", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nextEventId", - "columnName": "nextEventId", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "refresh", - "columnName": "refresh", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "more", - "columnName": "more", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "response", - "columnName": "response", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "retry", - "columnName": "retry", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "userId", - "config" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_EventMetadataEntity_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" - }, - { - "name": "index_EventMetadataEntity_config", - "unique": false, - "columnNames": [ - "config" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" - }, - { - "name": "index_EventMetadataEntity_createdAt", - "unique": false, - "columnNames": [ - "createdAt" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" - } - ], - "foreignKeys": [ - { - "table": "UserEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "VolumeEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `max_space` INTEGER, `used_space` INTEGER NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "creationTime", - "columnName": "creation_time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "maxSpace", - "columnName": "max_space", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "usedSpace", - "columnName": "used_space", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_VolumeEntity_user_id", - "unique": false, - "columnNames": [ - "user_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" - }, - { - "name": "index_VolumeEntity_share_id", - "unique": false, - "columnNames": [ - "share_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" - }, - { - "name": "index_VolumeEntity_id", - "unique": false, - "columnNames": [ - "id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_id` ON `${TABLE_NAME}` (`id`)" - } - ], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "ShareEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `link_id` TEXT NOT NULL, `block_size` INTEGER NOT NULL, `locked` INTEGER NOT NULL, `key` TEXT NOT NULL, `passphrase` TEXT NOT NULL, `passphrase_signature` TEXT NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "volumeId", - "columnName": "volume_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "flags", - "columnName": "flags", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "blockSize", - "columnName": "block_size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLocked", - "columnName": "locked", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "key", "columnName": "key", @@ -1665,1569 +15,132 @@ "notNull": true }, { - "fieldPath": "passphrase", - "columnName": "passphrase", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "passphraseSignature", - "columnName": "passphrase_signature", + "fieldPath": "type", + "columnName": "type", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ - "user_id", - "id" + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`passphrase` TEXT NOT NULL, `key` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`passphrase`), FOREIGN KEY(`key`) REFERENCES `AppLockEntity`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "appKeyPassphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appKey", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "passphrase" ], "autoGenerate": false }, "indices": [ { - "name": "index_ShareEntity_user_id", + "name": "index_LockEntity_key", "unique": false, "columnNames": [ - "user_id" + "key" ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" - }, - { - "name": "index_ShareEntity_volume_id", - "unique": false, - "columnNames": [ - "volume_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" - }, - { - "name": "index_ShareEntity_link_id", - "unique": false, - "columnNames": [ - "link_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LockEntity_key` ON `${TABLE_NAME}` (`key`)" } ], "foreignKeys": [ { - "table": "AccountEntity", + "table": "AppLockEntity", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ - "user_id" + "key" ], "referencedColumns": [ - "userId" + "key" ] } ] }, { - "tableName": "ShareUrlEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `name` TEXT, `token` TEXT NOT NULL, `creatior_email` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `expiration_time` INTEGER, `last_access_time` INTEGER, `max_accesses` INTEGER, `number_of_accesses` INTEGER NOT NULL, `url_password_salt` TEXT NOT NULL, `share_password_salt` TEXT NOT NULL, `srp_verifier` TEXT NOT NULL, `srp_modulus_id` TEXT NOT NULL, `password` TEXT NOT NULL, `share_passphrase_key_packet` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "tableName": "AutoLockDurationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `duration` INTEGER NOT NULL, PRIMARY KEY(`key`))", "fields": [ { - "fieldPath": "id", - "columnName": "id", + "fieldPath": "key", + "columnName": "key", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "flags", - "columnName": "flags", + "fieldPath": "durationInSeconds", + "columnName": "duration", "affinity": "INTEGER", "notNull": true - }, + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EnableAppLockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `last_access_time` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "token", - "columnName": "token", + "fieldPath": "key", + "columnName": "key", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "creatorEmail", - "columnName": "creatior_email", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "permissions", - "columnName": "permissions", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "creationTime", - "columnName": "creation_time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expirationTime", - "columnName": "expiration_time", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastAccessTime", + "fieldPath": "timestamp", "columnName": "last_access_time", "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "maxAccesses", - "columnName": "max_accesses", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "numberOfAccesses", - "columnName": "number_of_accesses", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "urlPasswordSalt", - "columnName": "url_password_salt", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sharePasswordSalt", - "columnName": "share_password_salt", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "srpVerifier", - "columnName": "srp_verifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "srpModulusId", - "columnName": "srp_modulus_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "password", - "columnName": "password", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sharePassphraseKeyPacket", - "columnName": "share_passphrase_key_packet", - "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ - "user_id", - "share_id", - "id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_ShareUrlEntity_user_id_share_id", - "unique": false, - "columnNames": [ - "user_id", - "share_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" - } - ], - "foreignKeys": [ - { - "table": "ShareEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id" - ], - "referencedColumns": [ - "user_id", - "id" - ] - } - ] - }, - { - "tableName": "LinkEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `parent_id` TEXT, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_signature_email` TEXT, `hash` TEXT NOT NULL, `state` INTEGER NOT NULL, `expiration_time` INTEGER, `size` INTEGER NOT NULL, `mime_type` TEXT NOT NULL, `attributes` INTEGER NOT NULL, `permissions` INTEGER NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `signature_address` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `trashed_time` INTEGER, `is_shared` INTEGER NOT NULL, `number_of_accesses` INTEGER NOT NULL, `share_url_expiration_time` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentId", - "columnName": "parent_id", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "nameSignatureEmail", - "columnName": "name_signature_email", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "hash", - "columnName": "hash", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expirationTime", - "columnName": "expiration_time", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mime_type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "attributes", - "columnName": "attributes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "permissions", - "columnName": "permissions", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nodeKey", - "columnName": "node_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "nodePassphrase", - "columnName": "node_passphrase", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "nodePassphraseSignature", - "columnName": "node_passphrase_signature", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "signatureAddress", - "columnName": "signature_address", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "creationTime", - "columnName": "creation_time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "last_modified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashedTime", - "columnName": "trashed_time", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "shared", - "columnName": "is_shared", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "numberOfAccesses", - "columnName": "number_of_accesses", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "shareUrlExpirationTime", - "columnName": "share_url_expiration_time", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "share_id", - "id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_LinkEntity_user_id_share_id", - "unique": false, - "columnNames": [ - "user_id", - "share_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" - }, - { - "name": "index_LinkEntity_user_id_id", - "unique": false, - "columnNames": [ - "user_id", - "id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_id` ON `${TABLE_NAME}` (`user_id`, `id`)" - }, - { - "name": "index_LinkEntity_user_id_share_id_parent_id", - "unique": false, - "columnNames": [ - "user_id", - "share_id", - "parent_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" - } - ], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id" - ], - "referencedColumns": [ - "userId" - ] - }, - { - "table": "ShareEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id" - ], - "referencedColumns": [ - "user_id", - "id" - ] - }, - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id", - "parent_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "LinkFilePropertiesEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`file_user_id` TEXT NOT NULL, `file_share_id` TEXT NOT NULL, `file_link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT, `file_signature_address` TEXT, PRIMARY KEY(`file_user_id`, `file_share_id`, `file_link_id`), FOREIGN KEY(`file_user_id`, `file_share_id`, `file_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "file_user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "file_share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "file_link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "activeRevisionId", - "columnName": "revision_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "hasThumbnail", - "columnName": "has_thumbnail", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "contentKeyPacket", - "columnName": "content_key_packet", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "contentKeyPacketSignature", - "columnName": "content_key_packet_signature", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "activeRevisionSignatureAddress", - "columnName": "file_signature_address", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "file_user_id", - "file_share_id", - "file_link_id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id", - "unique": false, - "columnNames": [ - "file_user_id", - "file_share_id", - "file_link_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id` ON `${TABLE_NAME}` (`file_user_id`, `file_share_id`, `file_link_id`)" - }, - { - "name": "index_LinkFilePropertiesEntity_revision_id", - "unique": false, - "columnNames": [ - "revision_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" - } - ], - "foreignKeys": [ - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "file_user_id", - "file_share_id", - "file_link_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "LinkFolderPropertiesEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`folder_user_id` TEXT NOT NULL, `folder_share_id` TEXT NOT NULL, `folder_link_id` TEXT NOT NULL, `node_hash_key` TEXT NOT NULL, PRIMARY KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`), FOREIGN KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "folder_user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "folder_share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "folder_link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "nodeHashKey", - "columnName": "node_hash_key", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "folder_user_id", - "folder_share_id", - "folder_link_id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id", - "unique": false, - "columnNames": [ - "folder_user_id", - "folder_share_id", - "folder_link_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id` ON `${TABLE_NAME}` (`folder_user_id`, `folder_share_id`, `folder_link_id`)" - } - ], - "foreignKeys": [ - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "folder_user_id", - "folder_share_id", - "folder_link_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "LinkOfflineEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_LinkOfflineEntity_user_id_share_id_link_id", - "unique": true, - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" - } - ], - "foreignKeys": [ - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id", - "link_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "LinkDownloadStateEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "revisionId", - "columnName": "revision_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "share_id", - "link_id", - "revision_id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_LinkDownloadStateEntity_user_id_share_id_link_id", - "unique": false, - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" - } - ], - "foreignKeys": [ - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id", - "link_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "DownloadBlockEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `index` INTEGER NOT NULL, `uri` TEXT NOT NULL, `encrypted_signature` TEXT, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`, `index`), FOREIGN KEY(`user_id`, `share_id`, `link_id`, `revision_id`) REFERENCES `LinkDownloadStateEntity`(`user_id`, `share_id`, `link_id`, `revision_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "revisionId", - "columnName": "revision_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "index", - "columnName": "index", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "encryptedSignature", - "columnName": "encrypted_signature", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "share_id", - "link_id", - "revision_id", - "index" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_DownloadBlockEntity_user_id_share_id_link_id_revision_id", - "unique": false, - "columnNames": [ - "user_id", - "share_id", - "link_id", - "revision_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id_share_id_link_id_revision_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`, `revision_id`)" - } - ], - "foreignKeys": [ - { - "table": "LinkDownloadStateEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id", - "link_id", - "revision_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "link_id", - "revision_id" - ] - } - ] - }, - { - "tableName": "LinkTrashStateEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_LinkTrashStateEntity_user_id_share_id_link_id", - "unique": false, - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" - } - ], - "foreignKeys": [ - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id", - "link_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "TrashWorkEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `work_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "workId", - "columnName": "work_id", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_TrashWorkEntity_user_id_share_id_link_id", - "unique": false, - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" - }, - { - "name": "index_TrashWorkEntity_work_id", - "unique": false, - "columnNames": [ - "work_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_work_id` ON `${TABLE_NAME}` (`work_id`)" - } - ], - "foreignKeys": [ - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id", - "link_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "MessageEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `content` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "content", - "columnName": "content", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_MessageEntity_user_id", - "unique": false, - "columnNames": [ - "user_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" - } - ], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "UiSettingsEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `layout_type` TEXT NOT NULL, `theme_style` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "layoutType", - "columnName": "layout_type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "themeStyle", - "columnName": "theme_style", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "user_id" + "key" ], "autoGenerate": false }, "indices": [], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "DriveLinkRemoteKeyEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `previous_key` INTEGER, `next_key` INTEGER, PRIMARY KEY(`key`, `user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "prevKey", - "columnName": "previous_key", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "nextKey", - "columnName": "next_key", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "key", - "user_id", - "share_id", - "link_id" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id", - "unique": true, - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" - }, - { - "name": "index_DriveLinkRemoteKeyEntity_user_id", - "unique": false, - "columnNames": [ - "user_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" - } - ], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id" - ], - "referencedColumns": [ - "userId" - ] - }, - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id", - "link_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "SortingEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `sorting_by` TEXT NOT NULL, `sorting_direction` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sortingBy", - "columnName": "sorting_by", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sortingDirection", - "columnName": "sorting_direction", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "user_id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "LinkUploadEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT NOT NULL, `manifest_signature` TEXT NOT NULL, `state` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "volumeId", - "columnName": "volume_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentId", - "columnName": "parent_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "revisionId", - "columnName": "revision_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mime_type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "nodeKey", - "columnName": "node_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "nodePassphrase", - "columnName": "node_passphrase", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "nodePassphraseSignature", - "columnName": "node_passphrase_signature", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "contentKeyPacket", - "columnName": "content_key_packet", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "contentKeyPacketSignature", - "columnName": "content_key_packet_signature", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "manifestSignature", - "columnName": "manifest_signature", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_LinkUploadEntity_user_id", - "unique": false, - "columnNames": [ - "user_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" - }, - { - "name": "index_LinkUploadEntity_volume_id", - "unique": false, - "columnNames": [ - "volume_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" - }, - { - "name": "index_LinkUploadEntity_share_id", - "unique": false, - "columnNames": [ - "share_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" - }, - { - "name": "index_LinkUploadEntity_link_id", - "unique": false, - "columnNames": [ - "link_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" - }, - { - "name": "index_LinkUploadEntity_revision_id", - "unique": false, - "columnNames": [ - "revision_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" - }, - { - "name": "index_LinkUploadEntity_parent_id", - "unique": false, - "columnNames": [ - "parent_id" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" - } - ], - "foreignKeys": [ - { - "table": "AccountEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id" - ], - "referencedColumns": [ - "userId" - ] - } - ] - }, - { - "tableName": "UploadBlockEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_link_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `size` INTEGER NOT NULL, `encrypted_signature` TEXT NOT NULL, `hash` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`upload_link_id`, `index`), FOREIGN KEY(`upload_link_id`) REFERENCES `LinkUploadEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "uploadLinkId", - "columnName": "upload_link_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "index", - "columnName": "index", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "encryptedSignature", - "columnName": "encrypted_signature", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "hash", - "columnName": "hash", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uploadToken", - "columnName": "token", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "upload_link_id", - "index" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "LinkUploadEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "upload_link_id" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "FolderMetadataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `last_fetch_children_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "linkId", - "columnName": "link_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastFetchChildrenTimestamp", - "columnName": "last_fetch_children_timestamp", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "share_id", - "link_id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "LinkEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id", - "link_id" - ], - "referencedColumns": [ - "user_id", - "share_id", - "id" - ] - } - ] - }, - { - "tableName": "TrashMetadataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `last_fetch_trash_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "shareId", - "columnName": "share_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastFetchTrashTimestamp", - "columnName": "last_fetch_trash_timestamp", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "user_id", - "share_id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "ShareEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "user_id", - "share_id" - ], - "referencedColumns": [ - "user_id", - "id" - ] - } - ] + "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1848c5aa24ef749bea72f819f081cfe5')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be4e11f65691008e811c02bbbb906104')" ] } } \ No newline at end of file diff --git a/app/src/dynamic/AndroidManifest.xml b/app/src/dynamic/AndroidManifest.xml new file mode 100644 index 00000000..15a9aa08 --- /dev/null +++ b/app/src/dynamic/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e449c412..ee360a1d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -126,6 +126,17 @@ android:authorities="${applicationId}.androidx-startup" android:exported="false" tools:node="merge"> + + + @@ -136,9 +147,18 @@ android:name="androidx.work.WorkManagerInitializer" android:value="androidx.startup" tools:node="remove" /> + + android:value="androidx.startup" + tools:node="remove" /> + @@ -147,7 +167,16 @@ android:value="androidx.startup" /> + android:value="androidx.startup" + tools:node="remove" /> + + diff --git a/app/src/main/kotlin/me/proton/android/drive/App.kt b/app/src/main/kotlin/me/proton/android/drive/App.kt index 02fa8345..b5485e41 100644 --- a/app/src/main/kotlin/me/proton/android/drive/App.kt +++ b/app/src/main/kotlin/me/proton/android/drive/App.kt @@ -20,6 +20,12 @@ package me.proton.android.drive import android.app.Application import dagger.hilt.android.HiltAndroidApp +import me.proton.android.drive.initializer.MainInitializer @HiltAndroidApp -class App : Application() +class App : Application() { + override fun onCreate() { + super.onCreate() + MainInitializer.init(this) + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/db/AppDatabase.kt b/app/src/main/kotlin/me/proton/android/drive/db/AppDatabase.kt index 35e5880c..a697010d 100644 --- a/app/src/main/kotlin/me/proton/android/drive/db/AppDatabase.kt +++ b/app/src/main/kotlin/me/proton/android/drive/db/AppDatabase.kt @@ -19,247 +19,33 @@ package me.proton.android.drive.db import android.content.Context -import androidx.room.AutoMigration import androidx.room.Database -import androidx.room.TypeConverters -import me.proton.core.account.data.db.AccountConverters -import me.proton.core.account.data.db.AccountDatabase -import me.proton.core.account.data.entity.AccountEntity -import me.proton.core.account.data.entity.AccountMetadataEntity -import me.proton.core.account.data.entity.SessionDetailsEntity -import me.proton.core.account.data.entity.SessionEntity -import me.proton.core.challenge.data.db.ChallengeConverters -import me.proton.core.challenge.data.db.ChallengeDatabase -import me.proton.core.challenge.data.entity.ChallengeFrameEntity -import me.proton.core.crypto.android.keystore.CryptoConverters +import androidx.room.migration.Migration +import me.proton.android.drive.lock.data.db.AppLockDatabase +import me.proton.android.drive.lock.data.db.entity.AppLockEntity +import me.proton.android.drive.lock.data.db.entity.AutoLockDurationEntity +import me.proton.android.drive.lock.data.db.entity.EnableAppLockEntity +import me.proton.android.drive.lock.data.db.entity.LockEntity import me.proton.core.data.room.db.BaseDatabase -import me.proton.core.data.room.db.CommonConverters -import me.proton.core.drive.drivelink.data.db.DriveLinkDatabase -import me.proton.core.drive.drivelink.download.data.db.DriveLinkDownloadDatabase -import me.proton.core.drive.drivelink.offline.data.db.DriveLinkOfflineDatabase -import me.proton.core.drive.drivelink.paged.data.db.DriveLinkPagedDatabase -import me.proton.core.drive.drivelink.paged.data.db.entity.DriveLinkRemoteKeyEntity -import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase -import me.proton.core.drive.drivelink.shared.data.db.DriveLinkSharedDatabase -import me.proton.core.drive.drivelink.trash.data.db.DriveLinkTrashDatabase -import me.proton.core.drive.folder.data.db.FolderDatabase -import me.proton.core.drive.folder.data.db.FolderMetadataEntity -import me.proton.core.drive.link.data.db.LinkDatabase -import me.proton.core.drive.link.data.db.entity.LinkEntity -import me.proton.core.drive.link.data.db.entity.LinkFilePropertiesEntity -import me.proton.core.drive.link.data.db.entity.LinkFolderPropertiesEntity -import me.proton.core.drive.link.selection.data.db.LinkSelectionConverters -import me.proton.core.drive.link.selection.data.db.LinkSelectionDatabase -import me.proton.core.drive.link.selection.data.db.entity.LinkSelectionEntity -import me.proton.core.drive.linkdownload.data.db.LinkDownloadDatabase -import me.proton.core.drive.linkdownload.data.db.entity.DownloadBlockEntity -import me.proton.core.drive.linkdownload.data.db.entity.LinkDownloadStateEntity -import me.proton.core.drive.linknode.data.db.LinkAncestorDatabase -import me.proton.core.drive.linkoffline.data.db.LinkOfflineDatabase -import me.proton.core.drive.linkoffline.data.db.LinkOfflineEntity -import me.proton.core.drive.linktrash.data.db.LinkTrashDatabase -import me.proton.core.drive.linktrash.data.db.entity.LinkTrashStateEntity -import me.proton.core.drive.linktrash.data.db.entity.TrashMetadataEntity -import me.proton.core.drive.linktrash.data.db.entity.TrashWorkEntity -import me.proton.core.drive.linkupload.data.db.LinkUploadDatabase -import me.proton.core.drive.linkupload.data.db.entity.LinkUploadEntity -import me.proton.core.drive.linkupload.data.db.entity.UploadBlockEntity -import me.proton.core.drive.linkupload.data.db.entity.UploadBulkEntity -import me.proton.core.drive.linkupload.data.db.entity.UploadBulkUriStringEntity -import me.proton.core.drive.messagequeue.data.storage.db.MessageQueueDatabase -import me.proton.core.drive.messagequeue.data.storage.db.entity.MessageEntity -import me.proton.core.drive.notification.data.db.NotificationConverters -import me.proton.core.drive.notification.data.db.NotificationDatabase -import me.proton.core.drive.notification.data.db.entity.NotificationChannelEntity -import me.proton.core.drive.notification.data.db.entity.NotificationEventEntity -import me.proton.core.drive.share.data.db.ShareDatabase -import me.proton.core.drive.share.data.db.ShareEntity -import me.proton.core.drive.shareurl.base.data.db.ShareUrlDatabase -import me.proton.core.drive.shareurl.base.data.db.entity.ShareUrlEntity -import me.proton.core.drive.sorting.data.db.SortingDatabase -import me.proton.core.drive.sorting.data.db.entity.SortingEntity -import me.proton.core.drive.volume.data.db.VolumeDatabase -import me.proton.core.drive.volume.data.db.VolumeEntity -import me.proton.core.eventmanager.data.db.EventManagerConverters -import me.proton.core.eventmanager.data.db.EventMetadataDatabase -import me.proton.core.eventmanager.data.entity.EventMetadataEntity -import me.proton.core.featureflag.data.db.FeatureFlagDatabase -import me.proton.core.featureflag.data.entity.FeatureFlagEntity -import me.proton.core.humanverification.data.db.HumanVerificationConverters -import me.proton.core.humanverification.data.db.HumanVerificationDatabase -import me.proton.core.humanverification.data.entity.HumanVerificationEntity -import me.proton.core.key.data.db.KeySaltDatabase -import me.proton.core.key.data.db.PublicAddressDatabase -import me.proton.core.key.data.entity.KeySaltEntity -import me.proton.core.key.data.entity.PublicAddressEntity -import me.proton.core.key.data.entity.PublicAddressKeyEntity -import me.proton.core.payment.data.local.db.PaymentDatabase -import me.proton.core.payment.data.local.entity.GooglePurchaseEntity -import me.proton.core.user.data.db.AddressDatabase -import me.proton.core.user.data.db.UserConverters -import me.proton.core.user.data.db.UserDatabase -import me.proton.core.user.data.entity.AddressEntity -import me.proton.core.user.data.entity.AddressKeyEntity -import me.proton.core.user.data.entity.UserEntity -import me.proton.core.user.data.entity.UserKeyEntity -import me.proton.core.usersettings.data.db.OrganizationDatabase -import me.proton.core.usersettings.data.db.UserSettingsConverters -import me.proton.core.usersettings.data.db.UserSettingsDatabase -import me.proton.core.usersettings.data.entity.OrganizationEntity -import me.proton.core.usersettings.data.entity.OrganizationKeysEntity -import me.proton.core.usersettings.data.entity.UserSettingsEntity -import me.proton.drive.android.settings.data.db.AppUiSettingsDatabase -import me.proton.drive.android.settings.data.db.entity.UiSettingsEntity @Database( entities = [ - // Core - AccountEntity::class, - AccountMetadataEntity::class, - SessionEntity::class, - SessionDetailsEntity::class, - UserEntity::class, - UserKeyEntity::class, - AddressEntity::class, - AddressKeyEntity::class, - KeySaltEntity::class, - PublicAddressEntity::class, - PublicAddressKeyEntity::class, - HumanVerificationEntity::class, - UserSettingsEntity::class, - OrganizationEntity::class, - OrganizationKeysEntity::class, - EventMetadataEntity::class, - FeatureFlagEntity::class, - ChallengeFrameEntity::class, - GooglePurchaseEntity::class, - // Drive - VolumeEntity::class, - ShareEntity::class, - ShareUrlEntity::class, - LinkEntity::class, - LinkFilePropertiesEntity::class, - LinkFolderPropertiesEntity::class, - LinkOfflineEntity::class, - LinkDownloadStateEntity::class, - DownloadBlockEntity::class, - LinkTrashStateEntity::class, - // Trash - TrashWorkEntity::class, - // MessageQueue - MessageEntity::class, - // AppUiSettings - UiSettingsEntity::class, - // DriveLinkPaged - DriveLinkRemoteKeyEntity::class, - // Sorting - SortingEntity::class, - // Upload - LinkUploadEntity::class, - UploadBlockEntity::class, - UploadBulkEntity::class, - UploadBulkUriStringEntity::class, - FolderMetadataEntity::class, - TrashMetadataEntity::class, - // Notification - NotificationChannelEntity::class, - NotificationEventEntity::class, - // Selection - LinkSelectionEntity::class, + // AppLock + AppLockEntity::class, + LockEntity::class, + AutoLockDurationEntity::class, + EnableAppLockEntity::class, ], version = AppDatabase.VERSION, - autoMigrations = [ - AutoMigration(from = 4, to = 5), - AutoMigration(from = 5, to = 6), - AutoMigration(from = 7, to = 8), - AutoMigration(from = 9, to = 10), - AutoMigration(from = 13, to = 14), - AutoMigration(from = 15, to = 16), - AutoMigration(from = 16, to = 17), - AutoMigration(from = 17, to = 18, spec = ShareDatabase.DeleteBlockSizeFromShareEntity::class), - AutoMigration(from = 18, to = 19), - ], - exportSchema = true, ) -@TypeConverters( - // Core - CommonConverters::class, - AccountConverters::class, - UserConverters::class, - CryptoConverters::class, - HumanVerificationConverters::class, - UserSettingsConverters::class, - EventManagerConverters::class, - ChallengeConverters::class, - // Drive - NotificationConverters::class, - LinkSelectionConverters::class, -) -abstract class AppDatabase : - BaseDatabase(), - AccountDatabase, - UserDatabase, - AddressDatabase, - KeySaltDatabase, - HumanVerificationDatabase, - PublicAddressDatabase, - UserSettingsDatabase, - OrganizationDatabase, - FeatureFlagDatabase, - VolumeDatabase, - ShareDatabase, - ShareUrlDatabase, - LinkDatabase, - FolderDatabase, - LinkAncestorDatabase, - LinkOfflineDatabase, - LinkDownloadDatabase, - LinkTrashDatabase, - LinkSelectionDatabase, - MessageQueueDatabase, - AppUiSettingsDatabase, - EventMetadataDatabase, - ChallengeDatabase, - SortingDatabase, - LinkUploadDatabase, - DriveLinkDatabase, - DriveLinkPagedDatabase, - DriveLinkTrashDatabase, - DriveLinkOfflineDatabase, - DriveLinkDownloadDatabase, - DriveLinkSharedDatabase, - DriveLinkSelectionDatabase, - NotificationDatabase, - PaymentDatabase { +abstract class AppDatabase : BaseDatabase(), AppLockDatabase { companion object { - const val VERSION = 21 - - private val migrations = listOf( - AppDatabaseMigrations.MIGRATION_1_2, - AppDatabaseMigrations.MIGRATION_2_3, - AppDatabaseMigrations.MIGRATION_3_4, - //AutoMigration(from = 4, to = 5) - //AutoMigration(from = 5, to = 6) - AppDatabaseMigrations.MIGRATION_6_7, - //AutoMigration(from = 7, to = 8) - AppDatabaseMigrations.MIGRATION_8_9, - //AutoMigration(from = 9, to = 10) - AppDatabaseMigrations.MIGRATION_10_11, - AppDatabaseMigrations.MIGRATION_11_12, - AppDatabaseMigrations.MIGRATION_12_13, - //AutoMigration(from = 13, to = 14) - AppDatabaseMigrations.MIGRATION_14_15, - //AutoMigration(from = 15, to = 16) - //AutoMigration(from = 16, to = 17) - //AutoMigration(from = 17, to = 18) - //AutoMigration(from = 18, to = 19) - AppDatabaseMigrations.MIGRATION_19_20, - AppDatabaseMigrations.MIGRATION_20_21, - ) + const val VERSION = 1 + private val migrations = listOf() fun buildDatabase(context: Context): AppDatabase = - databaseBuilder(context, "db-drive") + databaseBuilder(context, "db-app") .apply { migrations.forEach { addMigrations(it) } } .build() } diff --git a/app/src/main/kotlin/me/proton/android/drive/di/AppDatabaseModule.kt b/app/src/main/kotlin/me/proton/android/drive/di/AppDatabaseModule.kt index bd1d5fa6..c4371a3e 100644 --- a/app/src/main/kotlin/me/proton/android/drive/di/AppDatabaseModule.kt +++ b/app/src/main/kotlin/me/proton/android/drive/di/AppDatabaseModule.kt @@ -26,154 +26,23 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import me.proton.android.drive.db.AppDatabase -import me.proton.core.account.data.db.AccountDatabase -import me.proton.core.challenge.data.db.ChallengeDatabase -import me.proton.core.drive.drivelink.data.db.DriveLinkDatabase -import me.proton.core.drive.drivelink.download.data.db.DriveLinkDownloadDatabase -import me.proton.core.drive.drivelink.offline.data.db.DriveLinkOfflineDatabase -import me.proton.core.drive.drivelink.paged.data.db.DriveLinkPagedDatabase -import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase -import me.proton.core.drive.drivelink.shared.data.db.DriveLinkSharedDatabase -import me.proton.core.drive.drivelink.trash.data.db.DriveLinkTrashDatabase -import me.proton.core.drive.folder.data.db.FolderDatabase -import me.proton.core.drive.link.data.db.LinkDatabase -import me.proton.core.drive.link.selection.data.db.LinkSelectionDatabase -import me.proton.core.drive.linkdownload.data.db.LinkDownloadDatabase -import me.proton.core.drive.linknode.data.db.LinkAncestorDatabase -import me.proton.core.drive.linkoffline.data.db.LinkOfflineDatabase -import me.proton.core.drive.linktrash.data.db.LinkTrashDatabase -import me.proton.core.drive.linkupload.data.db.LinkUploadDatabase -import me.proton.core.drive.messagequeue.data.storage.db.MessageQueueDatabase -import me.proton.core.drive.notification.data.db.NotificationDatabase -import me.proton.core.drive.share.data.db.ShareDatabase -import me.proton.core.drive.shareurl.base.data.db.ShareUrlDatabase -import me.proton.core.drive.sorting.data.db.SortingDatabase -import me.proton.core.drive.volume.data.db.VolumeDatabase -import me.proton.core.eventmanager.data.db.EventMetadataDatabase -import me.proton.core.featureflag.data.db.FeatureFlagDatabase -import me.proton.core.humanverification.data.db.HumanVerificationDatabase -import me.proton.core.key.data.db.KeySaltDatabase -import me.proton.core.key.data.db.PublicAddressDatabase -import me.proton.core.payment.data.local.db.PaymentDatabase -import me.proton.core.user.data.db.AddressDatabase -import me.proton.core.user.data.db.UserDatabase -import me.proton.core.usersettings.data.db.OrganizationDatabase -import me.proton.core.usersettings.data.db.UserSettingsDatabase -import me.proton.drive.android.settings.data.db.AppUiSettingsDatabase +import me.proton.android.drive.lock.data.db.AppLockDatabase import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppDatabaseModule { + @Provides @Singleton fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase = AppDatabase.buildDatabase(context) -} +} @Module @InstallIn(SingletonComponent::class) abstract class AppDatabaseBindsModule { @Binds - abstract fun provideVolumeDatabase(db: AppDatabase): VolumeDatabase - - @Binds - abstract fun provideShareDatabase(db: AppDatabase): ShareDatabase - - @Binds - abstract fun provideShareUrlDatabase(db: AppDatabase): ShareUrlDatabase - - @Binds - abstract fun provideLinkDatabase(db: AppDatabase): LinkDatabase - - @Binds - abstract fun provideFolderDatabase(db: AppDatabase): FolderDatabase - - @Binds - abstract fun provideLinkAncestorDatabase(db: AppDatabase): LinkAncestorDatabase - - @Binds - abstract fun provideLinkOfflineDatabase(db: AppDatabase): LinkOfflineDatabase - - @Binds - abstract fun provideLinkDownloadDatabase(db: AppDatabase): LinkDownloadDatabase - - @Binds - abstract fun provideLinkTrashDatabase(db: AppDatabase): LinkTrashDatabase - - @Binds - abstract fun provideLinkSelectionDatabase(db: AppDatabase): LinkSelectionDatabase - - @Binds - abstract fun provideMessageQueueDatabase(db: AppDatabase): MessageQueueDatabase - - @Binds - abstract fun provideSortingDatabase(db: AppDatabase): SortingDatabase - - @Binds - abstract fun provideLinkUploadDatabase(db: AppDatabase): LinkUploadDatabase - - @Binds - abstract fun provideAccountDatabase(db: AppDatabase): AccountDatabase - - @Binds - abstract fun provideUserDatabase(db: AppDatabase): UserDatabase - - @Binds - abstract fun provideAddressDatabase(db: AppDatabase): AddressDatabase - - @Binds - abstract fun provideFeatureFlagDatabase(db: AppDatabase): FeatureFlagDatabase - - @Binds - abstract fun provideKeySaltDatabase(db: AppDatabase): KeySaltDatabase - - @Binds - abstract fun providePublicAddressDatabase(db: AppDatabase): PublicAddressDatabase - - @Binds - abstract fun provideHumanVerificationDatabase(db: AppDatabase): HumanVerificationDatabase - - @Binds - abstract fun provideUserSettingsDatabase(db: AppDatabase): UserSettingsDatabase - - @Binds - abstract fun provideOrganizationDatabase(db: AppDatabase): OrganizationDatabase - - @Binds - abstract fun provideAppUiSettingsDatabase(db: AppDatabase): AppUiSettingsDatabase - - @Binds - abstract fun provideEventMetadataDatabase(db: AppDatabase): EventMetadataDatabase - - @Binds - abstract fun provideChallengeDatabase(appDatabase: AppDatabase): ChallengeDatabase - - @Binds - abstract fun provideDriveLinkDatabase(db: AppDatabase): DriveLinkDatabase - - @Binds - abstract fun provideDriveLinkPagedDatabase(db: AppDatabase): DriveLinkPagedDatabase - - @Binds - abstract fun provideDriveLinkTrashDatabase(db: AppDatabase): DriveLinkTrashDatabase - - @Binds - abstract fun provideDriveLinkOfflineDatabase(db: AppDatabase): DriveLinkOfflineDatabase - - @Binds - abstract fun provideDriveLinkDownloadDatabase(db: AppDatabase): DriveLinkDownloadDatabase - - @Binds - abstract fun provideDriveLinkSharedDatabase(db: AppDatabase): DriveLinkSharedDatabase - - @Binds - abstract fun provideDriveLinkSelectionDatabase(db: AppDatabase): DriveLinkSelectionDatabase - - @Binds - abstract fun provideNotificationDatabase(db: AppDatabase): NotificationDatabase - - @Binds - abstract fun providePaymentDatabase(db: AppDatabase): PaymentDatabase + abstract fun provideAppLockDatabase(db: AppDatabase): AppLockDatabase } diff --git a/app/src/main/kotlin/me/proton/android/drive/di/ApplicationModule.kt b/app/src/main/kotlin/me/proton/android/drive/di/ApplicationModule.kt index 3f2b2674..a21aa4dd 100644 --- a/app/src/main/kotlin/me/proton/android/drive/di/ApplicationModule.kt +++ b/app/src/main/kotlin/me/proton/android/drive/di/ApplicationModule.kt @@ -18,6 +18,7 @@ package me.proton.android.drive.di +import android.app.ActivityManager import android.content.ClipboardManager import android.content.Context import androidx.core.app.NotificationManagerCompat @@ -29,15 +30,19 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import me.proton.android.drive.BuildConfig +import me.proton.android.drive.lock.data.usecase.BuildAppKeyImpl +import me.proton.android.drive.lock.domain.usecase.BuildAppKey import me.proton.android.drive.log.DriveLogger import me.proton.android.drive.notification.AppNotificationBuilderProvider import me.proton.android.drive.notification.AppNotificationEventHandler import me.proton.android.drive.provider.BuildConfigurationProvider import me.proton.android.drive.settings.DebugSettings +import me.proton.android.drive.usecase.GetDocumentsProviderRootsImpl import me.proton.core.account.domain.entity.AccountType import me.proton.core.domain.entity.AppStore import me.proton.core.domain.entity.Product import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.documentsprovider.domain.usecase.GetDocumentsProviderRoots import me.proton.core.drive.notification.data.provider.NotificationBuilderProvider import me.proton.core.drive.notification.domain.handler.NotificationEventHandler import me.proton.drive.android.settings.data.datastore.AppUiSettingsDataStore @@ -79,7 +84,7 @@ object ApplicationModule { @Provides @Singleton - fun provideDriveLogger(): DriveLogger = DriveLogger() + fun provideDriveLogger(@ApplicationContext context: Context): DriveLogger = DriveLogger(context) @Provides @Singleton @@ -105,6 +110,11 @@ object ApplicationModule { @Singleton fun provideClipboardManager(@ApplicationContext context: Context): ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + @Provides + @Singleton + fun provideActivityManager(@ApplicationContext context: Context): ActivityManager = + context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager } @Module @@ -119,4 +129,8 @@ abstract class ApplicationBindsModule { abstract fun bindsNotificationBuilderProvider( impl: AppNotificationBuilderProvider ): NotificationBuilderProvider + + @Binds + @Singleton + abstract fun bindsGetDocumentsProviderRootsImpl(impl: GetDocumentsProviderRootsImpl): GetDocumentsProviderRoots } diff --git a/app/src/main/kotlin/me/proton/android/drive/di/DriveDatabaseModule.kt b/app/src/main/kotlin/me/proton/android/drive/di/DriveDatabaseModule.kt new file mode 100644 index 00000000..c306c64d --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/di/DriveDatabaseModule.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.di + +import android.content.Context +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import me.proton.android.drive.db.DriveDatabase +import me.proton.core.account.data.db.AccountDatabase +import me.proton.core.challenge.data.db.ChallengeDatabase +import me.proton.core.drive.drivelink.data.db.DriveLinkDatabase +import me.proton.core.drive.drivelink.download.data.db.DriveLinkDownloadDatabase +import me.proton.core.drive.drivelink.offline.data.db.DriveLinkOfflineDatabase +import me.proton.core.drive.drivelink.paged.data.db.DriveLinkPagedDatabase +import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase +import me.proton.core.drive.drivelink.shared.data.db.DriveLinkSharedDatabase +import me.proton.core.drive.drivelink.trash.data.db.DriveLinkTrashDatabase +import me.proton.core.drive.folder.data.db.FolderDatabase +import me.proton.core.drive.link.data.db.LinkDatabase +import me.proton.core.drive.link.selection.data.db.LinkSelectionDatabase +import me.proton.core.drive.linkdownload.data.db.LinkDownloadDatabase +import me.proton.core.drive.linknode.data.db.LinkAncestorDatabase +import me.proton.core.drive.linkoffline.data.db.LinkOfflineDatabase +import me.proton.core.drive.linktrash.data.db.LinkTrashDatabase +import me.proton.core.drive.linkupload.data.db.LinkUploadDatabase +import me.proton.core.drive.messagequeue.data.storage.db.MessageQueueDatabase +import me.proton.core.drive.notification.data.db.NotificationDatabase +import me.proton.core.drive.share.data.db.ShareDatabase +import me.proton.core.drive.shareurl.base.data.db.ShareUrlDatabase +import me.proton.core.drive.sorting.data.db.SortingDatabase +import me.proton.core.drive.volume.data.db.VolumeDatabase +import me.proton.core.eventmanager.data.db.EventMetadataDatabase +import me.proton.core.featureflag.data.db.FeatureFlagDatabase +import me.proton.core.humanverification.data.db.HumanVerificationDatabase +import me.proton.core.key.data.db.KeySaltDatabase +import me.proton.core.key.data.db.PublicAddressDatabase +import me.proton.core.observability.data.db.ObservabilityDatabase +import me.proton.core.payment.data.local.db.PaymentDatabase +import me.proton.core.user.data.db.AddressDatabase +import me.proton.core.user.data.db.UserDatabase +import me.proton.core.usersettings.data.db.OrganizationDatabase +import me.proton.core.usersettings.data.db.UserSettingsDatabase +import me.proton.drive.android.settings.data.db.AppUiSettingsDatabase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DriveDatabaseModule { + @Provides + @Singleton + fun provideDriveDatabase(@ApplicationContext context: Context): DriveDatabase = + DriveDatabase.buildDatabase(context) +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class DriveDatabaseBindsModule { + + @Binds + abstract fun provideVolumeDatabase(db: DriveDatabase): VolumeDatabase + + @Binds + abstract fun provideShareDatabase(db: DriveDatabase): ShareDatabase + + @Binds + abstract fun provideShareUrlDatabase(db: DriveDatabase): ShareUrlDatabase + + @Binds + abstract fun provideLinkDatabase(db: DriveDatabase): LinkDatabase + + @Binds + abstract fun provideFolderDatabase(db: DriveDatabase): FolderDatabase + + @Binds + abstract fun provideLinkAncestorDatabase(db: DriveDatabase): LinkAncestorDatabase + + @Binds + abstract fun provideLinkOfflineDatabase(db: DriveDatabase): LinkOfflineDatabase + + @Binds + abstract fun provideLinkDownloadDatabase(db: DriveDatabase): LinkDownloadDatabase + + @Binds + abstract fun provideLinkTrashDatabase(db: DriveDatabase): LinkTrashDatabase + + @Binds + abstract fun provideLinkSelectionDatabase(db: DriveDatabase): LinkSelectionDatabase + + @Binds + abstract fun provideMessageQueueDatabase(db: DriveDatabase): MessageQueueDatabase + + @Binds + abstract fun provideSortingDatabase(db: DriveDatabase): SortingDatabase + + @Binds + abstract fun provideLinkUploadDatabase(db: DriveDatabase): LinkUploadDatabase + + @Binds + abstract fun provideAccountDatabase(db: DriveDatabase): AccountDatabase + + @Binds + abstract fun provideUserDatabase(db: DriveDatabase): UserDatabase + + @Binds + abstract fun provideAddressDatabase(db: DriveDatabase): AddressDatabase + + @Binds + abstract fun provideFeatureFlagDatabase(db: DriveDatabase): FeatureFlagDatabase + + @Binds + abstract fun provideKeySaltDatabase(db: DriveDatabase): KeySaltDatabase + + @Binds + abstract fun providePublicAddressDatabase(db: DriveDatabase): PublicAddressDatabase + + @Binds + abstract fun provideHumanVerificationDatabase(db: DriveDatabase): HumanVerificationDatabase + + @Binds + abstract fun provideUserSettingsDatabase(db: DriveDatabase): UserSettingsDatabase + + @Binds + abstract fun provideOrganizationDatabase(db: DriveDatabase): OrganizationDatabase + + @Binds + abstract fun provideAppUiSettingsDatabase(db: DriveDatabase): AppUiSettingsDatabase + + @Binds + abstract fun provideEventMetadataDatabase(db: DriveDatabase): EventMetadataDatabase + + @Binds + abstract fun provideChallengeDatabase(driveDatabase: DriveDatabase): ChallengeDatabase + + @Binds + abstract fun provideDriveLinkDatabase(db: DriveDatabase): DriveLinkDatabase + + @Binds + abstract fun provideDriveLinkPagedDatabase(db: DriveDatabase): DriveLinkPagedDatabase + + @Binds + abstract fun provideDriveLinkTrashDatabase(db: DriveDatabase): DriveLinkTrashDatabase + + @Binds + abstract fun provideDriveLinkOfflineDatabase(db: DriveDatabase): DriveLinkOfflineDatabase + + @Binds + abstract fun provideDriveLinkDownloadDatabase(db: DriveDatabase): DriveLinkDownloadDatabase + + @Binds + abstract fun provideDriveLinkSharedDatabase(db: DriveDatabase): DriveLinkSharedDatabase + + @Binds + abstract fun provideDriveLinkSelectionDatabase(db: DriveDatabase): DriveLinkSelectionDatabase + + @Binds + abstract fun provideNotificationDatabase(db: DriveDatabase): NotificationDatabase + + @Binds + abstract fun providePaymentDatabase(db: DriveDatabase): PaymentDatabase + + @Binds + abstract fun provideObservabilityDatabase(db: DriveDatabase): ObservabilityDatabase +} diff --git a/app/src/main/kotlin/me/proton/android/drive/extension/DriveException.kt b/app/src/main/kotlin/me/proton/android/drive/extension/DriveException.kt index df5b91a4..7ade3ddb 100644 --- a/app/src/main/kotlin/me/proton/android/drive/extension/DriveException.kt +++ b/app/src/main/kotlin/me/proton/android/drive/extension/DriveException.kt @@ -19,18 +19,19 @@ package me.proton.android.drive.extension import android.content.Context +import me.proton.android.drive.lock.domain.exception.LockException import me.proton.core.drive.base.domain.exception.DriveException import me.proton.core.drive.base.presentation.R as BasePresentation import me.proton.core.drive.share.domain.exception.ShareException import me.proton.core.util.kotlin.CoreLogger +import me.proton.android.drive.lock.presentation.extension.getDefaultMessage as lockGetDefaultMessage -fun DriveException.getDefaultMessage(context: Context): String = context.getString( - when (this) { - is ShareException.MainShareLocked -> BasePresentation.string.error_main_share_locked - is ShareException.MainShareNotFound -> BasePresentation.string.error_main_share_not_found - else -> throw IllegalStateException("Default message for exception is missing") - } -) +fun DriveException.getDefaultMessage(context: Context): String = when (this) { + is ShareException.MainShareLocked -> context.getString(BasePresentation.string.error_main_share_locked) + is ShareException.MainShareNotFound -> context.getString(BasePresentation.string.error_main_share_not_found) + is LockException -> lockGetDefaultMessage(context) + else -> throw IllegalStateException("Default message for exception is missing") +} fun DriveException.log(tag: String, message: String = this.message.orEmpty()): DriveException = also { CoreLogger.d(tag, this, message) diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/AutoLockInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/AutoLockInitializer.kt new file mode 100644 index 00000000..1645acbe --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/initializer/AutoLockInitializer.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ +package me.proton.android.drive.initializer + +import android.content.Context +import androidx.lifecycle.coroutineScope +import androidx.startup.Initializer +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.android.drive.lock.domain.manager.AutoLockManager +import me.proton.core.presentation.app.AppLifecycleProvider + +class AutoLockInitializer : Initializer { + + override fun create(context: Context) { + with ( + EntryPointAccessors.fromApplication( + context.applicationContext, + AutoLockInitializerEntryPoint::class.java + ) + ) { + appLifecycleProvider.state + .onEach { state -> + if (appLockManager.isEnabled()) { + when (state) { + AppLifecycleProvider.State.Background -> autoLockManager.autoLock() + AppLifecycleProvider.State.Foreground -> autoLockManager.cancelAutoLock() + } + } + } + .launchIn(appLifecycleProvider.lifecycle.coroutineScope) + } + } + + override fun dependencies(): List>> = listOf( + LoggerInitializer::class.java, + WorkManagerInitializer::class.java, + ) + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AutoLockInitializerEntryPoint { + val appLifecycleProvider: AppLifecycleProvider + val appLockManager: AppLockManager + val autoLockManager: AutoLockManager + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/DocumentsProviderInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/DocumentsProviderInitializer.kt new file mode 100644 index 00000000..a3ed2cd5 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/initializer/DocumentsProviderInitializer.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.initializer + +import android.content.Context +import androidx.lifecycle.coroutineScope +import androidx.startup.Initializer +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.core.drive.documentsprovider.data.DriveDocumentsProvider +import me.proton.core.presentation.app.AppLifecycleProvider + +class DocumentsProviderInitializer : Initializer { + + override fun create(context: Context) { + with ( + EntryPointAccessors.fromApplication( + context.applicationContext, + AutoLockInitializerEntryPoint::class.java + ) + ) { + appLockManager.enabled + .onEach { + DriveDocumentsProvider.notifyRootsHaveChanged(context) + } + .launchIn(appLifecycleProvider.lifecycle.coroutineScope) + } + } + + override fun dependencies(): List>> = listOf( + LoggerInitializer::class.java, + ) + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AutoLockInitializerEntryPoint { + val appLifecycleProvider: AppLifecycleProvider + val appLockManager: AppLockManager + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt new file mode 100644 index 00000000..5b1581d3 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.initializer + +import android.content.Context +import androidx.startup.AppInitializer +import androidx.startup.Initializer +import me.proton.core.auth.presentation.MissingScopeInitializer +import me.proton.core.humanverification.presentation.HumanVerificationInitializer +import me.proton.core.network.presentation.init.UnAuthSessionFetcherInitializer +import me.proton.core.plan.presentation.UnredeemedPurchaseInitializer + +class MainInitializer : Initializer { + + override fun create(context: Context) { + // No-op needed + } + + override fun dependencies() = listOf( + EventManagerInitializer::class.java, + HumanVerificationInitializer::class.java, + UnredeemedPurchaseInitializer::class.java, + MissingScopeInitializer::class.java, + UnAuthSessionFetcherInitializer::class.java, + AutoLockInitializer::class.java, + ) + + companion object { + + fun init(appContext: Context) { + with(AppInitializer.getInstance(appContext)) { + // WorkManager need to be initialized before any other dependant initializer. + initializeComponent(WorkManagerInitializer::class.java) + initializeComponent(MainInitializer::class.java) + } + } + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/log/DriveLogger.kt b/app/src/main/kotlin/me/proton/android/drive/log/DriveLogger.kt index a07bec08..b5483c59 100644 --- a/app/src/main/kotlin/me/proton/android/drive/log/DriveLogger.kt +++ b/app/src/main/kotlin/me/proton/android/drive/log/DriveLogger.kt @@ -20,21 +20,28 @@ package me.proton.android.drive.log +import android.content.Context import android.os.Build import android.os.LocaleList +import dagger.hilt.android.qualifiers.ApplicationContext import io.sentry.Breadcrumb import io.sentry.Sentry import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.protocol.Message +import me.proton.core.drive.base.presentation.extension.getDefaultMessage import me.proton.core.util.kotlin.Logger import me.proton.core.util.kotlin.LoggerLogTag import timber.log.Timber import java.util.Locale +import javax.inject.Inject import javax.inject.Singleton +import me.proton.core.drive.base.presentation.R as BasePresentation @Singleton -class DriveLogger : Logger { +class DriveLogger @Inject constructor( + @ApplicationContext private val appContext: Context, +) : Logger { override fun v(tag: String, message: String) { DriveSentry.addBreadcrumb(tag, message) @@ -61,11 +68,11 @@ class DriveLogger : Logger { Timber.tag(tag).i(e, message) } override fun e(tag: String, e: Throwable) { - DriveSentry.captureException(tag, e) + DriveSentry.captureException(appContext, tag, e) Timber.tag(tag).e(e) } override fun e(tag: String, e: Throwable, message: String) { - DriveSentry.captureException(tag, e, message) + DriveSentry.captureException(appContext, tag, e, message) Timber.tag(tag).e(e, message) } override fun log(tag: LoggerLogTag, message: String) = i(tag.name, message) @@ -85,12 +92,24 @@ class DriveLogger : Logger { private object DriveSentry { - fun captureException(tag: String, e: Throwable) { + fun captureException( + context: Context, + tag: String, + e: Throwable, + ) { + setInternalErrorTag(context, e) Sentry.setTag("CoreLogger", tag) Sentry.captureException(e) } - fun captureException(tag: String, e: Throwable, message: String, level: SentryLevel = SentryLevel.ERROR) { + fun captureException( + context: Context, + tag: String, + e: Throwable, + message: String, + level: SentryLevel = SentryLevel.ERROR, + ) { + setInternalErrorTag(context, e) Sentry.setTag("CoreLogger", tag) Sentry.captureEvent( SentryEvent(e).apply { @@ -122,6 +141,15 @@ class DriveLogger : Logger { } ) } + + private fun setInternalErrorTag(context: Context, e: Throwable) { + val internalErrorMessage = context.getString(BasePresentation.string.common_error_internal) + val errorMessage = e.getDefaultMessage( + context = context, + useExceptionMessage = false, + ) + Sentry.setTag("InternalError", (errorMessage == internalErrorMessage).toString()) + } } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt b/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt index 0567f7b4..c61ca54c 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt @@ -69,6 +69,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import me.proton.android.drive.extension.deepLinkBaseUrl +import me.proton.android.drive.lock.data.provider.BiometricPromptProvider +import me.proton.android.drive.lock.domain.manager.AppLockManager import me.proton.android.drive.log.DriveLogTag import me.proton.android.drive.ui.navigation.AppNavGraph import me.proton.android.drive.ui.provider.LocalSnackbarPadding @@ -100,6 +102,8 @@ class MainActivity : FragmentActivity() { @Inject lateinit var actionProvider: ActionProvider @Inject lateinit var getThemeStyle: GetThemeStyle @Inject lateinit var processIntent: ProcessIntent + @Inject lateinit var biometricPromptProvider: BiometricPromptProvider + @Inject lateinit var appLockManager: AppLockManager lateinit var configurationProvider: ConfigurationProvider private val accountViewModel: AccountViewModel by viewModels() private val bugReportViewModel: BugReportViewModel by viewModels() @@ -121,6 +125,7 @@ class MainActivity : FragmentActivity() { applySecureFlag() setTheme(CorePresentation.style.ProtonTheme_Drive) super.onCreate(savedInstanceState) + biometricPromptProvider.bindToActivity(this) setupAccountsViewModel() bugReportViewModel.initialize(this) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -145,12 +150,13 @@ class MainActivity : FragmentActivity() { deepLinkBaseUrl = this@MainActivity.deepLinkBaseUrl, clearBackstackTrigger = clearBackstackTrigger, deepLinkIntent = deepLinkIntent, + locked = appLockManager.locked, + primaryAccount = accountViewModel.primaryAccount, exitApp = { finish() }, sendBugReport = bugReportViewModel::sendBugReport, ) { isOpen -> isDrawerOpen = isOpen } - HeadlessSnackBar(snackbarHostState) } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/dialog/AutoLockDurations.kt b/app/src/main/kotlin/me/proton/android/drive/ui/dialog/AutoLockDurations.kt new file mode 100644 index 00000000..6331daf8 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/dialog/AutoLockDurations.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import me.proton.android.drive.ui.viewevent.AutoLockDurationsViewEvent +import me.proton.android.drive.ui.viewmodel.AutoLockDurationsViewModel +import me.proton.android.drive.ui.viewstate.AutoLockDurationsViewState +import me.proton.core.compose.component.bottomsheet.BottomSheetContent +import me.proton.core.compose.component.bottomsheet.RunAction +import me.proton.core.compose.flow.rememberFlowWithLifecycle +import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing +import me.proton.core.drive.settings.presentation.extension.toString + +@Composable +fun AutoLockDurations( + runAction: RunAction, + dismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel = hiltViewModel() + val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState) + .collectAsState(initial = viewModel.initialViewState) + AutoLockDurations( + viewState = viewState, + viewEvent = viewModel.viewEvent(runAction, dismiss), + modifier = modifier.navigationBarsPadding(), + ) +} + +@Composable +fun AutoLockDurations( + viewState: AutoLockDurationsViewState, + viewEvent: AutoLockDurationsViewEvent, + modifier: Modifier = Modifier, +) { + BottomSheetContent( + modifier = modifier, + header = { + Text(text = viewState.title) + }, + content = { + viewState.durations.forEach { duration -> + Duration( + title = duration.toString(LocalContext.current), + isSelected = duration == viewState.selected, + ) { + viewEvent.onDuration(duration) + } + } + } + ) +} + +@Composable +private fun Duration( + title: String, + isSelected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + modifier = Modifier + .padding(start = DefaultSpacing) + .weight(1f), + ) + RadioButton( + selected = isSelected, + onClick = { onClick() }, + ) + } +} + +@Preview +@Composable +private fun PreviewSelectedDuration() { + Duration(title = "Immediately", isSelected = true) {} +} + +@Preview +@Composable +private fun PreviewUnselectedDuration() { + Duration(title = "15 minutes", isSelected = false) {} +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/dialog/SystemAccessDialog.kt b/app/src/main/kotlin/me/proton/android/drive/ui/dialog/SystemAccessDialog.kt new file mode 100644 index 00000000..71049641 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/dialog/SystemAccessDialog.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import me.proton.android.drive.ui.viewmodel.SystemAccessDialogViewModel +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.component.ProtonAlertDialogText +import me.proton.core.drive.base.presentation.R + +@Composable +fun SystemAccessDialog( + modifier: Modifier = Modifier, + onDismiss: () -> Unit, +) { + val viewModel = hiltViewModel() + SystemAccessDialog( + modifier = modifier, + onDismiss = onDismiss, + onSettings = viewModel.viewEvent(LocalContext.current, onDismiss).onSettings, + ) +} + +@Composable +fun SystemAccessDialog( + modifier: Modifier = Modifier, + onDismiss: () -> Unit, + onSettings: () -> Unit, +) { + ProtonAlertDialog( + modifier = modifier, + titleResId = R.string.app_lock_system_dialog_title, + text = { + Column { + ProtonAlertDialogText(textResId = R.string.app_lock_system_dialog_description) + } + }, + onDismissRequest = onDismiss, + dismissButton = { + ProtonAlertDialogButton( + titleResId = R.string.common_cancel_action, + onClick = onDismiss, + ) + }, + confirmButton = { + ProtonAlertDialogButton( + titleResId = R.string.app_lock_system_dialog_settings_button, + onClick = onSettings, + ) + } + ) +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt index 682dc91b..ea300a15 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt @@ -43,6 +43,7 @@ import androidx.navigation.navArgument import androidx.navigation.navDeepLink import com.google.accompanist.navigation.animation.composable import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collectLatest import me.proton.android.drive.extension.get @@ -50,7 +51,9 @@ import me.proton.android.drive.extension.popAllBackStack import me.proton.android.drive.extension.require import me.proton.android.drive.extension.requireArguments import me.proton.android.drive.extension.runFromRoute +import me.proton.android.drive.lock.presentation.component.AppLock import me.proton.android.drive.log.DriveLogTag +import me.proton.android.drive.ui.dialog.AutoLockDurations import me.proton.android.drive.ui.dialog.ConfirmDeletionDialog import me.proton.android.drive.ui.dialog.ConfirmEmptyTrashDialog import me.proton.android.drive.ui.dialog.ConfirmStopSharingDialog @@ -59,6 +62,7 @@ import me.proton.android.drive.ui.dialog.MultipleFileOrFolderOptions import me.proton.android.drive.ui.dialog.ParentFolderOptions import me.proton.android.drive.ui.dialog.SendFileDialog import me.proton.android.drive.ui.dialog.SortingList +import me.proton.android.drive.ui.dialog.SystemAccessDialog import me.proton.android.drive.ui.navigation.animation.defaultEnterSlideTransition import me.proton.android.drive.ui.navigation.animation.defaultPopExitSlideTransition import me.proton.android.drive.ui.navigation.animation.slideComposable @@ -67,6 +71,7 @@ import me.proton.android.drive.ui.navigation.internal.MutableNavControllerSaver import me.proton.android.drive.ui.navigation.internal.createNavController import me.proton.android.drive.ui.navigation.internal.modalBottomSheet import me.proton.android.drive.ui.navigation.internal.rememberAnimatedNavController +import me.proton.android.drive.ui.screen.AppAccessScreen import me.proton.android.drive.ui.screen.FileInfoScreen import me.proton.android.drive.ui.screen.HomeScreen import me.proton.android.drive.ui.screen.LauncherScreen @@ -78,6 +83,7 @@ import me.proton.android.drive.ui.screen.SigningOutScreen import me.proton.android.drive.ui.screen.TrashScreen import me.proton.android.drive.ui.screen.UploadToScreen import me.proton.android.drive.ui.screen.WelcomeScreen +import me.proton.core.account.domain.entity.Account import me.proton.core.compose.component.bottomsheet.ModalBottomSheetViewState import me.proton.core.crypto.common.keystore.KeyStoreCrypto import me.proton.core.domain.entity.UserId @@ -101,6 +107,8 @@ fun AppNavGraph( deepLinkBaseUrl: String, clearBackstackTrigger: SharedFlow, deepLinkIntent: SharedFlow, + locked: Flow, + primaryAccount: Flow, exitApp: () -> Unit, sendBugReport: () -> Unit, onDrawerStateChanged: (Boolean) -> Unit, @@ -136,14 +144,16 @@ fun AppNavGraph( homeNavController = createNavController(localContext) } } - AppNavGraph( - navController = navController, - homeNavController = homeNavController, - deepLinkBaseUrl = deepLinkBaseUrl, - exitApp = exitApp, - sendBugReport = sendBugReport, - onDrawerStateChanged = onDrawerStateChanged, - ) + AppLock(locked = locked, primaryAccount = primaryAccount) { + AppNavGraph( + navController = navController, + homeNavController = homeNavController, + deepLinkBaseUrl = deepLinkBaseUrl, + exitApp = exitApp, + sendBugReport = sendBugReport, + onDrawerStateChanged = onDrawerStateChanged, + ) + } } @Composable @@ -188,6 +198,9 @@ fun AppNavGraph( addShareViaLink(navController) addDiscardShareViaLinkChanges(navController) addUploadTo(navController, deepLinkBaseUrl, exitApp) + addAppAccess(navController) + addSystemAccessDialog(navController) + addAutoLockDurations(navController) } } @@ -673,9 +686,16 @@ fun NavGraphBuilder.addSettings(navController: NavHostController) = composable( arguments = listOf( navArgument(Screen.PagerPreview.USER_ID) { type = NavType.StringType }, ), -) { +) { navBackStackEntry -> + val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID)) SettingsScreen( navigateBack = { navController.popBackStack() }, + navigateToAppAccess = { + navController.navigate(Screen.Settings.AppAccess(userId)) + }, + navigateToAutoLockDurations = { + navController.navigate(Screen.Settings.AutoLockDurations(userId)) + }, ) } @@ -872,3 +892,47 @@ fun NavGraphBuilder.addUploadTo( exitApp = exitApp, ) } + +@ExperimentalAnimationApi +fun NavGraphBuilder.addAppAccess(navController: NavHostController) = composable( + route = Screen.Settings.AppAccess.route, + enterTransition = defaultEnterSlideTransition { true }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = defaultPopExitSlideTransition { true }, + arguments = listOf( + navArgument(Screen.Settings.USER_ID) { type = NavType.StringType }, + ), +) { navBackStackEntry -> + val userId = UserId(navBackStackEntry.require(Screen.Settings.USER_ID)) + AppAccessScreen( + navigateToSystemAccess = { + navController.navigate(Screen.Settings.AppAccess.Dialogs.SystemAccess(userId)) + }, + navigateBack = { + navController.popBackStack() + }, + ) +} + +@ExperimentalCoroutinesApi +fun NavGraphBuilder.addSystemAccessDialog(navController: NavHostController) = dialog( + route = Screen.Settings.AppAccess.Dialogs.SystemAccess.route, + arguments = listOf( + navArgument(Screen.Settings.USER_ID) { type = NavType.StringType }, + ), +) { + SystemAccessDialog(onDismiss = { navController.popBackStack() }) +} + +fun NavGraphBuilder.addAutoLockDurations( + navController: NavHostController, +) = modalBottomSheet( + route = Screen.Settings.AutoLockDurations.route, + viewState = ModalBottomSheetViewState(dismissOnAction = false), +) { _, runAction -> + AutoLockDurations( + runAction = runAction, + dismiss = { navController.popBackStack() } + ) +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt index f6cf989c..a948b678 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt @@ -271,6 +271,22 @@ sealed class Screen(val route: String) { operator fun invoke(userId: UserId) = "settings/${userId.id}" const val USER_ID = Screen.USER_ID + + object AppAccess : Screen("settings/{userId}/appAccess") { + + operator fun invoke(userId: UserId) = "settings/${userId.id}/appAccess" + + object Dialogs { + + object SystemAccess : Screen("settings/{userId}/appAccess/systemAccess") { + operator fun invoke(userId: UserId) = "settings/${userId.id}/appAccess/systemAccess" + } + } + } + + object AutoLockDurations : Screen("settings/{userId}/autoLockDurations") { + operator fun invoke(userId: UserId) = "settings/${userId.id}/autoLockDurations" + } } object SendFile : Screen("send/{userId}/shares/{shareId}/files/{fileId}") { diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/AppAccessScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/AppAccessScreen.kt new file mode 100644 index 00000000..5f47a6b1 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/AppAccessScreen.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.RadioButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import me.proton.android.drive.ui.viewevent.AppAccessViewEvent +import me.proton.android.drive.ui.viewmodel.AppAccessViewModel +import me.proton.android.drive.ui.viewstate.AccessOption +import me.proton.android.drive.ui.viewstate.AppAccessViewState +import me.proton.core.compose.flow.rememberFlowWithLifecycle +import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.drive.base.presentation.component.ProtonListItem +import me.proton.core.drive.base.presentation.component.TopAppBar +import me.proton.core.drive.base.presentation.R as BasePresentation +import me.proton.core.presentation.R as CorePresentation + +@Composable +fun AppAccessScreen( + modifier: Modifier = Modifier, + navigateToSystemAccess: () -> Unit, + navigateBack: () -> Unit, +) { + val viewModel = hiltViewModel() + val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState) + .collectAsState(initial = viewModel.initialViewState) + AppAccess( + viewState = viewState, + viewEvent = viewModel.viewEvent(navigateToSystemAccess, navigateBack), + modifier = modifier.fillMaxSize(), + navigateBack = navigateBack, + ) +} + +@Composable +fun AppAccess( + viewState: AppAccessViewState, + viewEvent: AppAccessViewEvent, + modifier: Modifier = Modifier, + navigateBack: () -> Unit, +) { + Column(modifier = modifier) { + TopAppBar( + navigationIcon = painterResource(id = CorePresentation.drawable.ic_arrow_back), + onNavigationIcon = navigateBack, + title = viewState.title, + modifier = Modifier.statusBarsPadding() + ) + AppAccessOptions(viewState.enabledOption, viewEvent) + } +} + +@Composable +fun AppAccessOptions( + enabledOption: AccessOption, + viewEvent: AppAccessViewEvent, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.verticalScroll(rememberScrollState()) + ) { + AppAccessOption( + iconResId = BasePresentation.drawable.ic_proton_lock_open, + titleResId = BasePresentation.string.app_lock_option_none, + isSelected = enabledOption == AccessOption.NONE, + ) { + viewEvent.onDisable() + } + AppAccessOption( + iconResId = CorePresentation.drawable.ic_proton_fingerprint, + titleResId = BasePresentation.string.app_lock_option_system, + isSelected = enabledOption == AccessOption.SYSTEM, + ) { + viewEvent.onSystem() + } + } +} + +@Composable +fun AppAccessOption( + iconResId: Int, + titleResId: Int, + isSelected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically, + ) { + ProtonListItem( + icon = painterResource(id = iconResId), + title = stringResource(id = titleResId), + modifier = modifier + .weight(1f) + .padding(start = DefaultSpacing), + ) + RadioButton( + selected = isSelected, + onClick = { onClick() }, + ) + } +} + +@Preview +@Composable +private fun AppAccessPreview() { + ProtonTheme { + AppAccess( + viewState = AppAccessViewState( + title = "Title", + enabledOption = AccessOption.NONE, + ), + viewEvent = object : AppAccessViewEvent { + override val onDisable = {} + override val onSystem = {} + }, + navigateBack = {} + ) + } +} + diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/FilesScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/FilesScreen.kt index 8c9ec600..16659ddc 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/FilesScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/FilesScreen.kt @@ -66,15 +66,18 @@ fun FilesScreen( val selected by rememberFlowWithLifecycle(flow = viewState.selected) .collectAsState(initial = null) val inMultiselect = remember(selected) { selected?.isNotEmpty() ?: false } - val viewEvent = viewModel.viewEvent( - navigateToFiles, - navigateToPreview, - navigateToSortingDialog, - navigateToFileOrFolderOptions, - navigateToMultipleFileOrFolderOptions, - navigateToParentFolderOptions, - navigateBack, - ) + + val viewEvent = remember { + viewModel.viewEvent( + navigateToFiles, + navigateToPreview, + navigateToSortingDialog, + navigateToFileOrFolderOptions, + navigateToMultipleFileOrFolderOptions, + navigateToParentFolderOptions, + navigateBack, + ) + } BackHandler(enabled = inMultiselect) { viewEvent.onBack() } LaunchedEffect(viewState) { homeScaffoldState.topAppBar.value = { diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt index fe46c2bb..f0a3fac0 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt @@ -98,6 +98,7 @@ fun MoveToFolder( modifier .systemBarsPadding() .padding(vertical = DefaultSpacing) + .testTag(MoveToFolderScreenTestTag.screen) ) { Column { Title( @@ -198,5 +199,5 @@ fun TitleText( object MoveToFolderScreenTestTag { const val screen = "move to folder screen" - const val plusFolderButton = "plus folder button" + const val plusFolderButton = "move to folder plus folder button" } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/OfflineScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/OfflineScreen.kt index d249006c..f707476e 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/OfflineScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/OfflineScreen.kt @@ -29,12 +29,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import me.proton.android.drive.ui.navigation.PagerType import me.proton.android.drive.ui.viewmodel.OfflineViewModel +import me.proton.core.compose.flow.rememberFlowWithLifecycle import me.proton.core.drive.files.presentation.component.DriveLinksFlow import me.proton.core.drive.files.presentation.component.Files import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.LinkId -import me.proton.core.compose.flow.rememberFlowWithLifecycle import me.proton.core.drive.sorting.domain.entity.Sorting @Composable diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/PreviewScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PreviewScreen.kt index ae6ef7bc..2def56e4 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/PreviewScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PreviewScreen.kt @@ -107,7 +107,6 @@ fun PreviewScreen( navigateBack = navigateBack, navigateToFileOrFolderOptions = navigateToFileOrFolderOptions, ), - zoomEffect = viewModel.zoomEffect, modifier = modifier, ) { page -> viewModel.onPageChanged(page) diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/SettingsScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/SettingsScreen.kt index 30bf8932..c86e595c 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/SettingsScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/SettingsScreen.kt @@ -41,6 +41,8 @@ import me.proton.core.drive.settings.presentation.Settings @Composable fun SettingsScreen( navigateBack: () -> Unit, + navigateToAppAccess: () -> Unit, + navigateToAutoLockDurations: () -> Unit, modifier: Modifier = Modifier, ) { val viewModel = hiltViewModel() @@ -54,15 +56,19 @@ fun SettingsScreen( }.launchIn(this) } - settingsViewState?.let { viewState -> - Box( - modifier = modifier - .fillMaxSize() - .systemBarsPadding() - ) { + Box( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + ) { + settingsViewState?.let { viewState -> Settings( viewState = viewState, - viewEvent = viewModel.viewEvent(navigateBack), + viewEvent = viewModel.viewEvent( + navigateBack = navigateBack, + navigateToAppAccess = navigateToAppAccess, + navigateToAutoLockDurations = navigateToAutoLockDurations, + ), ) ProtonSnackbarHost( diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/AppAccessViewEvent.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/AppAccessViewEvent.kt new file mode 100644 index 00000000..fab81f21 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/AppAccessViewEvent.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewevent + +interface AppAccessViewEvent { + val onDisable: () -> Unit + val onSystem: () -> Unit +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/AutoLockDurationsViewEvent.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/AutoLockDurationsViewEvent.kt new file mode 100644 index 00000000..0acb58cf --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/AutoLockDurationsViewEvent.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewevent + +import kotlin.time.Duration + +interface AutoLockDurationsViewEvent { + val onDuration: (Duration) -> Unit +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/SystemAccessDialogViewEvent.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/SystemAccessDialogViewEvent.kt new file mode 100644 index 00000000..70641340 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/SystemAccessDialogViewEvent.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewevent + +interface SystemAccessDialogViewEvent { + val onSettings: () -> Unit +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/AppAccessViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/AppAccessViewModel.kt new file mode 100644 index 00000000..e2333ee6 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/AppAccessViewModel.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewmodel + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import me.proton.android.drive.extension.getDefaultMessage +import me.proton.android.drive.lock.data.extension.onNotAvailable +import me.proton.android.drive.lock.data.extension.onReady +import me.proton.android.drive.lock.data.extension.onSetupRequired +import me.proton.android.drive.lock.domain.entity.AppLockType +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.android.drive.lock.domain.usecase.DisableAppLock +import me.proton.android.drive.lock.domain.usecase.EnableAppLock +import me.proton.android.drive.lock.domain.usecase.GetLockState +import me.proton.android.drive.ui.viewevent.AppAccessViewEvent +import me.proton.android.drive.ui.viewstate.AccessOption +import me.proton.android.drive.ui.viewstate.AppAccessViewState +import me.proton.core.drive.base.domain.usecase.BroadcastMessages +import me.proton.core.drive.base.presentation.viewmodel.UserViewModel +import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage +import javax.inject.Inject +import me.proton.core.drive.base.presentation.R as BasePresentation + +@Suppress("StaticFieldLeak") +@HiltViewModel +class AppAccessViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + savedStateHandle: SavedStateHandle, + private val disableAppLock: DisableAppLock, + private val enableAppLock: EnableAppLock, + private val getLockState: GetLockState, + private val broadcastMessages: BroadcastMessages, + private val appLockManager: AppLockManager, +) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) { + + val initialViewState: AppAccessViewState = AppAccessViewState( + title = appContext.getString(BasePresentation.string.app_lock_access_title), + enabledOption = AccessOption.NONE, + ) + val viewState: Flow = appLockManager.enabled.map { enabled -> + initialViewState.copy( + enabledOption = if (!enabled) AccessOption.NONE else AccessOption.SYSTEM + ) + } + + fun viewEvent( + navigateToSystemAccess: () -> Unit, + navigateBack: () -> Unit, + ): AppAccessViewEvent = object : AppAccessViewEvent { + override val onDisable: () -> Unit = { doDisableAppLock(navigateBack) } + override val onSystem: () -> Unit = { + getLockState() + .onNotAvailable { + broadcastMessages( + userId = userId, + message = appContext.getString(BasePresentation.string.app_lock_system_not_available), + type = BroadcastMessage.Type.WARNING, + ) + } + .onSetupRequired { + navigateToSystemAccess() + } + .onReady { + doEnableSystemLock(navigateBack) + } + } + } + + private fun doDisableAppLock(navigateBack: () -> Unit) = viewModelScope.launch { + if (appLockManager.isEnabled().not()) return@launch + disableAppLock() + .onFailure { error -> + broadcastMessages( + userId = userId, + message = error.getDefaultMessage(appContext, true), + type = BroadcastMessage.Type.ERROR, + ) + } + .onSuccess { + broadcastMessages( + userId = userId, + message = appContext.getString(BasePresentation.string.app_lock_disabled), + type = BroadcastMessage.Type.INFO, + ) + navigateBack() + } + } + + private fun doEnableSystemLock(navigateBack: () -> Unit) = viewModelScope.launch { + if (appLockManager.isEnabled()) return@launch + enableAppLock(lockType = AppLockType.SYSTEM) + .onFailure { error -> + broadcastMessages( + userId = userId, + message = error.getDefaultMessage(appContext, true), + type = BroadcastMessage.Type.ERROR, + ) + } + .onSuccess { + broadcastMessages( + userId = userId, + message = appContext.getString(BasePresentation.string.app_lock_enabled), + type = BroadcastMessage.Type.INFO, + ) + } + navigateBack() + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/AutoLockDurationsViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/AutoLockDurationsViewModel.kt new file mode 100644 index 00000000..3fc4ed6f --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/AutoLockDurationsViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import me.proton.android.drive.lock.domain.usecase.GetAutoLockDuration +import me.proton.android.drive.lock.domain.usecase.UpdateAutoLockDuration +import me.proton.android.drive.ui.viewevent.AutoLockDurationsViewEvent +import me.proton.android.drive.ui.viewstate.AutoLockDurationsViewState +import me.proton.core.compose.component.bottomsheet.RunAction +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import javax.inject.Inject +import kotlin.time.Duration +import me.proton.core.drive.settings.R as SettingsPresentation + +@HiltViewModel +class AutoLockDurationsViewModel @Inject constructor( + @ApplicationContext appContext: Context, + configurationProvider: ConfigurationProvider, + getAutoLockDuration: GetAutoLockDuration, + private val updateAutoLockDuration: UpdateAutoLockDuration, +) : ViewModel() { + val initialViewState: AutoLockDurationsViewState = AutoLockDurationsViewState( + title = appContext.getString(SettingsPresentation.string.settings_auto_lock), + durations = configurationProvider.autoLockDurations, + selected = configurationProvider.autoLockDurations.first(), + ) + val viewState: Flow = getAutoLockDuration().map { duration -> + initialViewState.copy( + selected = duration + ) + } + + fun viewEvent( + runAction: RunAction, + dismiss: () -> Unit, + ): AutoLockDurationsViewEvent = object : AutoLockDurationsViewEvent { + override val onDuration: (Duration) -> Unit = { duration -> + runAction { + viewModelScope.launch { + updateAutoLockDuration(duration) + dismiss() + } + } + } + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/BugReportViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/BugReportViewModel.kt index 9767d5e4..01939388 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/BugReportViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/BugReportViewModel.kt @@ -23,10 +23,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import me.proton.core.accountmanager.domain.AccountManager import me.proton.core.accountmanager.domain.getPrimaryAccount import me.proton.core.drive.base.domain.usecase.BroadcastMessages +import me.proton.core.drive.base.domain.usecase.GetUserEmail import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage import me.proton.core.report.presentation.ReportOrchestrator import me.proton.core.report.presentation.entity.BugReportInput @@ -38,6 +42,7 @@ class BugReportViewModel @Inject constructor( accountManager: AccountManager, private val reportOrchestrator: ReportOrchestrator, private val broadcastMessages: BroadcastMessages, + private val getUserEmail: GetUserEmail, ) : ViewModel() { private val primaryAccount = accountManager @@ -57,11 +62,12 @@ class BugReportViewModel @Inject constructor( } fun sendBugReport() { - primaryAccount.value?.let { account -> + viewModelScope.launch { + val account = primaryAccount.filterNotNull().first() reportOrchestrator.startBugReport( input = BugReportInput( - email = account.email.orEmpty(), - username = account.username + email = getUserEmail(account.userId), + username = account.username, ), ) } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt index 1d3139a9..b449f6a7 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt @@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -44,6 +43,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import me.proton.android.drive.R @@ -66,9 +66,9 @@ import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLin import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted import me.proton.core.drive.drivelink.download.domain.usecase.GetDownloadProgress -import me.proton.core.drive.drivelink.list.domain.usecase.GetDriveLinks import me.proton.core.drive.drivelink.list.domain.usecase.GetPagedDriveLinksList import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks +import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll import me.proton.core.drive.files.presentation.event.FilesViewEvent import me.proton.core.drive.files.presentation.state.FilesViewState import me.proton.core.drive.files.presentation.state.ListContentAppendingState @@ -110,14 +110,15 @@ class FilesViewModel @Inject constructor( private val getUploadFileLinks: GetUploadFileLinks, private val getUploadProgress: GetUploadProgress, private val onFilesDriveLinkError: OnFilesDriveLinkError, - private val getDriveLinks: GetDriveLinks, private val selectLinks: SelectLinks, + private val selectAll: SelectAll, private val deselectLinks: DeselectLinks, private val getSelectedDriveLinks: GetSelectedDriveLinks, private val savedStateHandle: SavedStateHandle, getSorting: GetSorting, private val configurationProvider: ConfigurationProvider, ) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle), HomeTabViewModel { + private val shareId = savedStateHandle.get(Screen.Files.SHARE_ID) private val folderId = savedStateHandle.get(Screen.Files.FOLDER_ID)?.let { folderId -> shareId?.let { FolderId(ShareId(userId, shareId), folderId) } @@ -141,9 +142,6 @@ class FilesViewModel @Inject constructor( } ) }.stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val encryptedDriveLinks: Flow> = - driveLink.filterNotNull() - .flatMapLatest { folder -> getDriveLinks(folder.id) } val driveLinks: Flow> = driveLink.filterNotNull() @@ -175,9 +173,9 @@ class FilesViewModel @Inject constructor( contentDescriptionResId = BasePresentation.string.content_description_select_all, onAction = { viewModelScope.launch { - addSelected( - encryptedDriveLinks.first().map { driveLink -> driveLink.id } - ) + driveLink.value?.let { parent -> + selectAll(parent.id, selectionId.value) + } } } ) @@ -187,23 +185,20 @@ class FilesViewModel @Inject constructor( onAction = { viewEvent?.onSelectedOptions?.invoke() } ) private val topBarActions: MutableStateFlow> = MutableStateFlow(setOf(addFilesAction)) - private val selected: StateFlow> = combine( - selectionId.filterNotNull().flatMapLatest { selectionId -> getSelectedDriveLinks(selectionId) }, - encryptedDriveLinks, - ) { selectedDriveLinks, availableDriveLinks -> - val selectedDriveLinkIds = selectedDriveLinks.map { driveLink -> driveLink.id }.toSet() - val availableDriveLinkIds = availableDriveLinks.map { driveLink -> driveLink.id }.toSet() - val removedDriveLinkIds = selectedDriveLinkIds.subtract(availableDriveLinkIds) - if (removedDriveLinkIds.isNotEmpty()) { - removeSelected(removedDriveLinkIds.toList()) - } - if (selectedDriveLinkIds.isEmpty()) { - topBarActions.value = setOf(addFilesAction) - } else { - topBarActions.value = setOf(selectAllAction, selectedOptionsAction) - } - selectedDriveLinkIds - }.stateIn(viewModelScope, SharingStarted.Eagerly, setOf()) + private val selected: StateFlow> = selectionId + .filterNotNull() + .transformLatest { id -> + emitAll( + getSelectedDriveLinks(id).map { driveLinks -> + if (driveLinks.isEmpty()) { + topBarActions.value = setOf(addFilesAction) + } else { + topBarActions.value = setOf(selectAllAction, selectedOptionsAction) + } + driveLinks.map { driveLink -> driveLink.id }.toSet() + } + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) val isBottomNavigationEnabled: Flow = selected.map { set -> set.isEmpty() } val initialViewState = FilesViewState( title = savedStateHandle[Screen.Files.FOLDER_NAME], @@ -269,7 +264,6 @@ class FilesViewModel @Inject constructor( actionResId = BasePresentation.string.action_empty_files_add_files, ) private var viewEvent: FilesViewEvent? = null - fun viewEvent( navigateToFiles: (folderId: FolderId, folderName: String?) -> Unit, navigateToPreview: (fileId: FileId) -> Unit, @@ -279,6 +273,15 @@ class FilesViewModel @Inject constructor( navigateToParentFolderOptions: (folderId: FolderId) -> Unit, navigateBack: () -> Unit, ): FilesViewEvent = object : FilesViewEvent { + + private val driveLinkShareFlow = MutableSharedFlow(extraBufferCapacity = 1).also { flow -> + viewModelScope.launch { + flow.take(1).collect { driveLink -> + driveLink.onClick(navigateToFiles, navigateToPreview) + } + } + } + override val onTopAppBarNavigation = { if (selected.value.isNotEmpty()) { selectionId.value?.let { viewModelScope.launch { deselectLinks(it) } } @@ -301,7 +304,8 @@ class FilesViewModel @Inject constructor( addSelected(listOf(driveLink.id)) } } else { - driveLink.onClick(navigateToFiles, navigateToPreview) + driveLinkShareFlow.tryEmit(driveLink) + Unit } } override val onLoadState = onLoadState( diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/MoveToFolderViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/MoveToFolderViewModel.kt index b7e84837..44632cd9 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/MoveToFolderViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/MoveToFolderViewModel.kt @@ -141,13 +141,13 @@ class MoveToFolderViewModel @Inject constructor( override val onAppendErrorAction: () -> Unit = this@MoveToFolderViewModel.onRetry } - fun confirmMove() { + suspend fun confirmMove() { parentLink.value?.let { folder -> if (folder.id != parentId) { viewModelScope.launch { moveFile(userId, driveLinksToMove.value.map { driveLink -> driveLink.id }, folder.id) selectionId?.let{ deselectLinks(selectionId) } - } + }.join() } } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/OfflineViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/OfflineViewModel.kt index e3590fb0..d5ed07ea 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/OfflineViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/OfflineViewModel.kt @@ -25,6 +25,7 @@ import androidx.paging.CombinedLoadStates import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -34,16 +35,23 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch +import me.proton.android.drive.ui.common.onClick import me.proton.android.drive.ui.navigation.PagerType import me.proton.android.drive.ui.navigation.Screen import me.proton.android.drive.ui.viewevent.OfflineViewEvent import me.proton.android.drive.ui.viewstate.OfflineViewState import me.proton.core.domain.arch.onSuccess import me.proton.core.drive.base.domain.entity.Percentage +import me.proton.core.drive.base.domain.entity.onProcessing +import me.proton.core.drive.base.domain.extension.onFailure import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL +import me.proton.core.drive.base.presentation.extension.log +import me.proton.core.drive.base.presentation.viewmodel.UserViewModel import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink import me.proton.core.drive.drivelink.domain.entity.DriveLink +import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted import me.proton.core.drive.drivelink.download.domain.usecase.GetDownloadProgress import me.proton.core.drive.drivelink.list.domain.usecase.GetSortedDecryptedDriveLinks import me.proton.core.drive.drivelink.offline.domain.usecase.GetDecryptedOfflineDriveLinks @@ -53,12 +61,6 @@ import me.proton.core.drive.files.presentation.state.ListContentState import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.LinkId -import me.proton.android.drive.ui.common.onClick -import me.proton.core.drive.base.presentation.viewmodel.UserViewModel -import me.proton.core.drive.base.domain.entity.onProcessing -import me.proton.core.drive.base.domain.extension.onFailure -import me.proton.core.drive.base.presentation.extension.log -import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.sorting.domain.entity.Sorting import me.proton.core.drive.sorting.domain.usecase.GetSorting @@ -165,20 +167,30 @@ class OfflineViewModel @Inject constructor( navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit, navigateBack: () -> Unit, ): OfflineViewEvent = object : OfflineViewEvent { + + private val driveLinkShareFlow = MutableSharedFlow(extraBufferCapacity = 1).also { flow -> + viewModelScope.launch { + flow.take(1).collect { driveLink -> + driveLink.onClick( + navigateToFolder = navigateToFiles, + navigateToPreview = { fileId -> + navigateToPreview( + if (folderId == null) PagerType.OFFLINE else PagerType.FOLDER, + fileId + ) + } + ) + } + } + } + override val onTopAppBarNavigation = { navigateBack() } override val onSorting = navigateToSortingDialog override val onDriveLink = { driveLink: DriveLink -> - driveLink.onClick( - navigateToFolder = navigateToFiles, - navigateToPreview = { fileId -> - navigateToPreview( - if (folderId == null) PagerType.OFFLINE else PagerType.FOLDER, - fileId - ) - } - ) + driveLinkShareFlow.tryEmit(driveLink) + Unit } override val onLoadState = { _: CombinedLoadStates, _: Int -> } override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PreviewViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PreviewViewModel.kt index 2de880eb..e9f0d95e 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PreviewViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PreviewViewModel.kt @@ -18,11 +18,14 @@ package me.proton.android.drive.ui.viewmodel +import android.annotation.SuppressLint +import android.content.Context import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -32,6 +35,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -41,6 +45,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import me.proton.android.drive.extension.getDefaultMessage import me.proton.android.drive.ui.effect.PreviewEffect import me.proton.android.drive.ui.navigation.PagerType import me.proton.android.drive.ui.navigation.Screen @@ -51,11 +56,12 @@ import me.proton.core.domain.arch.transformSuccess import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.domain.extension.asSuccess import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL +import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.presentation.entity.toFileTypeCategory import me.proton.core.drive.base.presentation.extension.require import me.proton.core.drive.base.presentation.viewmodel.UserViewModel -import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink import me.proton.core.drive.documentsprovider.domain.usecase.GetDocumentUri +import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted import me.proton.core.drive.drivelink.download.domain.usecase.GetFile @@ -66,7 +72,6 @@ import me.proton.core.drive.files.preview.presentation.component.event.PreviewVi import me.proton.core.drive.files.preview.presentation.component.state.ContentState import me.proton.core.drive.files.preview.presentation.component.state.PreviewContentState import me.proton.core.drive.files.preview.presentation.component.state.PreviewViewState -import me.proton.core.drive.files.preview.presentation.component.state.ZoomEffect import me.proton.core.drive.files.preview.presentation.component.toComposable import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.LinkId @@ -77,8 +82,11 @@ import me.proton.core.drive.base.presentation.R as BasePresentation import me.proton.core.presentation.R as CorePresentation @HiltViewModel +@SuppressLint("StaticFieldLeak") @OptIn(ExperimentalCoroutinesApi::class) class PreviewViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + private val configurationProvider: ConfigurationProvider, getDriveLink: GetDecryptedDriveLink, private val getFile: GetFile, private val getDocumentUri: GetDocumentUri, @@ -110,11 +118,12 @@ class PreviewViewModel @Inject constructor( PagerType.OFFLINE -> OfflineContentProvider(userId, getOfflineDriveNodes) } + private val contentStatesCache = mutableMapOf>() + private val driveLinks: StateFlow?> = provider.getDriveLinks() .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _previewEffect = MutableSharedFlow() - private val _zoomEffect = MutableSharedFlow() private val isFullscreen = MutableStateFlow(false) private val renderFailed = MutableStateFlow(null) val initialViewState = PreviewViewState( @@ -151,7 +160,6 @@ class PreviewViewModel @Inject constructor( val previewEffect: Flow = _previewEffect.asSharedFlow() .onStart { emit(PreviewEffect.Fullscreen(isFullscreen.value)) } - val zoomEffect: Flow = _zoomEffect.asSharedFlow() fun viewEvent( navigateBack: () -> Unit, @@ -160,7 +168,6 @@ class PreviewViewModel @Inject constructor( override val onTopAppBarNavigation = { navigateBack() } override val onMoreOptions = { navigateToFileOrFolderOptions(fileId) } override val onSingleTap = { toggleFullscreen() } - override val onDoubleTap = { resetZoom() } override val onRenderFailed = { throwable: Throwable -> renderFailed.value = throwable } override val mediaControllerVisibility = { visible: Boolean -> if ((visible && isFullscreen.value) || (!visible && !isFullscreen.value)) { @@ -197,48 +204,37 @@ class PreviewViewModel @Inject constructor( } } - private fun resetZoom() { - viewModelScope.launch { - _zoomEffect.emit(ZoomEffect.Reset) - } - } - private fun getContentState( getFileState: GetFile.State, renderFailed: Throwable? = null, ): ContentState { return renderFailed?.let { throwable -> - ContentState.Error.NonRetryable(throwable.message, 0) + ContentState.Error.NonRetryable( + message = throwable.getDefaultMessage( + context = appContext, + useExceptionMessage = configurationProvider.useExceptionMessage, + ), + messageResId = 0, + ) } ?: getFileState.toContentState(this) } fun getUri(fileId: FileId) = getDocumentUri(userId, fileId) - - private fun DriveLink.File.getContentStateFlow(): Flow { - if (mimeType.toFileTypeCategory().toComposable() == PreviewComposable.Unknown) { - return NO_PREVIEW_SUPPORTED - } - var savedFlow: Flow? = null - return trigger.flatMapLatest { trigger -> - val availableFlow = savedFlow - when { - availableFlow != null -> availableFlow - trigger.fileId == id -> { + private fun DriveLink.File.getContentStateFlow(): Flow = + contentStatesCache.getOrPut(id) { + if (mimeType.toFileTypeCategory().toComposable() == PreviewComposable.Unknown) { + NO_PREVIEW_SUPPORTED + } else { + trigger.filter { trigger -> trigger.fileId == id }.flatMapLatest { trigger -> combine( getFile(this, trigger.verifySignature), renderFailed, ) { fileState, renderFailed -> - getContentState(fileState, renderFailed).also { state -> - if (state is ContentState.Available) { - savedFlow = flowOf(state) - } - } + getContentState(fileState, renderFailed) } } - else -> DEFAULT_STATE } } - } private data class Trigger( val fileId: FileId, @@ -247,7 +243,6 @@ class PreviewViewModel @Inject constructor( companion object { private val NO_PREVIEW_SUPPORTED = flowOf(ContentState.Available(Uri.EMPTY)) - private val DEFAULT_STATE = flowOf(ContentState.Downloading(null)) } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SettingsViewModel.kt index 86a7f35d..8535b3a2 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SettingsViewModel.kt @@ -23,6 +23,7 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -32,12 +33,13 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import me.proton.android.drive.BuildConfig import me.proton.android.drive.R -import me.proton.core.presentation.R as CorePresentation +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.android.drive.lock.domain.usecase.GetAutoLockDuration +import me.proton.android.drive.lock.domain.usecase.HasEnableAppLockTimestamp import me.proton.android.drive.settings.DebugSettings import me.proton.core.drive.base.presentation.viewmodel.UserViewModel import me.proton.core.drive.settings.presentation.component.DebugSettingsStateAndEvent @@ -46,10 +48,13 @@ import me.proton.core.drive.settings.presentation.event.SettingsViewEvent import me.proton.core.drive.settings.presentation.state.DebugSettingsViewState import me.proton.core.drive.settings.presentation.state.LegalLink import me.proton.core.drive.settings.presentation.state.SettingsViewState +import me.proton.drive.android.settings.domain.entity.ThemeStyle import me.proton.drive.android.settings.domain.usecase.GetThemeStyle import me.proton.drive.android.settings.domain.usecase.UpdateThemeStyle -import me.proton.drive.android.settings.domain.entity.ThemeStyle import javax.inject.Inject +import me.proton.core.drive.base.domain.extension.combine as baseCombine +import me.proton.core.drive.base.presentation.R as BasePresentation +import me.proton.core.presentation.R as CorePresentation @HiltViewModel @SuppressLint("StaticFieldLeak") @@ -59,18 +64,23 @@ class SettingsViewModel @Inject constructor( getThemeStyle: GetThemeStyle, private val updateThemeStyle: UpdateThemeStyle, savedStateHandle: SavedStateHandle, + appLockManager: AppLockManager, + getAutoLockDuration: GetAutoLockDuration, + private val hasEnableAppLockTimestamp: HasEnableAppLockTimestamp, ) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) { private val _errorMessage = MutableSharedFlow() val errorMessage: SharedFlow = _errorMessage - val viewState: Flow = combine( + val viewState: Flow = baseCombine( debugSettings.baseUrlFlow, debugSettings.hostFlow, debugSettings.appVersionHeaderFlow, debugSettings.useExceptionMessageFlow, getThemeStyle(userId), - ) { baseUrl, host, appVersionHeader, useExceptionMessage, themeStyle -> + appLockManager.enabled, + getAutoLockDuration(), + ) { baseUrl, host, appVersionHeader, useExceptionMessage, themeStyle, enabled, autoLockDuration -> SettingsViewState( navigationIcon = CorePresentation.drawable.ic_arrow_back, appNameResId = R.string.app_name, @@ -92,11 +102,18 @@ class SettingsViewModel @Inject constructor( baseUrl = baseUrl, appVersionHeader = appVersionHeader, useExceptionMessage = useExceptionMessage, - ) + ), + appAccessSubtitleResId = getAppAccessSubtitleResId(enabled), + isAutoLockDurationsVisible = enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O, + autoLockDuration = autoLockDuration, ) }.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - fun viewEvent(navigateBack: () -> Unit) = SettingsViewEvent( + fun viewEvent( + navigateBack: () -> Unit, + navigateToAppAccess: () -> Unit, + navigateToAutoLockDurations: () -> Unit, + ) = SettingsViewEvent( navigateBack = navigateBack, onLinkClicked = { link -> when (link) { @@ -107,9 +124,21 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { updateThemeStyle(userId, enumValues().first { style -> style.resId == newStyle }) } + }, + onAppAccess = { + navigateToAppAccess() + }, + onAutoLockDurations = { + navigateToAutoLockDurations() } ) + private suspend fun getAppAccessSubtitleResId(isAppLockEnabled: Boolean): Int = when { + isAppLockEnabled -> BasePresentation.string.app_lock_option_system + hasEnableAppLockTimestamp().not() -> BasePresentation.string.app_lock_option_never_set + else -> BasePresentation.string.app_lock_option_none + } + private fun onExternalLinkClicked(link: LegalLink.External) { try { context.startActivity( diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SharedViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SharedViewModel.kt index 72b59a0a..a6e6fbbf 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SharedViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SharedViewModel.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import me.proton.android.drive.ui.common.onClick @@ -114,7 +115,7 @@ class SharedViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = DataResult.Processing(ResponseSource.Local)) val driveLinksFlow = driveLinks.mapSuccessValueOrNull() - .map { driveLinks -> driveLinks.orEmpty() } + .filterNotNull() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) val initialViewState = SharedViewState( @@ -146,7 +147,11 @@ class SharedViewModel @Inject constructor( }.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) private fun DataResult>.toListContentState(): ListContentState = when (val cause = this) { - is DataResult.Processing -> ListContentState.Loading + is DataResult.Processing -> if (driveLinksFlow.replayCache.isNotEmpty()) { + ListContentState.Content(true) + } else { + ListContentState.Loading + } is DataResult.Success -> if (cause.value.isNotEmpty()) { ListContentState.Content(false) } else { @@ -171,18 +176,22 @@ class SharedViewModel @Inject constructor( navigateToSortingDialog: (Sorting) -> Unit, navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit, ): SharedViewEvent = object : SharedViewEvent { + + private val driveLinkShareFlow = MutableSharedFlow(extraBufferCapacity = 1).also { flow -> + viewModelScope.launch { + flow.take(1).collect { driveLink -> + driveLink.onClick(navigateToFiles, navigateToPreview) + } + } + } override val onTopAppBarNavigation = { viewModelScope.launch { _effects.emit(HomeEffect.OpenDrawer) } Unit } override val onSorting = navigateToSortingDialog - override val onDriveLink = { driveNode: DriveLink -> - driveNode.onClick( - navigateToFolder = navigateToFiles, - navigateToPreview = { fileId -> - navigateToPreview(fileId) - } - ) + override val onDriveLink = { driveLink: DriveLink -> + driveLinkShareFlow.tryEmit(driveLink) + Unit } override val onLoadState = { _: CombinedLoadStates, _: Int -> } override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SystemAccessDialogViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SystemAccessDialogViewModel.kt new file mode 100644 index 00000000..58085b9c --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SystemAccessDialogViewModel.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewmodel + +import android.annotation.TargetApi +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.lifecycle.ViewModel +import me.proton.android.drive.ui.viewevent.SystemAccessDialogViewEvent +import javax.inject.Inject + +class SystemAccessDialogViewModel @Inject constructor( + +) : ViewModel() { + fun viewEvent( + context: Context, + dismiss: () -> Unit, + ) = object : SystemAccessDialogViewEvent { + override val onSettings = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + context.showBiometricSettings() + } else { + context.showSetNewPasswordSettings() + } + dismiss() + } + } + + private fun Context.showSetNewPasswordSettings() { + startActivity(Intent(DevicePolicyManager.ACTION_SET_NEW_PASSWORD)) + } + + @TargetApi(Build.VERSION_CODES.P) + private fun Context.showBiometricSettings() { + val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Settings.ACTION_BIOMETRIC_ENROLL + } else { + @Suppress("DEPRECATION") + Settings.ACTION_FINGERPRINT_ENROLL + } + startActivity(Intent(action)) + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/AppAccessViewState.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/AppAccessViewState.kt new file mode 100644 index 00000000..9b78a7aa --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/AppAccessViewState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewstate + +import androidx.compose.runtime.Immutable + +@Immutable +data class AppAccessViewState( + val title: String, + val enabledOption: AccessOption, +) + +enum class AccessOption { + NONE, + SYSTEM, +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/AutoLockDurationsViewState.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/AutoLockDurationsViewState.kt new file mode 100644 index 00000000..27ea18b9 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/AutoLockDurationsViewState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewstate + +import kotlin.time.Duration + +data class AutoLockDurationsViewState( + val title: String, + val durations: Set, + val selected: Duration, +) diff --git a/app/src/main/kotlin/me/proton/android/drive/usecase/CleanUpAccount.kt b/app/src/main/kotlin/me/proton/android/drive/usecase/CleanUpAccount.kt index d7ed4580..f457a5bc 100644 --- a/app/src/main/kotlin/me/proton/android/drive/usecase/CleanUpAccount.kt +++ b/app/src/main/kotlin/me/proton/android/drive/usecase/CleanUpAccount.kt @@ -18,6 +18,7 @@ package me.proton.android.drive.usecase +import me.proton.android.drive.lock.domain.usecase.DisableAppLock import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.domain.usecase.DeleteAllFolders import me.proton.core.drive.drivelink.crypto.domain.usecase.RemoveAllDecryptedText @@ -28,11 +29,13 @@ class CleanUpAccount @Inject constructor( private val deleteAllFolders: DeleteAllFolders, private val removeAllKeys: RemoveAllKeys, private val removeAllDecryptedText: RemoveAllDecryptedText, + private val disableAppLock: DisableAppLock, ) { suspend operator fun invoke(userId: UserId) { deleteAllFolders(userId) removeAllKeys(userId) removeAllDecryptedText(userId) + disableAppLock(userAuthenticationRequired = false) } } diff --git a/app/src/main/kotlin/me/proton/android/drive/usecase/GetDocumentsProviderRootsImpl.kt b/app/src/main/kotlin/me/proton/android/drive/usecase/GetDocumentsProviderRootsImpl.kt new file mode 100644 index 00000000..d7606952 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/usecase/GetDocumentsProviderRootsImpl.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.usecase + +import kotlinx.coroutines.flow.first +import me.proton.android.drive.lock.domain.manager.AppLockManager +import me.proton.core.account.domain.entity.Account +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.drive.documentsprovider.domain.usecase.GetDocumentsProviderRoots +import javax.inject.Inject + +class GetDocumentsProviderRootsImpl @Inject constructor( + private val appLockManager: AppLockManager, + private val accountManager: AccountManager, +) : GetDocumentsProviderRoots { + + override suspend fun invoke(): List = takeUnless { appLockManager.isEnabled() } + ?.let { accountManager.getAccounts().first() }.orEmpty() +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/CreateFolderRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/CreateFolderRobot.kt index 669870b8..f8d4da7d 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/CreateFolderRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/CreateFolderRobot.kt @@ -18,34 +18,22 @@ package me.proton.android.drive.ui.robot -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasAnyAncestor -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import me.proton.core.drive.files.presentation.component.files.FilesListItemComponentTestTag import me.proton.core.drive.folder.create.presentation.CreateFolderComponentTestTag +import me.proton.test.fusion.Fusion.node import me.proton.core.drive.base.presentation.R as BasePresentation object CreateFolderRobot : Robot { - private val createFolderScreen get() = node(hasTestTag(CreateFolderComponentTestTag.screen)) - private val cancelButton get() = node(hasTextResource(BasePresentation.string.common_cancel_action)) - private val createButton get() = node(hasTextResource(BasePresentation.string.common_create_action)) - private val folderNameField - get() = node( - hasSetTextAction(), - hasAnyAncestor(hasTestTag(CreateFolderComponentTestTag.folderNameTextField)) - ) + private val createFolderScreen get() = node.withTag(CreateFolderComponentTestTag.screen) + private val cancelButton get() = node.withText(BasePresentation.string.common_cancel_action) + private val createButton get() = node.withText(BasePresentation.string.common_create_action) + private val folderNameField get() = node.isSetText().hasAncestor( + node.withTag(CreateFolderComponentTestTag.folderNameTextField) + ) - fun clickCancel() = cancelButton.tryToClickAndGoTo(FilesTabRobot) - fun typeFolderName(text: String) = folderNameField.tryToTypeText(text, CreateFolderRobot) - fun clearName() = folderNameField.clearText(CreateFolderRobot) - - fun clickCreate() { - createButton.tryToClickAndGoTo(this) - } + fun clickCancel() = cancelButton.clickTo(FilesTabRobot) + fun typeFolderName(text: String) = apply { folderNameField.typeText(text) } + fun clearName() = apply { folderNameField.clearText() } + fun clickCreate() = Unit.also { createButton.clickTo(this) } override fun robotDisplayed() { createFolderScreen.assertIsDisplayed() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/FileFolderOptionsRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/FileFolderOptionsRobot.kt index e844d495..4e45a904 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/FileFolderOptionsRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/FileFolderOptionsRobot.kt @@ -18,22 +18,22 @@ package me.proton.android.drive.ui.robot -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasTestTag +import me.proton.test.fusion.Fusion.node import me.proton.android.drive.ui.dialog.FileFolderOptionsDialogTestTag import me.proton.core.drive.base.presentation.R -object FileFolderOptionsRobot : Robot { - private val fileFolderOptionsScreen get() = node(hasTestTag(FileFolderOptionsDialogTestTag.fileOrFolderOptions)) - private val moveButton get() = node(hasTextResource(R.string.files_move_file_action)) - private val moveToTrash get() = node(hasTextResource(R.string.files_send_to_trash_action)) - private val makeAvailableOfflineButton get() = node(hasTextResource(R.string.title_make_available_offline)) - private val getLinkButton get() = node(hasTextResource(R.string.title_get_link)) - private val renameButton get() = node(hasTextResource(R.string.files_rename_file_action)) - private val folderDetailsButton get() = node(hasTextResource(R.string.files_display_folder_info_action)) - fun clickMove() = moveButton.tryToClickAndGoTo(MoveToFolderRobot) - fun clickRename() = renameButton.tryToClickAndGoTo(RenameRobot) +object FileFolderOptionsRobot : Robot { + private val fileFolderOptionsScreen get() = node.withTag(FileFolderOptionsDialogTestTag.fileOrFolderOptions) + private val moveButton get() = node.withText(R.string.files_move_file_action) + private val moveToTrash get() = node.withText(R.string.files_send_to_trash_action) + private val makeAvailableOfflineButton get() = node.withText(R.string.title_make_available_offline) + private val getLinkButton get() = node.withText(R.string.title_get_link) + private val renameButton get() = node.withText(R.string.files_rename_file_action) + private val folderDetailsButton get() = node.withText(R.string.files_display_folder_info_action) + + fun clickMove() = moveButton.clickTo(MoveToFolderRobot) + fun clickRename() = renameButton.clickTo(RenameRobot) override fun robotDisplayed() { fileFolderOptionsScreen.assertIsDisplayed() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/FilesTabRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/FilesTabRobot.kt index d82d62a9..3db5ce04 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/FilesTabRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/FilesTabRobot.kt @@ -18,76 +18,59 @@ package me.proton.android.drive.ui.robot -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasAnyChild -import androidx.compose.ui.test.hasAnySibling -import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.hasTextExactly -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeUp + import me.proton.core.drive.base.presentation.R import me.proton.core.drive.files.presentation.component.FilesTestTag -import me.proton.core.drive.files.presentation.component.files.FilesListItemComponentTestTag +import me.proton.core.drive.files.presentation.component.files.FilesListItemComponentTestTag.threeDotsButton +import me.proton.core.drive.files.presentation.component.files.FilesListItemComponentTestTag.item import me.proton.core.drive.files.presentation.component.files.FilesListItemComponentTestTag.ItemType -import me.proton.core.test.android.instrumented.utils.StringUtils +import me.proton.test.fusion.Fusion.allNodes +import me.proton.test.fusion.Fusion.node +import me.proton.test.fusion.ui.common.enums.SwipeDirection object FilesTabRobot : HomeRobot { - private val uploadFileDesc = - StringUtils.stringFromResource(R.string.files_upload_content_description_upload_file) - private val plusButton get() = node(hasContentDescription(uploadFileDesc)) - private val filesListItems get() = nodes(hasTestTag(FilesListItemComponentTestTag.item)) - + private val plusButton get() = node.withContentDescription(R.string.files_upload_content_description_upload_file) + private fun itemWithName(name: String) = node.withTag(item).withText(name) + private val fileList get() = node.withTag(FilesTestTag.content) private fun moreButton(itemName: String, itemType: ItemType) = - node( - hasTestTag(FilesListItemComponentTestTag.threeDotsButton(itemType)), - hasAnySibling(hasTextExactly(itemName)) - ) - - private val filesContent get() = node(hasTestTag(FilesTestTag.content)) + node + .withTag(threeDotsButton(itemType)) + .hasSibling(node.withText(itemName)) fun itemWithTextDisplayed(text: String) { - filesListItems.filter(hasText(text)).assertCountEquals(1) + itemWithName(text).await { assertIsDisplayed() } } - fun swipeUpToItemWithName(itemName: String): FilesTabRobot = waitFor(this) { - try { - node(hasText(itemName)).assertIsDisplayed() - } catch (error: AssertionError) { - filesContent.performTouchInput { swipeUp(durationMillis = 1000L) } - throw error - } + fun scrollToItemWithName(itemName: String): FilesTabRobot = apply { + allNodes.withTag(item).assertAny(node.isEnabled()) + fileList.scrollTo(node.withText(itemName)) } - fun clickPlusButton() = plusButton.tryToClickAndGoTo(ParentFolderOptionsRobot) + fun clickPlusButton() = plusButton.clickTo(ParentFolderOptionsRobot) fun clickMoreOnItem(title: String) = - node( - hasTestTag(FilesListItemComponentTestTag.threeDotsButton(ItemType.File)) or hasTestTag(FilesListItemComponentTestTag.threeDotsButton(ItemType.Folder)), - hasAnySibling(hasTextExactly(title)) - ).tryToClickAndGoTo(FileFolderOptionsRobot) + node + .withAnyTag(threeDotsButton(ItemType.File), threeDotsButton(ItemType.Folder)) + .hasSibling(node.withText(title)) + .clickTo(FileFolderOptionsRobot) fun clickMoreOnFolder(title: String) = - moreButton(title, ItemType.Folder).tryToClickAndGoTo(FileFolderOptionsRobot) + moreButton(title, ItemType.Folder).clickTo(FileFolderOptionsRobot) fun clickMoreOnFile(title: String) = - moreButton(title, ItemType.File).tryToClickAndGoTo(FileFolderOptionsRobot) + moreButton(title, ItemType.File).clickTo(FileFolderOptionsRobot) fun clickOnFile(name: String) = - node( - hasTestTag(FilesListItemComponentTestTag.threeDotsButton(ItemType.File)), - hasAnyChild(hasText(name)) - ).tryToClickAndGoTo(PreviewRobot) + node + .withTag(threeDotsButton(ItemType.File)) + .hasChild(node.withText(name)) + .clickTo(this) fun clickOnFolder(name: String) = - node( - hasAnySibling(hasTestTag(FilesListItemComponentTestTag.threeDotsButton(ItemType.Folder))), - hasText(name) - ).tryToClickAndGoTo(FilesTabRobot) + node + .withText(name) + .hasSibling(node.withTag(threeDotsButton(ItemType.Folder))) + .clickTo(this) override fun robotDisplayed() { homeScreenDisplayed() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/HomeRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/HomeRobot.kt index d047fd85..3b17e553 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/HomeRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/HomeRobot.kt @@ -18,28 +18,26 @@ package me.proton.android.drive.ui.robot +import me.proton.test.fusion.Fusion.node import androidx.annotation.StringRes -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasAnyChild -import androidx.compose.ui.test.hasTestTag import me.proton.android.drive.ui.screen.HomeScreenTestTag import me.proton.core.drive.base.presentation.R import me.proton.core.drive.base.presentation.component.BottomNavigationComponentTestTag interface HomeRobot : Robot { - private val homeScreen get() = node(hasTestTag(HomeScreenTestTag.screen)) + private val homeScreen get() = node.withTag(HomeScreenTestTag.screen) val filesTab get() = tabWithText(R.string.title_files) val sharedTab get() = tabWithText(R.string.title_shared) - fun clickFilesTab() = filesTab.tryToClickAndGoTo(FilesTabRobot) - fun clickSharedTab() = sharedTab.tryToClickAndGoTo(SharedTabRobot) + fun clickFilesTab() = filesTab.clickTo(FilesTabRobot) + fun clickSharedTab() = sharedTab.clickTo(SharedTabRobot) fun homeScreenDisplayed() { homeScreen.assertIsDisplayed() } - private fun tabWithText(@StringRes textRes: Int) = node( - hasTestTag(BottomNavigationComponentTestTag.tab), - hasAnyChild(hasTextResource(textRes)), - ) + private fun tabWithText(@StringRes textRes: Int) = + node + .withTag(BottomNavigationComponentTestTag.tab) + .hasChild(node.withText(textRes)) } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/MoveToFolderRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/MoveToFolderRobot.kt index 12ccdde2..40994157 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/MoveToFolderRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/MoveToFolderRobot.kt @@ -18,38 +18,33 @@ package me.proton.android.drive.ui.robot -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeDown +import me.proton.test.fusion.Fusion.node import me.proton.android.drive.R import me.proton.android.drive.ui.screen.MoveToFolderScreenTestTag import me.proton.core.drive.files.presentation.component.FilesTestTag import me.proton.core.drive.files.presentation.component.files.FilesListItemComponentTestTag -import me.proton.core.test.android.instrumented.utils.StringUtils object MoveToFolderRobot : Robot { - private val moveToFolderScreen get() = node(hasTestTag(MoveToFolderScreenTestTag.screen)) - private val addFolderButton get() = node(hasTestTag(MoveToFolderScreenTestTag.plusFolderButton)) - private val cancelButton get() = node(hasText(StringUtils.stringFromResource(R.string.move_file_dismiss_action))) - private val moveButton get() = node(hasText(StringUtils.stringFromResource(R.string.move_file_confirm_action))) - private val filesListItems get() = nodes(hasTestTag(FilesListItemComponentTestTag.item)) - private val filesContent get() = node(hasTestTag(FilesTestTag.content)) + private val moveToFolderScreen get() = node.withTag(MoveToFolderScreenTestTag.screen) + private val addFolderButton get() = node.withTag(MoveToFolderScreenTestTag.plusFolderButton) + private val cancelButton get() = node.withText(R.string.move_file_dismiss_action) + private val moveButton get() = node.withText(R.string.move_file_confirm_action) + private val fileList get() = node.withTag(FilesTestTag.content) + private fun itemWithName(name: String) = + node.withTag(FilesListItemComponentTestTag.item).withText(name) - fun clickAddFolder() = addFolderButton.tryToClickAndGoTo(CreateFolderRobot) - fun clickCancel() = cancelButton.tryToClickAndGoTo(CreateFolderRobot) - fun clickMove() = moveButton.tryToClickAndGoTo(CreateFolderRobot) + fun clickAddFolder() = addFolderButton.clickTo(CreateFolderRobot) + fun clickCancel() = cancelButton.clickTo(CreateFolderRobot) + fun clickMove() = moveButton.clickTo(CreateFolderRobot) - fun swipeDown() = filesContent.tryPerformTouchInputAndGoTo(this) { swipeDown()} - - fun itemListWithTextDisplayed(text: String, count: Int = 1) { - filesListItems.filter(hasText(text)).assertCountEquals(count) + fun itemWithTextDisplayed(text: String) { + fileList.scrollTo(node.withText(text)) } override fun robotDisplayed() { moveToFolderScreen.assertIsDisplayed() + addFolderButton.assertIsDisplayed() + cancelButton.assertIsDisplayed() + moveButton.assertIsDisplayed() } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ParentFolderOptionsRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ParentFolderOptionsRobot.kt index 7fe79107..44ed4874 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ParentFolderOptionsRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ParentFolderOptionsRobot.kt @@ -18,18 +18,19 @@ package me.proton.android.drive.ui.robot +import me.proton.test.fusion.Fusion.node import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasTestTag import me.proton.android.drive.R import me.proton.android.drive.ui.dialog.ParentFolderOptionsDialogTestTag object ParentFolderOptionsRobot : Robot { - private val contextMenu get() = node(hasTestTag(ParentFolderOptionsDialogTestTag.contextMenu)) - private val createFolderButton get() = node(hasTextResource(R.string.folder_option_create_folder)) - private val uploadAFileButton get() = node(hasTextResource(R.string.folder_option_import_file)) - private val takePhotoButton get() = node(hasTextResource(R.string.folder_option_take_a_photo)) + private val contextMenu get() = node.withTag(ParentFolderOptionsDialogTestTag.contextMenu) + private val createFolderButton get() = node.withText(R.string.folder_option_create_folder) + private val uploadAFileButton get() = node.withText(R.string.folder_option_import_file) + private val takePhotoButton get() = node.withText(R.string.folder_option_take_a_photo) - fun clickCreateFolder() = createFolderButton.tryToClickAndGoTo(CreateFolderRobot) + fun clickCreateFolder() = createFolderButton.clickTo(CreateFolderRobot) override fun robotDisplayed() { contextMenu.assertIsDisplayed() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PreviewRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PreviewRobot.kt index 1d44b90d..76d32c9e 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PreviewRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PreviewRobot.kt @@ -18,6 +18,7 @@ package me.proton.android.drive.ui.robot +import me.proton.test.fusion.Fusion.node import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag @@ -25,18 +26,16 @@ import androidx.compose.ui.test.hasText import me.proton.core.drive.base.presentation.R import me.proton.core.drive.base.presentation.component.TopAppBarComponentTestTag import me.proton.core.drive.files.preview.presentation.component.PreviewComponentTestTag -import me.proton.core.test.android.instrumented.utils.StringUtils object PreviewRobot : Robot { - private val contextualButtonDesc = StringUtils.stringFromResource(R.string.content_description_more_options) - private val previewScreen get() = node(hasTestTag(PreviewComponentTestTag.screen)) - private val contextualButton get() = node(hasContentDescription(contextualButtonDesc)) + private val previewScreen get() = node.withTag(PreviewComponentTestTag.screen) + private val contextualButton get() = node.withContentDescription(R.string.content_description_more_options) - fun clickOnContextualButton() = contextualButton.tryToClickAndGoTo(FileFolderOptionsRobot) + fun clickOnContextualButton() = contextualButton.clickTo(FileFolderOptionsRobot) fun topBarWithTextDisplayed(itemName: String) { - node(hasTestTag(TopAppBarComponentTestTag.appBar) and hasText(text = itemName, substring = true)).assertExists() + node.withTag(TopAppBarComponentTestTag.appBar).withTextSubstring(itemName).assertExists() } override fun robotDisplayed() { diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/RenameRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/RenameRobot.kt index c7a3e2c1..1517cb5b 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/RenameRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/RenameRobot.kt @@ -18,28 +18,20 @@ package me.proton.android.drive.ui.robot -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasAnyAncestor -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.hasTestTag +import me.proton.test.fusion.Fusion.node import me.proton.core.drive.drivelink.rename.presentation.R import me.proton.core.drive.drivelink.rename.presentation.RenameScreenTestTag object RenameRobot : Robot { + private val renameScreen get() = node.withTag(RenameScreenTestTag.screen) + private val cancelRenameButton get() = node.withText(R.string.link_rename_dismiss_button) + private val confirmRenameButton get() = node.withText(R.string.link_rename_button) + private val renameTextField get() = node.isSetText().hasAncestor(node.withTag(RenameScreenTestTag.textField)) - private val renameScreen get() = node(hasTestTag(RenameScreenTestTag.screen)) - private val cancelRenameButton get() = node(hasTextResource(R.string.link_rename_dismiss_button)) - private val confirmRenameButton get() = node(hasTextResource(R.string.link_rename_button)) - private val renameTextField - get() = node( - hasSetTextAction(), - hasAnyAncestor(hasTestTag(RenameScreenTestTag.textField)) - ) - - fun clickCancel() = cancelRenameButton.tryToClickAndGoTo(FilesTabRobot) - fun clickRename() = confirmRenameButton.tryToClickAndGoTo(FilesTabRobot) - fun typeName(text: String) = renameTextField.tryToTypeText(text, RenameRobot) - fun clearName() = renameTextField.clearText(RenameRobot) + fun clickCancel() = cancelRenameButton.clickTo(FilesTabRobot) + fun clickRename() = confirmRenameButton.clickTo(FilesTabRobot) + fun typeName(text: String) = apply { renameTextField.typeText(text) } + fun clearName() = apply { renameTextField.clearText() } override fun robotDisplayed() { renameScreen.assertIsDisplayed() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/Robot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/Robot.kt index 23c9bc0d..8d9034a4 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/Robot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/Robot.kt @@ -18,183 +18,23 @@ package me.proton.android.drive.ui.robot -import android.app.Instrumentation -import android.graphics.Bitmap -import androidx.annotation.StringRes -import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.test.ComposeTimeoutException -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.SemanticsNodeInteractionCollection -import androidx.compose.ui.test.TouchInjectionScope -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.captureToImage -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.onFirst -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextClearance -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTouchInput -import androidx.test.platform.app.InstrumentationRegistry -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import me.proton.android.drive.ui.test.BaseTest.Companion.composeTestRule -import me.proton.android.drive.ui.test.BaseTest.Companion.screenshotLocation -import me.proton.android.drive.ui.test.BaseTest.Companion.testName +import me.proton.test.fusion.Fusion.node import me.proton.core.drive.folder.create.presentation.R -import me.proton.core.test.android.instrumented.ProtonTest.Companion.testTag import me.proton.core.test.android.instrumented.utils.StringUtils -import me.proton.core.util.kotlin.CoreLogger -import me.proton.core.util.kotlin.EMPTY_STRING -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.util.concurrent.TimeoutException -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds +import me.proton.test.fusion.ui.compose.builders.OnNode interface Robot { - val instrumentation: Instrumentation get() = InstrumentationRegistry.getInstrumentation() - val waitForScreenDuration: Duration get() = 10.seconds - val waitForItemToAppearInList: Duration get() = 30.seconds - val watchInterval: Duration get() = 100.milliseconds - val shouldUseUnmergedTree: Boolean get() = true - fun robotDisplayed() - fun node( - vararg semanticsMatchers: SemanticsMatcher, - useUnmergedTree: Boolean = shouldUseUnmergedTree, - ): SemanticsNodeInteraction = - composeTestRule.onNode(getFinalSemanticMatcher(semanticsMatchers), useUnmergedTree) - - fun nodes( - vararg semanticsMatchers: SemanticsMatcher, - useUnmergedTree: Boolean = shouldUseUnmergedTree, - ): SemanticsNodeInteractionCollection = - composeTestRule.onAllNodes(getFinalSemanticMatcher(semanticsMatchers), useUnmergedTree) - - private fun getFinalSemanticMatcher( - semanticsMatchers: Array - ): SemanticsMatcher { - var finalSemanticsMatcher = semanticsMatchers.first() - semanticsMatchers.drop(1).forEach { - finalSemanticsMatcher = finalSemanticsMatcher.and(it) - } - return finalSemanticsMatcher - } - - /** - * Common semantics matchers - */ - fun hasTextResource( - @StringRes resourceId: Int, - substring: Boolean = false, - ignoreCase: Boolean = false, - formatString: String = EMPTY_STRING - ): SemanticsMatcher = hasText( - StringUtils.stringFromResource(resourceId, substring, ignoreCase).format(formatString) - ) - - private fun successGrowler(itemName: String) = node( - hasText( - StringUtils.stringFromResource - (R.string.folder_create_successful, itemName)) - ) - - /** - * Common user actions - */ - fun SemanticsNodeInteraction.tryToClickAndGoTo(goesTo: T): T = - waitFor(goesTo) { performClick() } - - fun SemanticsNodeInteraction.tryToTypeText(text: String, goesTo: T): T = - waitFor(goesTo) { performTextInput(text) } - - fun SemanticsNodeInteraction.clearText(goesTo: T): T = - waitFor(goesTo) { performTextClearance() } - - fun SemanticsNodeInteraction.tryPerformTouchInputAndGoTo( - goesTo: T, - touchInput: TouchInjectionScope.() -> Unit - ): T = waitFor(goesTo) { performTouchInput { touchInput() } } + fun OnNode.clickTo(goesTo: T): T = goesTo.apply { click() } + /** Common actions **/ fun dismissSuccessGrowler(itemName: String, goesTo: T) = - successGrowler(itemName).tryToClickAndGoTo(goesTo) + node + .withText(StringUtils.stringFromResource(R.string.folder_create_successful, itemName)) + .clickTo(goesTo) - - /** - * Other extensions and Helpers - */ - fun waitFor( - goesTo: T, - timeout: Duration = waitForScreenDuration, - interval: Duration = watchInterval, - block: () -> Any, - ): T { - var error: Throwable = TimeoutException("Condition not met in ${timeout}ms") - try { - composeTestRule.waitForIdle() - composeTestRule.waitUntil(timeout.inWholeMilliseconds) { - try { - block() - true - } catch (e: AssertionError) { - // Thrown on Compose failed actions and assertions - error = handleTestError(e, 1) - runBlocking { delay(interval) } - false - } catch (e: IllegalStateException) { - // Thrown when Compose view is not ready - error = handleTestError(e, 0) - runBlocking { delay(interval) } - false - } - } - } catch (e: ComposeTimeoutException) { - CoreLogger.e(testTag, e) - composeTestRule - .onAllNodes(SemanticsMatcher("isRoot") { it.isRoot }) - .onFirst() - .screenshot("${screenshotLocation}/${testName.methodName}.png") - throw error - } - return goesTo - } - - private fun handleTestError(throwable: Throwable, messageLine: Int): Throwable { - val lines = throwable.message?.lines() - if (!lines.isNullOrEmpty() && messageLine < lines.size) { - CoreLogger.i(testTag, "Condition not yet met: ${lines[messageLine]}") - return throwable - } - val message = "Could not extract message line at position $messageLine. Printing full message" - CoreLogger.e(testTag, throwable, message) - return throwable - } - - private fun SemanticsNodeInteraction.screenshot(file: String) { - try { - val bitmap = captureToImage().asAndroidBitmap() - FileOutputStream(file).use { destination -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, destination) - } - } catch (throwable: Throwable) { - when (throwable) { - is FileNotFoundException -> "File not found" - is OutOfMemoryError -> "Out of memory" - else -> "Unknown error" - }.let { - CoreLogger.e(testTag, throwable, "Could not take screenshot: $it") - } - } - } - - fun nodeWithTextDisplayed(@StringRes stringRes: String) { - node(hasText(stringRes)).assertIsDisplayed() - } - - fun nodeWithTextResourceDisplayed(@StringRes stringRes: Int, formatAttr: Any = EMPTY_STRING) { - node(hasTextResource(stringRes, formatString = formatAttr.toString())).assertIsDisplayed() - } + /** Common assertions **/ + fun nodeWithTextDisplayed(text: String) = + node.withText(text).await { assertIsDisplayed() } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/SharedTabRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/SharedTabRobot.kt index a7089737..52af5630 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/SharedTabRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/SharedTabRobot.kt @@ -18,15 +18,13 @@ package me.proton.android.drive.ui.robot -import androidx.compose.ui.test.assertCountEquals +import me.proton.test.fusion.Fusion.allNodes +import me.proton.test.fusion.Fusion.node import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText import me.proton.core.drive.files.presentation.component.files.FilesListItemComponentTestTag object SharedTabRobot : HomeRobot { - private val filesListItems get() = nodes(hasTestTag(FilesListItemComponentTestTag.item)) + private val filesListItems get() = allNodes.withTag(FilesListItemComponentTestTag.item) override fun robotDisplayed() { homeScreenDisplayed() @@ -34,6 +32,6 @@ object SharedTabRobot : HomeRobot { } fun itemWithTextDisplayed(text: String) { - filesListItems.filter(hasText(text)).assertCountEquals(1) + filesListItems.filter(node.withText(text)).assertCountEquals(1) } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/WelcomeRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/WelcomeRobot.kt index 3b5470ff..60c75b11 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/WelcomeRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/WelcomeRobot.kt @@ -18,33 +18,31 @@ package me.proton.android.drive.ui.robot -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.onFirst -import androidx.compose.ui.test.swipeLeft -import androidx.compose.ui.test.swipeRight +import me.proton.test.fusion.Fusion.node +import me.proton.test.fusion.Fusion.allNodes import me.proton.android.drive.ui.screen.WelcomeScreenTestTag +import me.proton.test.fusion.ui.common.enums.SwipeDirection import me.proton.core.drive.base.presentation.R as BasePresentation import me.proton.core.presentation.R as CorePresentation object WelcomeRobot : Robot { - private val skipButton get() = nodes(hasTextResource(CorePresentation.string.presentation_skip)).onFirst() - private val nextButton get() = nodes(hasTextResource(BasePresentation.string.common_next_action)).onFirst() - private val getStartedButton get() = node(hasTextResource(BasePresentation.string.welcome_get_started_action)) - private val welcomeLabel get() = node(hasTextResource(BasePresentation.string.welcome_to_description)) - private val filesTitle get() = node(hasTextResource(BasePresentation.string.title_welcome_files)) - private val titleSharing get() = node(hasTextResource(BasePresentation.string.title_welcome_sharing)) - private val filesDescription get() = node(hasTextResource(BasePresentation.string.welcome_files_description)) - private val sharingDescription get() = node(hasTextResource(BasePresentation.string.welcome_sharing_description)) - private val welcomeScreen get() = node(hasTestTag(WelcomeScreenTestTag.screen)) + private val skipButton get() = allNodes.withText(CorePresentation.string.presentation_skip).onFirst() + private val nextButton get() = allNodes.withText(BasePresentation.string.common_next_action).onFirst() + private val getStartedButton get() = node.withText(BasePresentation.string.welcome_get_started_action) + private val welcomeLabel get() = node.withText(BasePresentation.string.welcome_to_description) + private val filesTitle get() = node.withText(BasePresentation.string.title_welcome_files) + private val titleSharing get() = node.withText(BasePresentation.string.title_welcome_sharing) + private val filesDescription get() = node.withText(BasePresentation.string.welcome_files_description) + private val sharingDescription get() = node.withText(BasePresentation.string.welcome_sharing_description) + private val welcomeScreen get() = node.withTag(WelcomeScreenTestTag.screen) - fun clickNext() = nextButton.tryToClickAndGoTo(this) - fun clickSkip() = skipButton.tryToClickAndGoTo(FilesTabRobot) - fun clickGetStarted() = getStartedButton.tryToClickAndGoTo(FilesTabRobot) - fun swipeLeft() = welcomeScreen.tryPerformTouchInputAndGoTo(this) { this.swipeLeft() } - fun swipeRight() = welcomeScreen.tryPerformTouchInputAndGoTo(this) { this.swipeRight() } + fun clickNext() = nextButton.clickTo(this) + fun clickSkip() = skipButton.clickTo(FilesTabRobot) + fun clickGetStarted() = getStartedButton.clickTo(FilesTabRobot) + fun swipeLeft() = apply { welcomeScreen.swipe(SwipeDirection.Left) } + fun swipeRight() = apply { welcomeScreen.swipe(SwipeDirection.Right) } override fun robotDisplayed() { - welcomeScreen.assertIsDisplayed() + welcomeScreen.await { assertIsDisplayed() } } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/LogoutAllRule.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/LogoutAllRule.kt index 3e1488f3..abd27516 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/LogoutAllRule.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/LogoutAllRule.kt @@ -20,13 +20,17 @@ package me.proton.android.drive.ui.rules import kotlinx.coroutines.runBlocking import me.proton.android.drive.ui.test.BaseTest.Companion.loginTestHelper +import me.proton.android.drive.ui.test.BaseTest.Companion.quark import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement class LogoutAllRule : TestRule { override fun apply(base: Statement, description: Description): Statement { - runBlocking { loginTestHelper.logoutAll() } + runBlocking { + quark.jailUnban() + loginTestHelper.logoutAll() + } return base } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/BaseTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/BaseTest.kt index 824d85d2..d5f50904 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/BaseTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/BaseTest.kt @@ -19,26 +19,25 @@ package me.proton.android.drive.ui.test import android.app.Application -import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry import dagger.hilt.android.EntryPointAccessors import me.proton.android.drive.test.BuildConfig import me.proton.android.drive.ui.MainActivity import me.proton.android.drive.ui.robot.Robot import me.proton.android.drive.ui.rules.LogoutAllRule +import me.proton.android.drive.ui.toolkits.screenshot import me.proton.core.auth.presentation.testing.ProtonTestEntryPoint import me.proton.core.test.quark.Quark import me.proton.core.test.quark.data.User import me.proton.core.util.kotlin.deserialize import me.proton.core.util.kotlin.deserializeList +import me.proton.test.fusion.FusionConfig import org.junit.Rule import org.junit.rules.RuleChain import org.junit.rules.TestName - -typealias AndroidComposeRule = AndroidComposeTestRule, MainActivity> +import java.util.concurrent.atomic.AtomicInteger open class BaseTest { @Rule @@ -46,17 +45,19 @@ open class BaseTest { val ruleChain: RuleChain = RuleChain.outerRule(testName) @get:Rule - val composeTestRule: AndroidComposeRule = createAndroidComposeRule() + val composeTestRule = createAndroidComposeRule() @get:Rule(order = 0) val logoutAllRule = LogoutAllRule() init { - setGlobalComposeRule(composeTestRule) + FusionConfig.Compose.testRule.set(composeTestRule) + FusionConfig.Compose.useUnmergedTree.set(true) + FusionConfig.Compose.onFailure = { screenshot() } + screenshotCounter.set(0) } - inline fun T. verify(crossinline block: T.() -> Any): T = - apply { waitFor(this) { block() } } + inline fun T.verify(crossinline block: T.() -> Any): T = apply { block() } companion object { private val protonTestEntryPoint by lazy { @@ -73,17 +74,11 @@ open class BaseTest { ) } - private var globalComposeRule: AndroidComposeRule? = null - val loginTestHelper by lazy { protonTestEntryPoint.loginTestHelper } val uiTestHelper by lazy { uiTestEntryPoint.uiTestHelper } val testName = TestName() - val screenshotLocation: String get() = "/sdcard/Pictures" - val composeTestRule: AndroidComposeRule - get() = globalComposeRule - ?: throw AssertionError( - "Compose test rule was not set. Make sure to call setGlobalComposeRule(rule) first" - ) + val screenshotLocation get() = "/sdcard/Pictures/Screenshots/${testName.methodName}/" + val screenshotCounter = AtomicInteger(0) // TODO: before publishing to github, this information should be moved from assets into gitlab vars val users = User.Users(InstrumentationRegistry.getInstrumentation().context @@ -102,9 +97,5 @@ open class BaseTest { .bufferedReader() .use { it.readText() } .deserialize()) - - fun setGlobalComposeRule(rule: AndroidComposeRule) { - globalComposeRule = rule - } } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/CreatingFolderFlowErrorTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/CreatingFolderFlowErrorTest.kt index 7aec11a1..db787b96 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/CreatingFolderFlowErrorTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/CreatingFolderFlowErrorTest.kt @@ -35,7 +35,8 @@ import org.junit.runners.Parameterized class CreatingFolderFlowErrorTest( private val folderName: String, private val errorMessage: String, -): BaseTest() { + private val friendlyName: String +) : BaseTest() { private val user get() = User( @@ -64,11 +65,15 @@ class CreatingFolderFlowErrorTest( } companion object { - @get:Parameterized.Parameters(name = "folderName={0}, errorMessage={2}") + @get:Parameterized.Parameters(name = "{2}") @get:JvmStatic val data = listOf( - arrayOf("folder1", "A file or folder with that name already exists"), - arrayOf(getRandomString(256), StringUtils.stringFromResource(R.string.common_error_name_too_long, 255)), + arrayOf("folder1", "A file or folder with that name already exists", "alreadyExists"), + arrayOf( + getRandomString(256), + StringUtils.stringFromResource(R.string.common_error_name_too_long, 255), + "tooLongFilename" + ), ) } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/CreatingFolderFlowSuccessTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/CreatingFolderFlowSuccessTest.kt index 8145ddc6..bfdb1a52 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/CreatingFolderFlowSuccessTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/CreatingFolderFlowSuccessTest.kt @@ -53,9 +53,12 @@ class CreatingFolderFlowSuccessTest : BaseTest() { val newFolderName = getRandomString() FilesTabRobot - .swipeUpToItemWithName(subFolderName) + .scrollToItemWithName(subFolderName) .clickMoreOnFolder(subFolderName) .clickMove() + .verify { + robotDisplayed() + } .clickAddFolder() .typeFolderName(newFolderName) .clickCreate() @@ -63,10 +66,7 @@ class CreatingFolderFlowSuccessTest : BaseTest() { CreateFolderRobot .dismissSuccessGrowler(newFolderName, MoveToFolderRobot) .verify { - // Workaround (possibly a bug?) - // For some reason does not auto swipe to top on emulators only - swipeDown() - itemListWithTextDisplayed(newFolderName) + itemWithTextDisplayed(newFolderName) } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFileSuccessFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFileSuccessFlowTest.kt index 4ff26b3c..476c2a5f 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFileSuccessFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFileSuccessFlowTest.kt @@ -54,7 +54,7 @@ class RenamingFileSuccessFlowTest: BaseTest() { val newName = "picture.jpg" FilesTabRobot - .swipeUpToItemWithName(oldName) + .scrollToItemWithName(oldName) .clickOnFile(oldName) .verify { robotDisplayed() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFlowSuccessTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFlowSuccessTest.kt index d7179faa..c086ee2b 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFlowSuccessTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFlowSuccessTest.kt @@ -50,7 +50,7 @@ class RenamingFlowSuccessTest( @Test fun renameSuccess() { FilesTabRobot - .swipeUpToItemWithName(itemToBeRenamed) + .scrollToItemWithName(itemToBeRenamed) .clickMoreOnItem(itemToBeRenamed) .clickRename() .clearName() @@ -62,7 +62,7 @@ class RenamingFlowSuccessTest( } companion object { - @get:Parameterized.Parameters(name = "folderToBeRenamed={0}, newFolderName={1}") + @get:Parameterized.Parameters(name = "folderToBeRenamed={0}_newFolderName={1}") @get:JvmStatic val data = listOf( arrayOf("folder1", "...folder1"), diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFolderFlowErrorTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFolderFlowErrorTest.kt index cea09cec..cc30ba72 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFolderFlowErrorTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/RenamingFolderFlowErrorTest.kt @@ -36,7 +36,8 @@ class RenamingFolderFlowErrorTest( private val itemToBeRenamed: String, private val newItemName: String, private val errorMessage: String, -): BaseTest() { + private val friendlyName: String +) : BaseTest() { private val user get() = User( @@ -53,7 +54,7 @@ class RenamingFolderFlowErrorTest( @Test fun renameError() { FilesTabRobot - .swipeUpToItemWithName(itemToBeRenamed) + .scrollToItemWithName(itemToBeRenamed) .clickMoreOnFolder(itemToBeRenamed) .clickRename() .clearName() @@ -65,13 +66,33 @@ class RenamingFolderFlowErrorTest( } companion object { - @get:Parameterized.Parameters(name = "folderToBeRenamed={0}, newFolderName={1}, errorMessage={2}") + @get:Parameterized.Parameters(name = "{3}") @get:JvmStatic val data = listOf( - arrayOf("folder1", "folder2", "An item with that name already exists in current folder"), - arrayOf("folder1", "", StringUtils.stringFromResource(R.string.common_error_name_is_blank)), - arrayOf("folder1", getRandomString(256), StringUtils.stringFromResource(R.string.common_error_name_too_long, 255)), - arrayOf("folder2", "folder1", "An item with that name already exists in current folder"), + arrayOf( + "folder1", + "folder2", + "An item with that name already exists in current folder", + "Existing folder" + ), + arrayOf( + "folder1", + "", + StringUtils.stringFromResource(R.string.common_error_name_is_blank), + "Empty folder name" + ), + arrayOf( + "folder1", + getRandomString(256), + StringUtils.stringFromResource(R.string.common_error_name_too_long, 255), + "Very long name" + ), + arrayOf( + "folder2", + "folder1", + "An item with that name already exists in current folder", + "Existing folder 2" + ) ) } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/toolkits/Helpers.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/toolkits/Helpers.kt index 27e4d781..35219739 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/toolkits/Helpers.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/toolkits/Helpers.kt @@ -18,9 +18,56 @@ package me.proton.android.drive.ui.toolkits +import android.graphics.Bitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onFirst +import androidx.test.platform.app.InstrumentationRegistry +import me.proton.android.drive.ui.test.BaseTest +import me.proton.core.test.android.instrumented.ProtonTest +import me.proton.core.util.kotlin.CoreLogger +import me.proton.test.fusion.FusionConfig +import java.io.FileNotFoundException +import java.io.FileOutputStream + fun getRandomString(length: Int = 10): String { val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9').shuffled() return (1..length) .map { allowedChars.random() } .joinToString("") } + +fun BaseTest.Companion.screenshot() { + val screenshotNumber = screenshotCounter.getAndIncrement() + val fileName = "${screenshotLocation}/${screenshotNumber}_${testName.methodName}.png" + + if (screenshotNumber == 0) { + InstrumentationRegistry + .getInstrumentation() + .uiAutomation + .executeShellCommand("mkdir -p $screenshotLocation") + } + + try { + FusionConfig.Compose.testRule.get() + .onAllNodes(SemanticsMatcher("isRoot") { it.isRoot }) + .onFirst() + .captureToImage() + .asAndroidBitmap() + .let { bitmap -> + FileOutputStream(fileName).use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + + } catch (throwable: Throwable) { + when (throwable) { + is FileNotFoundException -> "File not found" + is OutOfMemoryError -> "Out of memory" + else -> "Unknown error" + }.let { + CoreLogger.e(ProtonTest.testTag, throwable, "Could not take screenshot: $it") + } + } +} diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index b8c1bb04..343de68a 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -22,7 +22,7 @@ object Config { const val minSdk = 23 const val targetSdk = 33 const val testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - const val versionName = "1.0.4" + const val versionName = "1.1.0" const val archivesBaseName = "ProtonDrive-$versionName" val resourceConfigurations = listOf("en") } diff --git a/buildSrc/src/main/kotlin/DeleteTestPlugin.kt b/buildSrc/src/main/kotlin/DeleteTestPlugin.kt index 6f4dd797..3abbdd76 100644 --- a/buildSrc/src/main/kotlin/DeleteTestPlugin.kt +++ b/buildSrc/src/main/kotlin/DeleteTestPlugin.kt @@ -1,7 +1,3 @@ -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.tasks.Delete - /* * Copyright (c) 2023 Proton AG. * This file is part of Proton Drive. @@ -20,6 +16,11 @@ import org.gradle.api.tasks.Delete * along with Proton Drive. If not, see . */ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.Delete + + class DeleteTestPlugin : Plugin { override fun apply(project: Project) { @@ -33,4 +34,4 @@ class DeleteTestPlugin : Plugin { ) } } -} \ No newline at end of file +} diff --git a/buildSrc/src/main/kotlin/DriveModule.kt b/buildSrc/src/main/kotlin/DriveModule.kt index 23723e02..df0f8851 100644 --- a/buildSrc/src/main/kotlin/DriveModule.kt +++ b/buildSrc/src/main/kotlin/DriveModule.kt @@ -100,6 +100,15 @@ fun Project.driveModule( extensions.findByType()?.apply { compileSdk = Config.compileSdk + + defaultConfig { + javaCompileOptions { + annotationProcessorOptions { + arguments["room.schemaLocation"] = "$projectDir/schemas" + } + } + } + buildTypes { debug { enableUnitTestCoverage = true @@ -189,7 +198,7 @@ fun Project.driveModule( } if (includeSubmodules) { val fullName = project.fullName - projectDir.findModules().forEach { module -> + projectDir.findModules().filterNot { it.endsWith("-test") }.forEach { module -> add("api", project(":$fullName$module")) } } @@ -202,7 +211,7 @@ fun Project.driveModule( } // first alpha and fourth beta were not tagged in git so we add them to list of all git tags -val Project.tags get() = "1.0.0-alpha01\n1.0.0_cancelled(16)\n1.0.0_cancelled(18)\n1.0.0_cancelled(20)\n1.0.0-beta04\n" + "git tag".runCommand(workingDir = rootDir) +val Project.tags get() = "1.0.0-alpha01\n1.0.0_cancelled(16)\n1.0.0_cancelled(18)\n1.0.0_cancelled(20)\n1.0.0-beta04\n1.0.3_iap(26)\n" + "git tag".runCommand(workingDir = rootDir) val Project.versionCodeFromTags: Int get() = tags.countSubstrings("\n") + 2 // last new line + next tag diff --git a/drive/base/data-test/build.gradle.kts b/drive/base/data-test/build.gradle.kts new file mode 100644 index 00000000..ab13ea8f --- /dev/null +++ b/drive/base/data-test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +plugins { + id("com.android.library") +} + +driveModule( + hilt = true, +) { + api(libs.core.domain) +} + +configureJacoco() \ No newline at end of file diff --git a/drive/base/data-test/src/main/AndroidManifest.xml b/drive/base/data-test/src/main/AndroidManifest.xml new file mode 100644 index 00000000..d88b8a21 --- /dev/null +++ b/drive/base/data-test/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + diff --git a/drive/base/data-test/src/main/kotlin/me/proton/core/drive/base/data/test/manager/StubbedWorkManager.kt b/drive/base/data-test/src/main/kotlin/me/proton/core/drive/base/data/test/manager/StubbedWorkManager.kt new file mode 100644 index 00000000..8628766d --- /dev/null +++ b/drive/base/data-test/src/main/kotlin/me/proton/core/drive/base/data/test/manager/StubbedWorkManager.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.base.data.test.manager + +import kotlinx.coroutines.flow.MutableStateFlow +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.ResponseSource +import me.proton.core.domain.arch.onSuccess +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StubbedWorkManager @Inject constructor() { + + var behavior: () -> DataResult = BEHAVIOR_SUCCESS + + data class Work( + val name: String, + val data: List + ) + + val works = MutableStateFlow(listOf()) + + fun add(name: String, vararg data: Any): DataResult = add(Work(name, listOf(*data))) + + private fun add(work: Work) = behavior().onSuccess { + works.value = works.value + work + } + + fun execute() { + works.value = emptyList() + } + + companion object { + val BEHAVIOR_SUCCESS = { DataResult.Success(ResponseSource.Local, "") } + val BEHAVIOR_ERROR = { DataResult.Error.Local("behavior_error", null) } + } +} + +fun StubbedWorkManager.assertHasWorks(name: String) { + if (works.value.none { it.name == name }) { + throw AssertionError("Does not contains work: $name in ${works.value.map { it.name }}") + } +} + +fun StubbedWorkManager.assertHasWork(name: String, vararg data: Any) { + val namedWorks = works.value.filter { it.name == name } + if (namedWorks.isEmpty()) { + throw AssertionError("Does not contains work: $name in ${works.value.map { it.name }}") + } + val dataAsList = listOf(*data) + val dataWorks = namedWorks.filter { it.data == dataAsList } + if (dataWorks.isEmpty()) { + throw AssertionError("Does not contains work: $name with the same data, expected:$dataAsList but was: ${namedWorks.map { it.data }}") + } else if (dataWorks.size > 1) { + throw AssertionError("Does contains more then one work: $name with data: $dataAsList") + } +} diff --git a/drive/base/data-test/src/test/kotlin/me/proton/core/drive/base/data/test/manager/StubbedWorkManagerTest.kt b/drive/base/data-test/src/test/kotlin/me/proton/core/drive/base/data/test/manager/StubbedWorkManagerTest.kt new file mode 100644 index 00000000..052f4690 --- /dev/null +++ b/drive/base/data-test/src/test/kotlin/me/proton/core/drive/base/data/test/manager/StubbedWorkManagerTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.base.data.test.manager + +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.ResponseSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class StubbedWorkManagerTest { + private val manager = StubbedWorkManager() + + @Test + fun `Given success behavior When add work without data Then returns success and succeed asserting`() { + manager.behavior = StubbedWorkManager.BEHAVIOR_SUCCESS + + val result = manager.add("name") + + assertEquals(DataResult.Success(ResponseSource.Local, ""), result) + manager.assertHasWorks("name") + } + + @Test + fun `Given success behavior When add work with data Then returns success and succeed asserting`() { + manager.behavior = StubbedWorkManager.BEHAVIOR_SUCCESS + + val result = manager.add("name", "data") + + assertEquals(DataResult.Success(ResponseSource.Local, ""), result) + manager.assertHasWork("name", "data") + } + + @Test + fun `Given error behavior When add work Then returns error and fail asserting`() { + manager.behavior = StubbedWorkManager.BEHAVIOR_ERROR + + val result = manager.add("name") + + assertEquals(DataResult.Error.Local("behavior_error", null), result) + assertThrows(AssertionError::class.java) { + manager.assertHasWorks("name") + } + } + + @Test + fun `Given a work When execute Then have no work`() { + manager.add("name") + + manager.execute() + + assertEquals(emptyList(), manager.works.value) + } + + @Test + fun `Given no work When asserting for works Then fail`() { + val exception = assertThrows(AssertionError::class.java) { + manager.assertHasWorks("name") + } + assertEquals( + "Does not contains work: name in []", + exception.message + ) + } + + @Test + fun `Given work with different name When asserting for work Then fail`() { + manager.add("different-name") + + val exception = assertThrows(AssertionError::class.java) { + manager.assertHasWork("name") + } + + assertEquals( + "Does not contains work: name in [different-name]", + exception.message + ) + } + + @Test + fun `Given two same works When asserting for one work Then fail`() { + manager.add("name", "data") + manager.add("name", "data") + + val exception = assertThrows(AssertionError::class.java) { + manager.assertHasWork("name", "data") + } + + assertEquals( + "Does contains more then one work: name with data: [data]", + exception.message + ) + } + + @Test + fun `Given two same works with different data When asserting for one work Then fail`() { + manager.add("name", "data1") + manager.add("name", "data2") + + manager.assertHasWork("name", "data1") + } + + @Test + fun `Given work with different data When asserting for work Then fail`() { + manager.add("name", "different-data") + + val exception = assertThrows(AssertionError::class.java) { + manager.assertHasWork("name", "data") + } + + assertEquals( + "Does not contains work: name with the same data, expected:[data] but was: [[different-data]]", + exception.message + ) + } +} \ No newline at end of file diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/api/RunCatchingApiException.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/api/RunCatchingApiException.kt index 816c9f18..f351b120 100644 --- a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/api/RunCatchingApiException.kt +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/api/RunCatchingApiException.kt @@ -17,15 +17,15 @@ */ package me.proton.core.drive.base.data.api -import me.proton.core.data.arch.toDataResult import me.proton.core.domain.arch.DataResult import me.proton.core.domain.arch.ResponseSource +import me.proton.core.drive.base.domain.extension.toDataResult import me.proton.core.network.domain.ApiException inline fun runCatchingApiException(block: () -> T): DataResult { return try { DataResult.Success(ResponseSource.Remote, block()) } catch (e: ApiException) { - e.error.toDataResult() + e.toDataResult() } } diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/Column.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/Column.kt index 017d73d1..6e07fbfe 100644 --- a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/Column.kt +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/Column.kt @@ -27,6 +27,8 @@ object Column { const val CONTENT_KEY_PACKET_SIGNATURE = "content_key_packet_signature" const val CREATION_TIME = "creation_time" const val CREATOR_EMAIL = "creatior_email" + const val DIGESTS = "digests" + const val DURATION = "duration" const val ENCRYPTED = "encrypted" const val ENCRYPTED_NAME = "encrypted_name" const val ENCRYPTED_SIGNATURE = "encrypted_signature" diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/paging/Flow.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/paging/Flow.kt index 1331403f..12fa8181 100644 --- a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/paging/Flow.kt +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/paging/Flow.kt @@ -22,19 +22,24 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.transformLatest import me.proton.core.drive.base.domain.extension.mapWithPrevious import me.proton.core.drive.base.domain.log.LogTag import me.proton.core.util.kotlin.CoreLogger import kotlin.coroutines.CoroutineContext +import kotlin.math.ceil /** * When using Room's [PagingSource] implementation, if the tables listened on are being updated @@ -74,7 +79,7 @@ fun Flow>>.asPagingSource( val pageList = list .onFailure { throwable -> val error = throwable.cause ?: throwable - CoreLogger.d(LogTag.PAGING, "load (key=$pageKey) from flow failed with $error") + CoreLogger.d(LogTag.PAGING, throwable, "load (key=$pageKey) from flow failed with $error") return LoadResult.Error(error) } .getOrThrow() @@ -93,7 +98,7 @@ fun Flow>>.asPagingSource( nextKey = nextKey, ) } catch (e: Throwable) { - CoreLogger.d(LogTag.PAGING, "load (key=$pageKey) from flow failed with ${e.cause ?: e}") + CoreLogger.d(LogTag.PAGING, e, "load (key=$pageKey) from flow failed with ${e.cause ?: e}") LoadResult.Invalid() } } @@ -103,3 +108,117 @@ object PagingSourceScope : CoroutineScope { override val coroutineContext: CoroutineContext get() = Dispatchers.IO + Job() } + +@OptIn(ExperimentalCoroutinesApi::class) +fun ((fromIndex: Int, count: Int) -> Flow>>).asPagingSource( + sourceSize: Flow, + observablePageSize: Int, + stopOnFailure: Boolean = true, + processPage: (suspend (List) -> List)? = null, +): PagingSource = + object : PagingSource() { + + private val itemsCount: StateFlow = sourceSize + .takeWhile { invalid.not() } + .distinctUntilChanged() + .mapWithPrevious { previous, current -> + if (previous != null) invalidate() + current + } + .stateIn(PagingSourceScope, SharingStarted.Eagerly, null) + + private val fromIndex = MutableStateFlow(null) + + private val listFlow: StateFlow>?> = fromIndex + .takeWhile { invalid.not() } + .filterNotNull() + .transformLatest { fromIndex -> + emitAll( + this@asPagingSource(fromIndex, observablePageSize) + .takeWhile { invalid.not() } + .distinctUntilChanged() + .mapWithPrevious { previous, current -> + if (previous != null && !(previous.isFailure && stopOnFailure)) { + invalidate() + } + current + } + ) + } + .stateIn(PagingSourceScope, SharingStarted.Eagerly, null) + + override fun getRefreshKey(state: PagingState): Int? = + state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + + override suspend fun load(params: LoadParams): LoadResult { + require(observablePageSize >= params.loadSize * 4) { + "Observable page size ($observablePageSize) must be at least 4 times as big as load page size (${params.loadSize})" + } + val pageKey = params.key ?: 0 + val items = itemsCount.filterNotNull().first() + val pageRange = rangeFromPage(pageKey, params.loadSize, items) + val currentIndex = fromIndex.value ?: findIndexForRange(pageRange, observablePageSize, items) + fromIndex.value = currentIndex + val list = listFlow.filterNotNull().first() + return try { + val pageList = list + .onFailure { throwable -> + val error = throwable.cause ?: throwable + CoreLogger.d(LogTag.PAGING, throwable, "load (key=$pageKey) from flow failed with $error") + return LoadResult.Error(error) + } + .getOrThrow() + val page = pageList.subList(pageRange.first - currentIndex, pageRange.last - currentIndex + 1) + val prevKey = (pageKey - 1).takeIf { key -> key >= 0 } + val nextKey = (pageKey + 1).takeIf { key -> key < ceil(items / params.loadSize.toDouble()).toInt() } + CoreLogger.d( + tag = LogTag.PAGING, + message = "load (key=$pageKey, items=${page.size}) from flow (items=${pageList.size}) nextKey=$nextKey prevKey=$prevKey" + ) + LoadResult.Page( + data = processPage?.invoke(page) ?: page, + prevKey = prevKey, + nextKey = nextKey, + ).also { + val rangeDirection = rangeDirection(items, currentIndex, observablePageSize, pageRange) + if (rangeDirection != RangeDirection.CURRENT) { + invalidate() + } + } + } catch (e: Throwable) { + CoreLogger.d(LogTag.PAGING, e, "load (key=$pageKey) from flow failed with ${e.cause ?: e}") + LoadResult.Invalid() + } + } + + private fun rangeFromPage(pageIndex: Int, pageSize: Int, itemsCount: Int): IntRange = + IntRange(pageIndex * pageSize, minOf(((pageIndex + 1) * pageSize) - 1, itemsCount - 1)) + + private fun rangeDirection(itemsCount: Int, fromIndex: Int, observablePageSize: Int, pageRange: IntRange): RangeDirection { + val hasPrevious = fromIndex > 0 + val hasNext = fromIndex + observablePageSize < itemsCount + val pageSize = pageRange.last - pageRange.first + 1 + return when { + !hasPrevious && !hasNext -> RangeDirection.CURRENT + hasPrevious && pageRange.first - pageSize <= fromIndex -> RangeDirection.PREVIOUS + hasNext && pageRange.last + pageSize >= fromIndex + observablePageSize -> RangeDirection.NEXT + else -> RangeDirection.CURRENT + } + } + + private fun findIndexForRange(pageRange: IntRange, observablePageSize: Int, itemsCount: Int): Int = when { + itemsCount <= observablePageSize -> 0 + pageRange.first <= observablePageSize / 2 -> 0 + itemsCount - pageRange.last <= observablePageSize / 2 -> itemsCount - observablePageSize + else -> pageRange.first - observablePageSize / 2 + } + } + +private enum class RangeDirection { + CURRENT, + PREVIOUS, + NEXT, +} diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/di/BaseBindModule.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/di/BaseBindModule.kt index d44a22e5..5ce3bda0 100644 --- a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/di/BaseBindModule.kt +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/di/BaseBindModule.kt @@ -21,17 +21,13 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoSet import me.proton.core.drive.base.data.formatter.DateTimeFormatterImpl -import me.proton.core.drive.base.data.provider.ExifImageResolutionProvider -import me.proton.core.drive.base.data.provider.MetadataRetrieverVideoResolutionProvider -import me.proton.core.drive.base.data.provider.MimeTypeProviderImpl import me.proton.core.drive.base.data.usecase.CopyToClipboardImpl +import me.proton.core.drive.base.data.usecase.GetMemoryInfoImpl import me.proton.core.drive.base.data.usecase.Sha256Impl import me.proton.core.drive.base.domain.formatter.DateTimeFormatter -import me.proton.core.drive.base.domain.provider.MediaResolutionProvider -import me.proton.core.drive.base.domain.provider.MimeTypeProvider import me.proton.core.drive.base.domain.usecase.CopyToClipboard +import me.proton.core.drive.base.domain.usecase.GetMemoryInfo import me.proton.core.drive.base.domain.usecase.Sha256 import javax.inject.Singleton @@ -50,4 +46,8 @@ interface BaseBindModule { @Binds @Singleton fun bindsSha256Impl(impl: Sha256Impl): Sha256 + + @Binds + @Singleton + fun bindsGetMemoryInfoImpl(impl: GetMemoryInfoImpl): GetMemoryInfo } diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/usecase/GetMemoryInfoImpl.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/usecase/GetMemoryInfoImpl.kt new file mode 100644 index 00000000..b0707893 --- /dev/null +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/usecase/GetMemoryInfoImpl.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.base.data.usecase + +import android.app.ActivityManager +import me.proton.core.drive.base.domain.entity.MemoryInfo +import me.proton.core.drive.base.domain.extension.bytes +import me.proton.core.drive.base.domain.usecase.GetMemoryInfo +import javax.inject.Inject + +class GetMemoryInfoImpl @Inject constructor( + private val activityManager: ActivityManager, +) : GetMemoryInfo { + override operator fun invoke(): Result = runCatching { + with( + ActivityManager.MemoryInfo().also { memoryInfo -> + activityManager.getMemoryInfo(memoryInfo) + } + ) { + MemoryInfo( + isLowOnMemory = lowMemory, + memoryClass = (activityManager.memoryClass * 1024 * 2024).bytes + ) + } + } +} diff --git a/drive/base/data/src/test/kotlin/me/proton/core/drive/base/data/db/paging/FlowTest.kt b/drive/base/data/src/test/kotlin/me/proton/core/drive/base/data/db/paging/FlowTest.kt new file mode 100644 index 00000000..cf6a0b15 --- /dev/null +++ b/drive/base/data/src/test/kotlin/me/proton/core/drive/base/data/db/paging/FlowTest.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.base.data.db.paging + +import androidx.paging.PagingSource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.runTest +import me.proton.core.test.kotlin.assertEquals +import org.junit.Assert +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PagingSourceTest { + private val backingData = listOf( + "first", + "second", + "third", + "fourth", + "fifth", + "sixth", + "seventh", + "eighth", + "ninth", + "tenth", + "eleventh", + "twelfth", + "thirteenth", + "fourteenth", + "fifteenth", + ) + private val source = MutableStateFlow(backingData) + private fun flowFromBackingData(fromIndex: Int, count: Int): Flow>> = flow { + val source = this@PagingSourceTest.source.value + emit( + when { + fromIndex < 0 || fromIndex >= source.count() -> Result.failure(IllegalArgumentException()) + else -> Result.success(source.subList(fromIndex, (fromIndex + count).coerceIn(0, source.size))) + } + ) + } + private fun pagingSource(observablePageSize: Int = DEFAULT_OBSERVABLE_PAGE_SIZE_S) = { fromIndex: Int, count: Int -> + flowFromBackingData(fromIndex, count) + }.asPagingSource( + sourceSize = source.map { backingData -> backingData.size }, + observablePageSize = observablePageSize, + ) + + @Test + fun `refresh paging list when observable page size is bigger then underlying data`() = runTest { + val pagingSource = pagingSource(maxOf(DEFAULT_OBSERVABLE_PAGE_SIZE_L, backingData.size)) + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("first", "second", "third"), + prevKey = null, + nextKey = 1, + ), + actual = pagingSource.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = LOAD_SIZE_M, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("thirteenth", "fourteenth", "fifteenth"), + prevKey = 1, + nextKey = null, + ), + actual = pagingSource.load( + PagingSource.LoadParams.Refresh( + key = 2, + loadSize = LOAD_SIZE_L, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + } + + @Test + fun `refresh paging list`() = runTest { + val pagingSource = pagingSource(DEFAULT_OBSERVABLE_PAGE_SIZE_M) + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("first", "second"), + prevKey = null, + nextKey = 1, + ), + actual = pagingSource.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = LOAD_SIZE_S, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("first", "second", "third"), + prevKey = null, + nextKey = 1, + ), + actual = pagingSource.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = LOAD_SIZE_M, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + } + + @Test + fun `append list with second page`() = runTest { + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("third", "fourth"), + prevKey = 0, + nextKey = 2, + ), + actual = pagingSource().load( + PagingSource.LoadParams.Append( + key = 1, + loadSize = LOAD_SIZE_S, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + } + + @Test + fun `append middle pages`() = runTest { + val pagingSource = pagingSource(DEFAULT_OBSERVABLE_PAGE_SIZE_M) + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("seventh", "eighth", "ninth"), + prevKey = 1, + nextKey = 3, + ), + actual = pagingSource.load( + PagingSource.LoadParams.Append( + key = 2, + loadSize = LOAD_SIZE_M, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("tenth", "eleventh", "twelfth"), + prevKey = 2, + nextKey = 4, + ), + actual = pagingSource.load( + PagingSource.LoadParams.Append( + key = 3, + loadSize = LOAD_SIZE_M, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + } + + @Test + fun `append list with last page`() = runTest { + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("fifteenth"), + prevKey = 6, + nextKey = null, + ), + actual = pagingSource().load( + PagingSource.LoadParams.Append( + key = 7, + loadSize = LOAD_SIZE_S, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + } + + @Test + fun `prepend list with third page`() = runTest { + assertEquals( + expected = PagingSource.LoadResult.Page( + data = listOf("fifth", "sixth"), + prevKey = 1, + nextKey = 3, + ), + actual = pagingSource().load( + PagingSource.LoadParams.Prepend( + key = 2, + loadSize = LOAD_SIZE_S, + placeholdersEnabled = false, + ) + ), + message = { "" }, + ) + } + + @Test + fun `load page size must be less then observablePageSize`() { + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + pagingSource(DEFAULT_OBSERVABLE_PAGE_SIZE_S).load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = DEFAULT_OBSERVABLE_PAGE_SIZE_S, + placeholdersEnabled = false, + ) + ) + } + } + } + + companion object { + const val LOAD_SIZE_S = 2 + const val LOAD_SIZE_M = 3 + const val LOAD_SIZE_L = 6 + const val DEFAULT_OBSERVABLE_PAGE_SIZE_S = LOAD_SIZE_S * 4 + const val DEFAULT_OBSERVABLE_PAGE_SIZE_M = LOAD_SIZE_M * 4 + const val DEFAULT_OBSERVABLE_PAGE_SIZE_L = LOAD_SIZE_L * 4 + } +} diff --git a/drive/base/domain/build.gradle.kts b/drive/base/domain/build.gradle.kts index 92251e83..59f67c2c 100644 --- a/drive/base/domain/build.gradle.kts +++ b/drive/base/domain/build.gradle.kts @@ -26,12 +26,12 @@ driveModule( api(project(":drive:message-queue:domain")) api(libs.core.cryptoCommon) api(libs.core.domain) - api(libs.core.user) + api(libs.core.user.domain) - implementation(libs.core.accountManager) + implementation(libs.core.accountManager.domain) implementation(libs.core.data) - implementation(libs.core.key) - implementation(libs.core.network) + implementation(libs.core.key.domain) + implementation(libs.core.network.domain) } configureJacoco() diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/entity/MemoryInfo.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/entity/MemoryInfo.kt new file mode 100644 index 00000000..ac0a877f --- /dev/null +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/entity/MemoryInfo.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.base.domain.entity + +data class MemoryInfo( + val isLowOnMemory: Boolean, + val memoryClass: Bytes, +) diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/ApiException.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/ApiException.kt new file mode 100644 index 00000000..a5932e33 --- /dev/null +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/ApiException.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.domain.extension + +import me.proton.core.domain.arch.DataResult +import me.proton.core.network.domain.ApiException +import me.proton.core.network.domain.ApiResult +import me.proton.core.util.kotlin.exhaustive + +fun ApiException.toDataResult() : DataResult.Error = when (val e = error) { + is ApiResult.Error.Http -> { + DataResult.Error.Remote( + message = e.proton?.error ?: message, + cause = this, + protonCode = e.proton?.code ?: 0, + httpCode = e.httpCode + ) + } + is ApiResult.Error.Parse -> DataResult.Error.Remote(cause?.message, this) + is ApiResult.Error.Connection -> DataResult.Error.Remote(cause?.message, this) +}.exhaustive diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Bytes.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Bytes.kt index 98d36e6b..ef3631b7 100644 --- a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Bytes.kt +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Bytes.kt @@ -20,6 +20,7 @@ package me.proton.core.drive.base.domain.extension import me.proton.core.drive.base.domain.entity.Bytes import java.io.File +inline val Int.GiB: Bytes get() = (this * 1_073_741_824L).bytes inline val Int.MiB: Bytes get() = (this * 1_048_576L).bytes inline val Int.KiB: Bytes get() = (this * 1_024L).bytes diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/provider/ConfigurationProvider.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/provider/ConfigurationProvider.kt index 4eed3f71..6011ecc9 100644 --- a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/provider/ConfigurationProvider.kt +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/provider/ConfigurationProvider.kt @@ -23,6 +23,8 @@ import me.proton.core.drive.base.domain.extension.MiB import me.proton.core.drive.base.domain.extension.bytes import kotlin.time.Duration import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds interface ConfigurationProvider { val host: String @@ -30,6 +32,7 @@ interface ConfigurationProvider { val appVersionHeader: String val uiPageSize: Int get() = 50 val apiPageSize: Int get() = 150 + val dbPageSize: Int get() = 500 val cacheMaxEntries: Int get() = 10_000 val linkMaxNameLength: Int get() = 255 val blockMaxSize: Bytes get() = 4.MiB @@ -49,4 +52,8 @@ interface ConfigurationProvider { val validateUploadLimit: Boolean get() = true val uploadLimitThreshold: Int get() = 250 val useExceptionMessage: Boolean get() = false + val digestAlgorithms: List get() = listOf("SHA1") + val autoLockDurations: Set get() = setOf( + 0.seconds, 60.seconds, 2.minutes, 5.minutes, 15.minutes, 30.minutes + ) } diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/repository/Fetcher.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/repository/Fetcher.kt index 649e5668..af28a865 100644 --- a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/repository/Fetcher.kt +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/repository/Fetcher.kt @@ -19,9 +19,9 @@ package me.proton.core.drive.base.domain.repository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.FlowCollector -import me.proton.core.data.arch.toDataResult import me.proton.core.domain.arch.DataResult import me.proton.core.domain.arch.ResponseSource +import me.proton.core.drive.base.domain.extension.toDataResult import me.proton.core.network.domain.ApiException suspend inline fun FlowCollector>.fetcher(fetchAction: () -> Unit) { @@ -31,7 +31,7 @@ suspend inline fun FlowCollector>.fetcher(fetchAction: () -> U } catch (e: CancellationException) { throw e } catch (e: ApiException) { - emit(e.error.toDataResult()) + emit(e.toDataResult()) } catch (e: RuntimeException) { emit(DataResult.Error.Local(e.message, e)) } @@ -44,7 +44,7 @@ suspend inline fun FlowCollector>>.listFetcherEmitOnEmpty } catch (e: CancellationException) { throw e } catch (e: ApiException) { - emit(e.error.toDataResult()) + emit(e.toDataResult()) } catch (e: RuntimeException) { emit(DataResult.Error.Local(e.message, e)) } diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/usecase/GetMemoryInfo.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/usecase/GetMemoryInfo.kt new file mode 100644 index 00000000..ad73d71a --- /dev/null +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/usecase/GetMemoryInfo.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.base.domain.usecase + +import me.proton.core.drive.base.domain.entity.MemoryInfo + +interface GetMemoryInfo { + operator fun invoke(): Result +} diff --git a/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/repository/FetcherTest.kt b/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/repository/FetcherTest.kt new file mode 100644 index 00000000..61cb98d9 --- /dev/null +++ b/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/repository/FetcherTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.domain.repository + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.ResponseSource +import me.proton.core.network.domain.ApiException +import me.proton.core.network.domain.ApiResult +import org.junit.Assert.* +import org.junit.Test +import java.net.UnknownHostException + +@OptIn(ExperimentalCoroutinesApi::class) +class FetcherTest { + + @Test + fun `Given success When fetch Then emit value`() = runTest { + val values = flow> { + fetcher { + emit(DataResult.Success(ResponseSource.Local, Unit)) + } + }.toList() + assertEquals(DataResult.Processing(ResponseSource.Remote), values[0]) + assertEquals(DataResult.Success(ResponseSource.Local, Unit), values[1]) + } + + @Test + fun `Given api exception When fetch Then emit error with api exception`() = runTest { + val errorMessage = "Unable to resolve host" + val value = flow> { + fetcher { + throw ApiException( + ApiResult.Error.Connection( + cause = UnknownHostException(errorMessage) + ) + ) + } + }.last() + + val error = value as DataResult.Error + assertEquals(errorMessage, error.message) + assertEquals(ResponseSource.Remote, error.source) + assertEquals(ApiException::class.java, error.cause?.javaClass) + } + + @Test + fun `Given runtime exception When fetch Then emit error with runtime exception`() = runTest { + val errorMessage = "error" + val value = flow> { + fetcher { + throw RuntimeException(errorMessage) + } + }.last() + + val error = value as DataResult.Error + assertEquals(errorMessage, error.message) + assertEquals(ResponseSource.Local, error.source) + assertEquals(RuntimeException::class.java, error.cause?.javaClass) + } + + @Test + fun `Given cancellation exception When fetch Then emit error with runtime exception`() { + val flow = flow> { + fetcher { + throw CancellationException() + } + } + assertThrows(CancellationException::class.java) { + runTest { flow.last() } + } + } +} \ No newline at end of file diff --git a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/OutlinedTextFieldWithError.kt b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/OutlinedTextFieldWithError.kt index 1c4cae51..d789012a 100644 --- a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/OutlinedTextFieldWithError.kt +++ b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/OutlinedTextFieldWithError.kt @@ -22,79 +22,16 @@ import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import me.proton.core.compose.theme.ProtonTheme import me.proton.core.compose.theme.caption import me.proton.core.compose.theme.default -@Composable -fun OutlinedTextFieldWithError( - text: String, - modifier: Modifier = Modifier, - selection: IntRange = IntRange(text.length, text.length), - errorText: String? = null, - focusRequester: FocusRequester = remember { FocusRequester() }, - maxLines: Int = MaxLines, - onValueChanged: (String) -> Unit, -) { - // This code is based on BasicTextField:122 - // Holds the latest internal TextFieldValue state. We need to keep it to have the correct value - // of the composition. - var textFieldValueState by remember { - mutableStateOf( - TextFieldValue( - text = text, - selection = TextRange(selection.first, selection.last) - ) - ) - } - // Holds the latest TextFieldValue that OutlinedTextFieldWithError was recomposed with. - // We couldn't simply pass `TextFieldValue(text = text, selection=[..])` to the CoreTextField - // because we need to preserve the composition. - val textFieldValue = textFieldValueState.copy( - text = text, - selection = TextRange(selection.first, selection.last), - ) - // Last String value that either text field was recomposed with or updated in the onValueChange - // callback. We keep track of it to prevent calling onValueChange(String) for same String when - // CoreTextField's onValueChange is called multiple times without recomposition in between. - var lastTextValue by remember(text) { mutableStateOf(text) } - - SideEffect { - if (textFieldValue.selection != textFieldValueState.selection || - textFieldValue.composition != textFieldValueState.composition - ) { - textFieldValueState = textFieldValue - } - } - OutlinedTextFieldWithError( - textFieldValue = textFieldValue, - modifier = modifier, - errorText = errorText, - focusRequester = focusRequester, - maxLines = maxLines, - ) { newTextFieldValueState -> - textFieldValueState = newTextFieldValueState - - val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text - lastTextValue = newTextFieldValueState.text - - if (stringChangedSinceLastInvocation) { - onValueChanged(newTextFieldValueState.text) - } - } -} - @Composable fun OutlinedTextFieldWithError( textFieldValue: TextFieldValue, diff --git a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/ProtonListItem.kt b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/ProtonListItem.kt index 11c6bed2..c21a433e 100644 --- a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/ProtonListItem.kt +++ b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/ProtonListItem.kt @@ -50,7 +50,8 @@ fun ProtonListItem( @StringRes title: Int, modifier: Modifier = Modifier, iconTitlePadding: Dp = ListItemTextStartPadding, -) = ProtonListItem(painterResource(icon), stringResource(title), modifier, iconTitlePadding) + iconTintColor: Color = ProtonTheme.colors.iconNorm, +) = ProtonListItem(painterResource(icon), stringResource(title), modifier, iconTitlePadding, iconTintColor) @Composable fun ProtonListItem( @@ -58,6 +59,7 @@ fun ProtonListItem( title: String, modifier: Modifier = Modifier, iconTitlePadding: Dp = ListItemTextStartPadding, + iconTintColor: Color = ProtonTheme.colors.iconNorm, ) { Row( modifier = modifier @@ -72,7 +74,7 @@ fun ProtonListItem( modifier = Modifier .size(DefaultIconSize), painter = icon, - tint = ProtonTheme.colors.iconNorm, + tint = iconTintColor, contentDescription = null, ) Text( diff --git a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/checkbox/CheckboxDefaults.kt b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Composable.kt similarity index 53% rename from drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/checkbox/CheckboxDefaults.kt rename to drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Composable.kt index 7ca0a108..902c5296 100644 --- a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/checkbox/CheckboxDefaults.kt +++ b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Composable.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Proton AG. + * Copyright (c) 2023 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -15,20 +15,15 @@ * You should have received a copy of the GNU General Public License * along with Proton Core. If not, see . */ -package me.proton.core.drive.base.presentation.component -import androidx.compose.material.CheckboxColors -import androidx.compose.material.CheckboxDefaults +package me.proton.core.drive.base.presentation.extension + +import android.content.res.Configuration import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import me.proton.core.compose.theme.ProtonTheme +import androidx.compose.ui.platform.LocalConfiguration -@Composable -fun CheckboxDefaults.protonColors(): CheckboxColors = - colors( - checkedColor = ProtonTheme.colors.interactionNorm, - uncheckedColor = ProtonTheme.colors.shade60, - checkmarkColor = Color.White, - disabledColor = ProtonTheme.colors.shade40, - disabledIndeterminateColor = ProtonTheme.colors.shade40, - ) +val isPortrait: Boolean @Composable get() = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT + +val isLandscape: Boolean @Composable get() = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE diff --git a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Modifier.kt b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Modifier.kt index 7dd8e081..519b2856 100644 --- a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Modifier.kt +++ b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Modifier.kt @@ -18,6 +18,13 @@ package me.proton.core.drive.base.presentation.extension import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp fun Modifier.conditional(condition: Boolean, block: Modifier.() -> Modifier) = if (condition) { @@ -25,3 +32,36 @@ fun Modifier.conditional(condition: Boolean, block: Modifier.() -> Modifier) = } else { this } + +fun Modifier.shadow( + color: Color = Color.Black, + alpha: Float = 1f, + cornersRadius: Dp = 0.dp, + blurRadius: Dp = 0.dp, + offsetX: Dp = 0.dp, + offsetY: Dp = 0.dp, +) = drawBehind { + val shadow = color.copy(alpha = alpha).toArgb() + val transparent = color.copy(alpha = 0f).toArgb() + + drawIntoCanvas { canvas -> + val paint = Paint() + val frameworkPaint = paint.asFrameworkPaint() + frameworkPaint.color = transparent + frameworkPaint.setShadowLayer( + blurRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + shadow, + ) + canvas.drawRoundRect( + left = 0f, + top = 0f, + right = size.width, + bottom = size.height, + radiusX = cornersRadius.toPx(), + radiusY = cornersRadius.toPx(), + paint = paint, + ) + } +} diff --git a/drive/base/presentation/src/main/res/drawable/ic_checkmark_circle_filled.xml b/drive/base/presentation/src/main/res/drawable/ic_checkmark_circle_filled.xml new file mode 100644 index 00000000..c10a83f0 --- /dev/null +++ b/drive/base/presentation/src/main/res/drawable/ic_checkmark_circle_filled.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/drive/base/presentation/src/main/res/drawable/ic_proton_lock_open.xml b/drive/base/presentation/src/main/res/drawable/ic_proton_lock_open.xml new file mode 100644 index 00000000..a63cff7f --- /dev/null +++ b/drive/base/presentation/src/main/res/drawable/ic_proton_lock_open.xml @@ -0,0 +1,13 @@ + + + + diff --git a/drive/base/presentation/src/main/res/values/strings.xml b/drive/base/presentation/src/main/res/values/strings.xml index 9c95a93f..c972c990 100644 --- a/drive/base/presentation/src/main/res/values/strings.xml +++ b/drive/base/presentation/src/main/res/values/strings.xml @@ -424,4 +424,24 @@ %1$d item %1$d items + + + App locked + Unlock using your device lock + Confirmation + To enable unlock, confirm with your device lock + To disable unlock, confirm with your device lock + Enable biometrics or device lock + Keep unlocked + Biometrics or device lock + Turn on biometrics access + Set up biometrics or device lock access on this device. + Settings + Biometrics or device lock is not available on your device + Biometrics authentication failed + Biometrics or device lock access activated + App lock has been deactivated + Choose how to lock the app + Unlock the app + diff --git a/drive/block/data/src/main/kotlin/me/proton/core/drive/block/data/repository/BlockRepositoryImpl.kt b/drive/block/data/src/main/kotlin/me/proton/core/drive/block/data/repository/BlockRepositoryImpl.kt index a2af1c7f..62a9b3e9 100644 --- a/drive/block/data/src/main/kotlin/me/proton/core/drive/block/data/repository/BlockRepositoryImpl.kt +++ b/drive/block/data/src/main/kotlin/me/proton/core/drive/block/data/repository/BlockRepositoryImpl.kt @@ -30,7 +30,7 @@ import javax.inject.Inject class BlockRepositoryImpl @Inject constructor( private val api: BlockApiDataSource, -): BlockRepository { +) : BlockRepository { override suspend fun getUploadBlocksUrl( userId: UserId, addressId: AddressId, @@ -39,13 +39,17 @@ class BlockRepositoryImpl @Inject constructor( uploadBlocks: List, uploadThumbnail: UploadBlock?, ): Result = coRunCatching { - api.uploadBlock( - userId = userId, - addressId = addressId, - fileId = fileId, - revisionId = revisionId, - uploadBlocks = uploadBlocks, - uploadThumbnail = uploadThumbnail, - ).toUploadBlocksUrl() + if (uploadThumbnail != null || uploadBlocks.isNotEmpty()) { + api.uploadBlock( + userId = userId, + addressId = addressId, + fileId = fileId, + revisionId = revisionId, + uploadBlocks = uploadBlocks, + uploadThumbnail = uploadThumbnail, + ).toUploadBlocksUrl() + } else { + UploadBlocksUrl(emptyList(), null) + } } } diff --git a/drive/crypto-base/domain/build.gradle.kts b/drive/crypto-base/domain/build.gradle.kts index 38028a20..6be398db 100644 --- a/drive/crypto-base/domain/build.gradle.kts +++ b/drive/crypto-base/domain/build.gradle.kts @@ -21,7 +21,7 @@ plugins { driveModule(hilt = true) { api(project(":drive:base:domain")) - api(libs.core.auth) - api(libs.core.crypto) - api(libs.core.key) + api(libs.core.auth.domain) + api(libs.core.cryptoCommon) + api(libs.core.key.domain) } diff --git a/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/extension/UserAddressRepository.kt b/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/extension/UserAddressRepository.kt index d6de0429..d426746b 100644 --- a/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/extension/UserAddressRepository.kt +++ b/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/extension/UserAddressRepository.kt @@ -24,16 +24,16 @@ import me.proton.core.user.domain.entity.AddressId import me.proton.core.user.domain.repository.UserAddressRepository suspend fun UserAddressRepository.getAddressKeys(userId: UserId, email: String): KeyHolder = - getAddresses(userId) - .first { userAddress -> userAddress.email == email } - .keys - .keyHolder() + with (getAddresses(userId)) { + firstOrNull { userAddress -> userAddress.email == email }?.keys?.keyHolder() + ?: flatMap { userAddress -> userAddress.keys }.keyHolder() + } suspend fun UserAddressRepository.getAddressKeys(userId: UserId, addressId: AddressId): KeyHolder = - getAddresses(userId) - .first { userAddress -> userAddress.addressId == addressId } - .keys - .keyHolder() + with (getAddresses(userId)) { + firstOrNull { userAddress -> userAddress.addressId == addressId }?.keys?.keyHolder() + ?: flatMap { userAddress -> userAddress.keys }.keyHolder() + } fun List.keyHolder() = object : KeyHolder { override val keys: List = this@keyHolder diff --git a/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/DecryptNestedPrivateKey.kt b/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/DecryptNestedPrivateKey.kt index a0686e76..bcc32799 100644 --- a/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/DecryptNestedPrivateKey.kt +++ b/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/DecryptNestedPrivateKey.kt @@ -17,6 +17,7 @@ */ package me.proton.core.drive.cryptobase.domain.usecase +import kotlinx.coroutines.withContext import me.proton.core.crypto.common.context.CryptoContext import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.domain.util.coRunCatching @@ -70,12 +71,14 @@ class DecryptNestedPrivateKey @Inject constructor( signatureAddress: String, allowCompromisedVerificationKeys: Boolean = false, coroutineContext: CoroutineContext = CryptoScope.EncryptAndDecrypt.coroutineContext, - ): Result = + ): Result = withContext(coroutineContext) { invoke( decryptKey = decryptKey, key = key, - verifyKeyRing = userAddressRepository.getAddressKeys(userId, signatureAddress).publicKeyRing(cryptoContext), + verifyKeyRing = userAddressRepository.getAddressKeys(userId, signatureAddress) + .publicKeyRing(cryptoContext), allowCompromisedVerificationKeys = allowCompromisedVerificationKeys, coroutineContext = coroutineContext, ) + } } diff --git a/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/GenerateSrpForShareUrl.kt b/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/GenerateSrpForShareUrl.kt index 6aedea52..068904f1 100644 --- a/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/GenerateSrpForShareUrl.kt +++ b/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/GenerateSrpForShareUrl.kt @@ -19,16 +19,20 @@ package me.proton.core.drive.cryptobase.domain.usecase import me.proton.core.auth.domain.repository.AuthRepository import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.cryptobase.domain.entity.SrpForShareUrl +import me.proton.core.network.domain.session.SessionProvider import javax.inject.Inject class GenerateSrpForShareUrl @Inject constructor( private val cryptoContext: CryptoContext, private val authRepository: AuthRepository, + private val sessionProvider: SessionProvider, ) { - suspend operator fun invoke(urlPassword: ByteArray) = coRunCatching { - val modulus = authRepository.randomModulus() + suspend operator fun invoke(userId: UserId, urlPassword: ByteArray) = coRunCatching { + val sessionId = sessionProvider.getSessionId(userId) + val modulus = authRepository.randomModulus(sessionId) val auth = cryptoContext.srpCrypto.calculatePasswordVerifier( username = "", password = urlPassword, diff --git a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareUrlCustomPasswordInfo.kt b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareUrlCustomPasswordInfo.kt index 97eb9920..9059203c 100644 --- a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareUrlCustomPasswordInfo.kt +++ b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareUrlCustomPasswordInfo.kt @@ -53,7 +53,7 @@ class CreateShareUrlCustomPasswordInfo @Inject constructor( .getOrThrow() .take(RANDOM_URL_PASSWORD_SIZE) val urlPassword = "$randomPassword$customPassword" - val srpForShareUrl = generateSrpForShareUrl(urlPassword.toByteArray()).getOrThrow() + val srpForShareUrl = generateSrpForShareUrl(userId, urlPassword.toByteArray()).getOrThrow() val addressKeys = getAddressKeys(userId, addressId) val reencryptedSharePassphrase = reencryptSharePassphraseWithUrlPassword( decryptKey = addressKeys.keyHolder, diff --git a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareUrlInfo.kt b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareUrlInfo.kt index d842d9bc..8d289183 100644 --- a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareUrlInfo.kt +++ b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareUrlInfo.kt @@ -46,7 +46,7 @@ class CreateShareUrlInfo @Inject constructor( "Random URL password size (${randomUrlPassword.length}) does not match requirement (${RANDOM_URL_PASSWORD_SIZE})" } val addressKeys = getAddressKeys(userId, addressId) - val srpForShareUrl = generateSrpForShareUrl(randomUrlPassword.toByteArray()).getOrThrow() + val srpForShareUrl = generateSrpForShareUrl(userId, randomUrlPassword.toByteArray()).getOrThrow() val reencryptedSharePassphrase = reencryptSharePassphraseWithUrlPassword( decryptKey = addressKeys.keyHolder, urlPassword = randomUrlPassword.toByteArray(), diff --git a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/upload/EncryptUploadBlocks.kt b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/upload/EncryptUploadBlocks.kt index 47afed0c..28f47152 100644 --- a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/upload/EncryptUploadBlocks.kt +++ b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/upload/EncryptUploadBlocks.kt @@ -54,36 +54,22 @@ class EncryptUploadBlocks @Inject constructor( coroutineContext = coroutineContext, ) { sessionKey -> input.mapIndexed { index, input -> - if (input.length() == 0L) { - block( - index, - input, - output[index].apply { createNewFile() }, - encryptedSignature( - unlockedEncryptKey = unlockedSignatureEncryptionKey, - unlockedSignKey = unlockedFileSignKey, - input = ByteArray(size = 0), - coroutineContext = coroutineContext, - ).getOrThrow() - ) - } else { - block( - index, - input, - encryptFile( - encryptKey = sessionKey, - source = input, - destination = output[index], - coroutineContext = coroutineContext, - ).getOrThrow(), - encryptedSignature( - unlockedEncryptKey = unlockedSignatureEncryptionKey, - unlockedSignKey = unlockedFileSignKey, - file = input, - coroutineContext = coroutineContext, - ).getOrThrow() - ) - } + block( + index, + input, + encryptFile( + encryptKey = sessionKey, + source = input, + destination = output[index], + coroutineContext = coroutineContext, + ).getOrThrow(), + encryptedSignature( + unlockedEncryptKey = unlockedSignatureEncryptionKey, + unlockedSignKey = unlockedFileSignKey, + file = input, + coroutineContext = coroutineContext, + ).getOrThrow() + ) } }.getOrThrow() }.getOrThrow() diff --git a/drive/db/build.gradle.kts b/drive/db/build.gradle.kts new file mode 100644 index 00000000..d820e529 --- /dev/null +++ b/drive/db/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2021-2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +plugins { + id("com.android.library") +} + +driveModule( + hilt = true, + room = true, +) { + api(libs.core.account.data) + api(libs.core.challenge.data) + api(libs.core.crypto.android) + api(libs.core.eventManager.data) + api(libs.core.featureFlag.data) + api(libs.core.humanVerification.data) + api(libs.core.key.data) + api(libs.core.observability.data) + api(libs.core.payment.data) + api(libs.core.user.data) + api(libs.core.userSettings.data) + // TODO: Extract from drive db + api(project(":app-ui-settings")) + api(project(":drive:drivelink:data")) + api(project(":drive:drivelink-download:data")) + api(project(":drive:drivelink-offline:data")) + api(project(":drive:drivelink-paged:data")) + api(project(":drive:drivelink-selection:data")) + api(project(":drive:drivelink-shared:data")) + api(project(":drive:drivelink-trash:data")) + api(project(":drive:folder:data")) + api(project(":drive:link:data")) + api(project(":drive:link-download:data")) + api(project(":drive:link-node:data")) + api(project(":drive:link-offline:data")) + api(project(":drive:link-selection:data")) + api(project(":drive:link-trash:data")) + api(project(":drive:link-upload:data")) + api(project(":drive:message-queue:data")) + api(project(":drive:notification:data")) + api(project(":drive:share:data")) + api(project(":drive:share-url:base:data")) + api(project(":drive:sorting:data")) + api(project(":drive:volume:data")) +} diff --git a/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/1.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/1.json new file mode 100644 index 00000000..8d06834d --- /dev/null +++ b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/1.json @@ -0,0 +1,3233 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "1848c5aa24ef749bea72f819f081cfe5", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "private", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `captchaVerificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captchaVerificationToken", + "columnName": "captchaVerificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_u2fKeys` TEXT, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.u2fKeys", + "columnName": "twoFA_u2fKeys", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `vpnPlanName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vpnPlanName", + "columnName": "vpnPlanName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "VolumeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `max_space` INTEGER, `used_space` INTEGER NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "used_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_VolumeEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_VolumeEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_VolumeEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `link_id` TEXT NOT NULL, `block_size` INTEGER NOT NULL, `locked` INTEGER NOT NULL, `key` TEXT NOT NULL, `passphrase` TEXT NOT NULL, `passphrase_signature` TEXT NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockSize", + "columnName": "block_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphraseSignature", + "columnName": "passphrase_signature", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ShareEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareUrlEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `name` TEXT, `token` TEXT NOT NULL, `creatior_email` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `expiration_time` INTEGER, `last_access_time` INTEGER, `max_accesses` INTEGER, `number_of_accesses` INTEGER NOT NULL, `url_password_salt` TEXT NOT NULL, `share_password_salt` TEXT NOT NULL, `srp_verifier` TEXT NOT NULL, `srp_modulus_id` TEXT NOT NULL, `password` TEXT NOT NULL, `share_passphrase_key_packet` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorEmail", + "columnName": "creatior_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastAccessTime", + "columnName": "last_access_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAccesses", + "columnName": "max_accesses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urlPasswordSalt", + "columnName": "url_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePasswordSalt", + "columnName": "share_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpVerifier", + "columnName": "srp_verifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpModulusId", + "columnName": "srp_modulus_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePassphraseKeyPacket", + "columnName": "share_passphrase_key_packet", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ShareUrlEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `parent_id` TEXT, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_signature_email` TEXT, `hash` TEXT NOT NULL, `state` INTEGER NOT NULL, `expiration_time` INTEGER, `size` INTEGER NOT NULL, `mime_type` TEXT NOT NULL, `attributes` INTEGER NOT NULL, `permissions` INTEGER NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `signature_address` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `trashed_time` INTEGER, `is_shared` INTEGER NOT NULL, `number_of_accesses` INTEGER NOT NULL, `share_url_expiration_time` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameSignatureEmail", + "columnName": "name_signature_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashedTime", + "columnName": "trashed_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shared", + "columnName": "is_shared", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareUrlExpirationTime", + "columnName": "share_url_expiration_time", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_LinkEntity_user_id_id", + "unique": false, + "columnNames": [ + "user_id", + "id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_id` ON `${TABLE_NAME}` (`user_id`, `id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFilePropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`file_user_id` TEXT NOT NULL, `file_share_id` TEXT NOT NULL, `file_link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT, `file_signature_address` TEXT, PRIMARY KEY(`file_user_id`, `file_share_id`, `file_link_id`), FOREIGN KEY(`file_user_id`, `file_share_id`, `file_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "file_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "file_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "file_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeRevisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasThumbnail", + "columnName": "has_thumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activeRevisionSignatureAddress", + "columnName": "file_signature_address", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id", + "unique": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id` ON `${TABLE_NAME}` (`file_user_id`, `file_share_id`, `file_link_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFolderPropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`folder_user_id` TEXT NOT NULL, `folder_share_id` TEXT NOT NULL, `folder_link_id` TEXT NOT NULL, `node_hash_key` TEXT NOT NULL, PRIMARY KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`), FOREIGN KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "folder_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "folder_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "folder_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeHashKey", + "columnName": "node_hash_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id", + "unique": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id` ON `${TABLE_NAME}` (`folder_user_id`, `folder_share_id`, `folder_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkOfflineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkOfflineEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkDownloadStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkDownloadStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DownloadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `index` INTEGER NOT NULL, `uri` TEXT NOT NULL, `encrypted_signature` TEXT, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`, `index`), FOREIGN KEY(`user_id`, `share_id`, `link_id`, `revision_id`) REFERENCES `LinkDownloadStateEntity`(`user_id`, `share_id`, `link_id`, `revision_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id", + "index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DownloadBlockEntity_user_id_share_id_link_id_revision_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id_share_id_link_id_revision_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`, `revision_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkDownloadStateEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + } + ] + }, + { + "tableName": "LinkTrashStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkTrashStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashWorkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `work_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "work_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TrashWorkEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_TrashWorkEntity_work_id", + "unique": false, + "columnNames": [ + "work_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_work_id` ON `${TABLE_NAME}` (`work_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `content` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_MessageEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UiSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `layout_type` TEXT NOT NULL, `theme_style` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutType", + "columnName": "layout_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "themeStyle", + "columnName": "theme_style", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DriveLinkRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `previous_key` INTEGER, `next_key` INTEGER, PRIMARY KEY(`key`, `user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "key", + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "SortingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `sorting_by` TEXT NOT NULL, `sorting_direction` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingBy", + "columnName": "sorting_by", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingDirection", + "columnName": "sorting_direction", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LinkUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT NOT NULL, `manifest_signature` TEXT NOT NULL, `state` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_LinkUploadEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkUploadEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkUploadEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkUploadEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkUploadEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkUploadEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_link_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `size` INTEGER NOT NULL, `encrypted_signature` TEXT NOT NULL, `hash` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`upload_link_id`, `index`), FOREIGN KEY(`upload_link_id`) REFERENCES `LinkUploadEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uploadLinkId", + "columnName": "upload_link_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadToken", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "upload_link_id", + "index" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkUploadEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_link_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FolderMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `last_fetch_children_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchChildrenTimestamp", + "columnName": "last_fetch_children_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `last_fetch_trash_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTrashTimestamp", + "columnName": "last_fetch_trash_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1848c5aa24ef749bea72f819f081cfe5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/10.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/10.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/10.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/10.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/11.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/11.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/11.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/11.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/12.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/12.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/12.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/12.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/13.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/13.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/13.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/13.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/14.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/14.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/14.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/14.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/15.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/15.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/15.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/15.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/16.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/16.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/16.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/16.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/17.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/17.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/17.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/17.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/18.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/18.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/18.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/18.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/19.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/19.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/19.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/19.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/2.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/2.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/2.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/2.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/20.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/20.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/20.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/20.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/21.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/21.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/21.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/21.json diff --git a/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/22.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/22.json new file mode 100644 index 00000000..4520cac4 --- /dev/null +++ b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/22.json @@ -0,0 +1,4237 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "294985598d7bdcd4fa1e0709f83e5ead", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `vpnPlanName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vpnPlanName", + "columnName": "vpnPlanName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "VolumeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `max_space` INTEGER, `used_space` INTEGER NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "used_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_VolumeEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_VolumeEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_VolumeEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `address_id` TEXT, `flags` INTEGER NOT NULL, `link_id` TEXT NOT NULL, `locked` INTEGER NOT NULL, `key` TEXT NOT NULL, `passphrase` TEXT NOT NULL, `passphrase_signature` TEXT NOT NULL, `creation_time` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "address_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphraseSignature", + "columnName": "passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ShareEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_ShareEntity_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ShareEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareUrlEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `name` TEXT, `token` TEXT NOT NULL, `creatior_email` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `expiration_time` INTEGER, `last_access_time` INTEGER, `max_accesses` INTEGER, `number_of_accesses` INTEGER NOT NULL, `url_password_salt` TEXT NOT NULL, `share_password_salt` TEXT NOT NULL, `srp_verifier` TEXT NOT NULL, `srp_modulus_id` TEXT NOT NULL, `password` TEXT NOT NULL, `share_passphrase_key_packet` TEXT NOT NULL, `public_url` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorEmail", + "columnName": "creatior_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastAccessTime", + "columnName": "last_access_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAccesses", + "columnName": "max_accesses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urlPasswordSalt", + "columnName": "url_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePasswordSalt", + "columnName": "share_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpVerifier", + "columnName": "srp_verifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpModulusId", + "columnName": "srp_modulus_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedUrlPassword", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePassphraseKeyPacket", + "columnName": "share_passphrase_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicUrl", + "columnName": "public_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ShareUrlEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareUrlEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_ShareUrlEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `parent_id` TEXT, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_signature_email` TEXT, `hash` TEXT NOT NULL, `state` INTEGER NOT NULL, `expiration_time` INTEGER, `size` INTEGER NOT NULL, `mime_type` TEXT NOT NULL, `attributes` INTEGER NOT NULL, `permissions` INTEGER NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `signature_address` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `trashed_time` INTEGER, `is_shared` INTEGER NOT NULL, `number_of_accesses` INTEGER NOT NULL, `share_url_expiration_time` INTEGER, `x_attr` TEXT, `share_url_share_id` TEXT DEFAULT NULL, `share_url_id` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameSignatureEmail", + "columnName": "name_signature_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashedTime", + "columnName": "trashed_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shared", + "columnName": "is_shared", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareUrlExpirationTime", + "columnName": "share_url_expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "xAttr", + "columnName": "x_attr", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrlShareId", + "columnName": "share_url_share_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shareUrlId", + "columnName": "share_url_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_LinkEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_LinkEntity_user_id_id", + "unique": false, + "columnNames": [ + "user_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_id` ON `${TABLE_NAME}` (`user_id`, `id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFilePropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`file_user_id` TEXT NOT NULL, `file_share_id` TEXT NOT NULL, `file_link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT, `file_signature_address` TEXT, PRIMARY KEY(`file_user_id`, `file_share_id`, `file_link_id`), FOREIGN KEY(`file_user_id`, `file_share_id`, `file_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "file_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "file_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "file_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeRevisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasThumbnail", + "columnName": "has_thumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activeRevisionSignatureAddress", + "columnName": "file_signature_address", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkFilePropertiesEntity_file_share_id", + "unique": false, + "columnNames": [ + "file_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_share_id` ON `${TABLE_NAME}` (`file_share_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_link_id", + "unique": false, + "columnNames": [ + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_link_id` ON `${TABLE_NAME}` (`file_link_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id", + "unique": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id` ON `${TABLE_NAME}` (`file_user_id`, `file_share_id`, `file_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFolderPropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`folder_user_id` TEXT NOT NULL, `folder_share_id` TEXT NOT NULL, `folder_link_id` TEXT NOT NULL, `node_hash_key` TEXT NOT NULL, PRIMARY KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`), FOREIGN KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "folder_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "folder_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "folder_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeHashKey", + "columnName": "node_hash_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkFolderPropertiesEntity_folder_share_id", + "unique": false, + "columnNames": [ + "folder_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_share_id` ON `${TABLE_NAME}` (`folder_share_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_link_id", + "unique": false, + "columnNames": [ + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_link_id` ON `${TABLE_NAME}` (`folder_link_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id", + "unique": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id` ON `${TABLE_NAME}` (`folder_user_id`, `folder_share_id`, `folder_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkOfflineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkOfflineEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkOfflineEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkOfflineEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkOfflineEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkDownloadStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `state` TEXT NOT NULL, `manifest_signature` TEXT DEFAULT NULL, `signature_address` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkDownloadStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkDownloadStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DownloadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `index` INTEGER NOT NULL, `uri` TEXT NOT NULL, `encrypted_signature` TEXT, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`, `index`), FOREIGN KEY(`user_id`, `share_id`, `link_id`, `revision_id`) REFERENCES `LinkDownloadStateEntity`(`user_id`, `share_id`, `link_id`, `revision_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id", + "index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DownloadBlockEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DownloadBlockEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DownloadBlockEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DownloadBlockEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_DownloadBlockEntity_user_id_share_id_link_id_revision_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id_share_id_link_id_revision_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`, `revision_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkDownloadStateEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + } + ] + }, + { + "tableName": "LinkTrashStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkTrashStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkTrashStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkTrashStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkTrashStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkTrashStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashWorkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `work_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "work_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TrashWorkEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_TrashWorkEntity_work_id", + "unique": false, + "columnNames": [ + "work_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_work_id` ON `${TABLE_NAME}` (`work_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `content` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_MessageEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UiSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `layout_type` TEXT NOT NULL, `theme_style` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutType", + "columnName": "layout_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "themeStyle", + "columnName": "theme_style", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DriveLinkRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `previous_key` INTEGER, `next_key` INTEGER, PRIMARY KEY(`key`, `user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "key", + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "SortingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `sorting_by` TEXT NOT NULL, `sorting_direction` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingBy", + "columnName": "sorting_by", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingDirection", + "columnName": "sorting_direction", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LinkUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT NOT NULL, `manifest_signature` TEXT NOT NULL, `state` TEXT NOT NULL, `size` INTEGER DEFAULT NULL, `last_modified` INTEGER, `uri` TEXT DEFAULT NULL, `should_delete_source_uri` INTEGER NOT NULL DEFAULT false, `media_resolution_width` INTEGER DEFAULT NULL, `media_resolution_height` INTEGER DEFAULT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "mediaResolutionWidth", + "columnName": "media_resolution_width", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mediaResolutionHeight", + "columnName": "media_resolution_height", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_LinkUploadEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkUploadEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkUploadEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkUploadEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkUploadEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkUploadEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_link_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `size` INTEGER NOT NULL, `encrypted_signature` TEXT NOT NULL, `hash` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, `raw_size` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`upload_link_id`, `index`), FOREIGN KEY(`upload_link_id`) REFERENCES `LinkUploadEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uploadLinkId", + "columnName": "upload_link_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadToken", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawSize", + "columnName": "raw_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "upload_link_id", + "index" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkUploadEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_link_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UploadBulkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `should_delete_source_uri` INTEGER NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_UploadBulkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_UploadBulkEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_UploadBulkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_UploadBulkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBulkUriStringEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_bulk_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, PRIMARY KEY(`upload_bulk_id`, `uri`), FOREIGN KEY(`upload_bulk_id`) REFERENCES `UploadBulkEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "upload_bulk_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "upload_bulk_id", + "uri" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UploadBulkUriStringEntity_upload_bulk_id", + "unique": false, + "columnNames": [ + "upload_bulk_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_upload_bulk_id` ON `${TABLE_NAME}` (`upload_bulk_id`)" + } + ], + "foreignKeys": [ + { + "table": "UploadBulkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_bulk_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FolderMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `last_fetch_children_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchChildrenTimestamp", + "columnName": "last_fetch_children_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `last_fetch_trash_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTrashTimestamp", + "columnName": "last_fetch_trash_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "NotificationChannelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `type`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "type" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationChannelEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationChannelEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_tag` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_tag`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationTag", + "columnName": "notification_tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id", + "notification_event_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_tag`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "LinkSelectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `selection_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `selection_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectionId", + "columnName": "selection_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id", + "selection_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkSelectionEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_LinkSelectionEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkSelectionEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkSelectionEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkSelectionEntity_selection_id", + "unique": false, + "columnNames": [ + "selection_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_selection_id` ON `${TABLE_NAME}` (`selection_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '294985598d7bdcd4fa1e0709f83e5ead')" + ] + } +} \ No newline at end of file diff --git a/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/23.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/23.json new file mode 100644 index 00000000..efaae2b9 --- /dev/null +++ b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/23.json @@ -0,0 +1,4244 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "59591b661e49ae97591acd9c77f82223", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `vpnPlanName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vpnPlanName", + "columnName": "vpnPlanName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "VolumeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `max_space` INTEGER, `used_space` INTEGER NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "used_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_VolumeEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_VolumeEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_VolumeEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `address_id` TEXT, `flags` INTEGER NOT NULL, `link_id` TEXT NOT NULL, `locked` INTEGER NOT NULL, `key` TEXT NOT NULL, `passphrase` TEXT NOT NULL, `passphrase_signature` TEXT NOT NULL, `creation_time` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "address_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphraseSignature", + "columnName": "passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ShareEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_ShareEntity_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ShareEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareUrlEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `name` TEXT, `token` TEXT NOT NULL, `creatior_email` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `expiration_time` INTEGER, `last_access_time` INTEGER, `max_accesses` INTEGER, `number_of_accesses` INTEGER NOT NULL, `url_password_salt` TEXT NOT NULL, `share_password_salt` TEXT NOT NULL, `srp_verifier` TEXT NOT NULL, `srp_modulus_id` TEXT NOT NULL, `password` TEXT NOT NULL, `share_passphrase_key_packet` TEXT NOT NULL, `public_url` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorEmail", + "columnName": "creatior_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastAccessTime", + "columnName": "last_access_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAccesses", + "columnName": "max_accesses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urlPasswordSalt", + "columnName": "url_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePasswordSalt", + "columnName": "share_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpVerifier", + "columnName": "srp_verifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpModulusId", + "columnName": "srp_modulus_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedUrlPassword", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePassphraseKeyPacket", + "columnName": "share_passphrase_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicUrl", + "columnName": "public_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ShareUrlEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareUrlEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_ShareUrlEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `parent_id` TEXT, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_signature_email` TEXT, `hash` TEXT NOT NULL, `state` INTEGER NOT NULL, `expiration_time` INTEGER, `size` INTEGER NOT NULL, `mime_type` TEXT NOT NULL, `attributes` INTEGER NOT NULL, `permissions` INTEGER NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `signature_address` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `trashed_time` INTEGER, `is_shared` INTEGER NOT NULL, `number_of_accesses` INTEGER NOT NULL, `share_url_expiration_time` INTEGER, `x_attr` TEXT, `share_url_share_id` TEXT DEFAULT NULL, `share_url_id` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameSignatureEmail", + "columnName": "name_signature_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashedTime", + "columnName": "trashed_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shared", + "columnName": "is_shared", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareUrlExpirationTime", + "columnName": "share_url_expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "xAttr", + "columnName": "x_attr", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrlShareId", + "columnName": "share_url_share_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shareUrlId", + "columnName": "share_url_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_LinkEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_LinkEntity_user_id_id", + "unique": false, + "columnNames": [ + "user_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_id` ON `${TABLE_NAME}` (`user_id`, `id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFilePropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`file_user_id` TEXT NOT NULL, `file_share_id` TEXT NOT NULL, `file_link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT, `file_signature_address` TEXT, PRIMARY KEY(`file_user_id`, `file_share_id`, `file_link_id`), FOREIGN KEY(`file_user_id`, `file_share_id`, `file_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "file_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "file_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "file_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeRevisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasThumbnail", + "columnName": "has_thumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activeRevisionSignatureAddress", + "columnName": "file_signature_address", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkFilePropertiesEntity_file_share_id", + "unique": false, + "columnNames": [ + "file_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_share_id` ON `${TABLE_NAME}` (`file_share_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_link_id", + "unique": false, + "columnNames": [ + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_link_id` ON `${TABLE_NAME}` (`file_link_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id", + "unique": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id` ON `${TABLE_NAME}` (`file_user_id`, `file_share_id`, `file_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFolderPropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`folder_user_id` TEXT NOT NULL, `folder_share_id` TEXT NOT NULL, `folder_link_id` TEXT NOT NULL, `node_hash_key` TEXT NOT NULL, PRIMARY KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`), FOREIGN KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "folder_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "folder_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "folder_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeHashKey", + "columnName": "node_hash_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkFolderPropertiesEntity_folder_share_id", + "unique": false, + "columnNames": [ + "folder_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_share_id` ON `${TABLE_NAME}` (`folder_share_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_link_id", + "unique": false, + "columnNames": [ + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_link_id` ON `${TABLE_NAME}` (`folder_link_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id", + "unique": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id` ON `${TABLE_NAME}` (`folder_user_id`, `folder_share_id`, `folder_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkOfflineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkOfflineEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkOfflineEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkOfflineEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkOfflineEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkDownloadStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `state` TEXT NOT NULL, `manifest_signature` TEXT DEFAULT NULL, `signature_address` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkDownloadStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkDownloadStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DownloadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `index` INTEGER NOT NULL, `uri` TEXT NOT NULL, `encrypted_signature` TEXT, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`, `index`), FOREIGN KEY(`user_id`, `share_id`, `link_id`, `revision_id`) REFERENCES `LinkDownloadStateEntity`(`user_id`, `share_id`, `link_id`, `revision_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id", + "index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DownloadBlockEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DownloadBlockEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DownloadBlockEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DownloadBlockEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_DownloadBlockEntity_user_id_share_id_link_id_revision_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id_share_id_link_id_revision_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`, `revision_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkDownloadStateEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + } + ] + }, + { + "tableName": "LinkTrashStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkTrashStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkTrashStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkTrashStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkTrashStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkTrashStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashWorkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `work_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "work_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TrashWorkEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_TrashWorkEntity_work_id", + "unique": false, + "columnNames": [ + "work_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_work_id` ON `${TABLE_NAME}` (`work_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `content` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_MessageEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UiSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `layout_type` TEXT NOT NULL, `theme_style` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutType", + "columnName": "layout_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "themeStyle", + "columnName": "theme_style", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DriveLinkRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `previous_key` INTEGER, `next_key` INTEGER, PRIMARY KEY(`key`, `user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "key", + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "SortingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `sorting_by` TEXT NOT NULL, `sorting_direction` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingBy", + "columnName": "sorting_by", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingDirection", + "columnName": "sorting_direction", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LinkUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT NOT NULL, `manifest_signature` TEXT NOT NULL, `state` TEXT NOT NULL, `size` INTEGER DEFAULT NULL, `last_modified` INTEGER, `uri` TEXT DEFAULT NULL, `should_delete_source_uri` INTEGER NOT NULL DEFAULT false, `media_resolution_width` INTEGER DEFAULT NULL, `media_resolution_height` INTEGER DEFAULT NULL, `digests` TEXT DEFAULT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "mediaResolutionWidth", + "columnName": "media_resolution_width", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mediaResolutionHeight", + "columnName": "media_resolution_height", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "digests", + "columnName": "digests", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_LinkUploadEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkUploadEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkUploadEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkUploadEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkUploadEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkUploadEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_link_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `size` INTEGER NOT NULL, `encrypted_signature` TEXT NOT NULL, `hash` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, `raw_size` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`upload_link_id`, `index`), FOREIGN KEY(`upload_link_id`) REFERENCES `LinkUploadEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uploadLinkId", + "columnName": "upload_link_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadToken", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawSize", + "columnName": "raw_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "upload_link_id", + "index" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkUploadEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_link_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UploadBulkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `should_delete_source_uri` INTEGER NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_UploadBulkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_UploadBulkEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_UploadBulkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_UploadBulkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBulkUriStringEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_bulk_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, PRIMARY KEY(`upload_bulk_id`, `uri`), FOREIGN KEY(`upload_bulk_id`) REFERENCES `UploadBulkEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "upload_bulk_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "upload_bulk_id", + "uri" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UploadBulkUriStringEntity_upload_bulk_id", + "unique": false, + "columnNames": [ + "upload_bulk_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_upload_bulk_id` ON `${TABLE_NAME}` (`upload_bulk_id`)" + } + ], + "foreignKeys": [ + { + "table": "UploadBulkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_bulk_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FolderMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `last_fetch_children_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchChildrenTimestamp", + "columnName": "last_fetch_children_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `last_fetch_trash_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTrashTimestamp", + "columnName": "last_fetch_trash_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "NotificationChannelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `type`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "type" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationChannelEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationChannelEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_tag` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_tag`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationTag", + "columnName": "notification_tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id", + "notification_event_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_tag`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "LinkSelectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `selection_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `selection_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectionId", + "columnName": "selection_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id", + "share_id", + "link_id", + "selection_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LinkSelectionEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_LinkSelectionEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkSelectionEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkSelectionEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkSelectionEntity_selection_id", + "unique": false, + "columnNames": [ + "selection_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_selection_id` ON `${TABLE_NAME}` (`selection_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '59591b661e49ae97591acd9c77f82223')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/3.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/3.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/3.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/3.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/4.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/4.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/4.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/4.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/5.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/5.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/5.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/5.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/6.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/6.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/6.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/6.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/7.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/7.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/7.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/7.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/8.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/8.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/8.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/8.json diff --git a/app/schemas/me.proton.android.drive.db.AppDatabase/9.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/9.json similarity index 100% rename from app/schemas/me.proton.android.drive.db.AppDatabase/9.json rename to drive/db/schemas/me.proton.android.drive.db.DriveDatabase/9.json diff --git a/drive/db/src/main/AndroidManifest.xml b/drive/db/src/main/AndroidManifest.xml new file mode 100644 index 00000000..35ad1b9a --- /dev/null +++ b/drive/db/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + \ No newline at end of file diff --git a/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabase.kt b/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabase.kt new file mode 100644 index 00000000..836b8db6 --- /dev/null +++ b/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabase.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.android.drive.db + +import android.content.Context +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.TypeConverters +import me.proton.core.account.data.db.AccountConverters +import me.proton.core.account.data.db.AccountDatabase +import me.proton.core.account.data.entity.AccountEntity +import me.proton.core.account.data.entity.AccountMetadataEntity +import me.proton.core.account.data.entity.SessionDetailsEntity +import me.proton.core.account.data.entity.SessionEntity +import me.proton.core.challenge.data.db.ChallengeConverters +import me.proton.core.challenge.data.db.ChallengeDatabase +import me.proton.core.challenge.data.entity.ChallengeFrameEntity +import me.proton.core.crypto.android.keystore.CryptoConverters +import me.proton.core.data.room.db.BaseDatabase +import me.proton.core.data.room.db.CommonConverters +import me.proton.core.drive.drivelink.data.db.DriveLinkDatabase +import me.proton.core.drive.drivelink.download.data.db.DriveLinkDownloadDatabase +import me.proton.core.drive.drivelink.offline.data.db.DriveLinkOfflineDatabase +import me.proton.core.drive.drivelink.paged.data.db.DriveLinkPagedDatabase +import me.proton.core.drive.drivelink.paged.data.db.entity.DriveLinkRemoteKeyEntity +import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase +import me.proton.core.drive.drivelink.shared.data.db.DriveLinkSharedDatabase +import me.proton.core.drive.drivelink.trash.data.db.DriveLinkTrashDatabase +import me.proton.core.drive.folder.data.db.FolderDatabase +import me.proton.core.drive.folder.data.db.FolderMetadataEntity +import me.proton.core.drive.link.data.db.LinkDatabase +import me.proton.core.drive.link.data.db.entity.LinkEntity +import me.proton.core.drive.link.data.db.entity.LinkFilePropertiesEntity +import me.proton.core.drive.link.data.db.entity.LinkFolderPropertiesEntity +import me.proton.core.drive.link.selection.data.db.LinkSelectionConverters +import me.proton.core.drive.link.selection.data.db.LinkSelectionDatabase +import me.proton.core.drive.link.selection.data.db.entity.LinkSelectionEntity +import me.proton.core.drive.linkdownload.data.db.LinkDownloadDatabase +import me.proton.core.drive.linkdownload.data.db.entity.DownloadBlockEntity +import me.proton.core.drive.linkdownload.data.db.entity.LinkDownloadStateEntity +import me.proton.core.drive.linknode.data.db.LinkAncestorDatabase +import me.proton.core.drive.linkoffline.data.db.LinkOfflineDatabase +import me.proton.core.drive.linkoffline.data.db.LinkOfflineEntity +import me.proton.core.drive.linktrash.data.db.LinkTrashDatabase +import me.proton.core.drive.linktrash.data.db.entity.LinkTrashStateEntity +import me.proton.core.drive.linktrash.data.db.entity.TrashMetadataEntity +import me.proton.core.drive.linktrash.data.db.entity.TrashWorkEntity +import me.proton.core.drive.linkupload.data.db.LinkUploadDatabase +import me.proton.core.drive.linkupload.data.db.entity.LinkUploadEntity +import me.proton.core.drive.linkupload.data.db.entity.UploadBlockEntity +import me.proton.core.drive.linkupload.data.db.entity.UploadBulkEntity +import me.proton.core.drive.linkupload.data.db.entity.UploadBulkUriStringEntity +import me.proton.core.drive.messagequeue.data.storage.db.MessageQueueDatabase +import me.proton.core.drive.messagequeue.data.storage.db.entity.MessageEntity +import me.proton.core.drive.notification.data.db.NotificationConverters +import me.proton.core.drive.notification.data.db.NotificationDatabase +import me.proton.core.drive.notification.data.db.entity.NotificationChannelEntity +import me.proton.core.drive.notification.data.db.entity.NotificationEventEntity +import me.proton.core.drive.share.data.db.ShareDatabase +import me.proton.core.drive.share.data.db.ShareEntity +import me.proton.core.drive.shareurl.base.data.db.ShareUrlDatabase +import me.proton.core.drive.shareurl.base.data.db.entity.ShareUrlEntity +import me.proton.core.drive.sorting.data.db.SortingDatabase +import me.proton.core.drive.sorting.data.db.entity.SortingEntity +import me.proton.core.drive.volume.data.db.VolumeDatabase +import me.proton.core.drive.volume.data.db.VolumeEntity +import me.proton.core.eventmanager.data.db.EventManagerConverters +import me.proton.core.eventmanager.data.db.EventMetadataDatabase +import me.proton.core.eventmanager.data.entity.EventMetadataEntity +import me.proton.core.featureflag.data.db.FeatureFlagDatabase +import me.proton.core.featureflag.data.entity.FeatureFlagEntity +import me.proton.core.humanverification.data.db.HumanVerificationConverters +import me.proton.core.humanverification.data.db.HumanVerificationDatabase +import me.proton.core.humanverification.data.entity.HumanVerificationEntity +import me.proton.core.key.data.db.KeySaltDatabase +import me.proton.core.key.data.db.PublicAddressDatabase +import me.proton.core.key.data.entity.KeySaltEntity +import me.proton.core.key.data.entity.PublicAddressEntity +import me.proton.core.key.data.entity.PublicAddressKeyEntity +import me.proton.core.observability.data.db.ObservabilityDatabase +import me.proton.core.observability.data.entity.ObservabilityEventEntity +import me.proton.core.payment.data.local.db.PaymentDatabase +import me.proton.core.payment.data.local.entity.GooglePurchaseEntity +import me.proton.core.user.data.db.AddressDatabase +import me.proton.core.user.data.db.UserConverters +import me.proton.core.user.data.db.UserDatabase +import me.proton.core.user.data.entity.AddressEntity +import me.proton.core.user.data.entity.AddressKeyEntity +import me.proton.core.user.data.entity.UserEntity +import me.proton.core.user.data.entity.UserKeyEntity +import me.proton.core.usersettings.data.db.OrganizationDatabase +import me.proton.core.usersettings.data.db.UserSettingsConverters +import me.proton.core.usersettings.data.db.UserSettingsDatabase +import me.proton.core.usersettings.data.entity.OrganizationEntity +import me.proton.core.usersettings.data.entity.OrganizationKeysEntity +import me.proton.core.usersettings.data.entity.UserSettingsEntity +import me.proton.drive.android.settings.data.db.AppUiSettingsDatabase +import me.proton.drive.android.settings.data.db.entity.UiSettingsEntity + +@Database( + entities = [ + // Core + AccountEntity::class, + AccountMetadataEntity::class, + SessionEntity::class, + SessionDetailsEntity::class, + UserEntity::class, + UserKeyEntity::class, + AddressEntity::class, + AddressKeyEntity::class, + KeySaltEntity::class, + PublicAddressEntity::class, + PublicAddressKeyEntity::class, + HumanVerificationEntity::class, + UserSettingsEntity::class, + OrganizationEntity::class, + OrganizationKeysEntity::class, + EventMetadataEntity::class, + FeatureFlagEntity::class, + ChallengeFrameEntity::class, + GooglePurchaseEntity::class, + ObservabilityEventEntity::class, + // Drive + VolumeEntity::class, + ShareEntity::class, + ShareUrlEntity::class, + LinkEntity::class, + LinkFilePropertiesEntity::class, + LinkFolderPropertiesEntity::class, + LinkOfflineEntity::class, + LinkDownloadStateEntity::class, + DownloadBlockEntity::class, + LinkTrashStateEntity::class, + // Trash + TrashWorkEntity::class, + // MessageQueue + MessageEntity::class, + // AppUiSettings + UiSettingsEntity::class, + // DriveLinkPaged + DriveLinkRemoteKeyEntity::class, + // Sorting + SortingEntity::class, + // Upload + LinkUploadEntity::class, + UploadBlockEntity::class, + UploadBulkEntity::class, + UploadBulkUriStringEntity::class, + FolderMetadataEntity::class, + TrashMetadataEntity::class, + // Notification + NotificationChannelEntity::class, + NotificationEventEntity::class, + // Selection + LinkSelectionEntity::class, + ], + version = DriveDatabase.VERSION, + autoMigrations = [ + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6), + AutoMigration(from = 7, to = 8), + AutoMigration(from = 9, to = 10), + AutoMigration(from = 13, to = 14), + AutoMigration(from = 15, to = 16), + AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18, spec = ShareDatabase.DeleteBlockSizeFromShareEntity::class), + AutoMigration(from = 18, to = 19), + AutoMigration(from = 22, to = 23), + ], + exportSchema = true, +) +@TypeConverters( + // Core + CommonConverters::class, + AccountConverters::class, + UserConverters::class, + CryptoConverters::class, + HumanVerificationConverters::class, + UserSettingsConverters::class, + EventManagerConverters::class, + ChallengeConverters::class, + // Drive + NotificationConverters::class, + LinkSelectionConverters::class, +) +abstract class DriveDatabase : + BaseDatabase(), + AccountDatabase, + UserDatabase, + AddressDatabase, + KeySaltDatabase, + HumanVerificationDatabase, + PublicAddressDatabase, + UserSettingsDatabase, + OrganizationDatabase, + FeatureFlagDatabase, + VolumeDatabase, + ShareDatabase, + ShareUrlDatabase, + LinkDatabase, + FolderDatabase, + LinkAncestorDatabase, + LinkOfflineDatabase, + LinkDownloadDatabase, + LinkTrashDatabase, + LinkSelectionDatabase, + MessageQueueDatabase, + AppUiSettingsDatabase, + EventMetadataDatabase, + ChallengeDatabase, + SortingDatabase, + LinkUploadDatabase, + DriveLinkDatabase, + DriveLinkPagedDatabase, + DriveLinkTrashDatabase, + DriveLinkOfflineDatabase, + DriveLinkDownloadDatabase, + DriveLinkSharedDatabase, + DriveLinkSelectionDatabase, + NotificationDatabase, + PaymentDatabase, + ObservabilityDatabase { + + companion object { + const val VERSION = 23 + + private val migrations = listOf( + DriveDatabaseMigrations.MIGRATION_1_2, + DriveDatabaseMigrations.MIGRATION_2_3, + DriveDatabaseMigrations.MIGRATION_3_4, + //AutoMigration(from = 4, to = 5) + //AutoMigration(from = 5, to = 6) + DriveDatabaseMigrations.MIGRATION_6_7, + //AutoMigration(from = 7, to = 8) + DriveDatabaseMigrations.MIGRATION_8_9, + //AutoMigration(from = 9, to = 10) + DriveDatabaseMigrations.MIGRATION_10_11, + DriveDatabaseMigrations.MIGRATION_11_12, + DriveDatabaseMigrations.MIGRATION_12_13, + //AutoMigration(from = 13, to = 14) + DriveDatabaseMigrations.MIGRATION_14_15, + //AutoMigration(from = 15, to = 16) + //AutoMigration(from = 16, to = 17) + //AutoMigration(from = 17, to = 18) + //AutoMigration(from = 18, to = 19) + DriveDatabaseMigrations.MIGRATION_19_20, + DriveDatabaseMigrations.MIGRATION_20_21, + DriveDatabaseMigrations.MIGRATION_21_22, + //AutoMigration(from = 22, to = 23) + ) + + fun buildDatabase(context: Context): DriveDatabase = + databaseBuilder(context, "db-drive") + .apply { migrations.forEach { addMigrations(it) } } + .build() + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/db/AppDatabaseMigrations.kt b/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabaseMigrations.kt similarity index 87% rename from app/src/main/kotlin/me/proton/android/drive/db/AppDatabaseMigrations.kt rename to drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabaseMigrations.kt index d71b5a8e..7e271812 100644 --- a/app/src/main/kotlin/me/proton/android/drive/db/AppDatabaseMigrations.kt +++ b/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabaseMigrations.kt @@ -1,19 +1,19 @@ /* * Copyright (c) 2023 Proton AG. - * This file is part of Proton Drive. + * This file is part of Proton Core. * - * Proton Drive is free software: you can redistribute it and/or modify + * Proton Core is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * - * Proton Drive is distributed in the hope that it will be useful, + * Proton Core is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with Proton Drive. If not, see . + * along with Proton Core. If not, see . */ package me.proton.android.drive.db @@ -28,11 +28,12 @@ import me.proton.core.drive.notification.data.db.NotificationDatabase import me.proton.core.drive.share.data.db.ShareDatabase import me.proton.core.featureflag.data.db.FeatureFlagDatabase import me.proton.core.humanverification.data.db.HumanVerificationDatabase +import me.proton.core.observability.data.db.ObservabilityDatabase import me.proton.core.payment.data.local.db.PaymentDatabase import me.proton.core.usersettings.data.db.OrganizationDatabase import me.proton.core.usersettings.data.db.UserSettingsDatabase -object AppDatabaseMigrations { +object DriveDatabaseMigrations { val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { NotificationDatabase.MIGRATION_0.migrate(database) @@ -104,4 +105,9 @@ object AppDatabaseMigrations { } } + val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(database: SupportSQLiteDatabase) { + ObservabilityDatabase.MIGRATION_0.migrate(database) + } + } } diff --git a/drive/documentsprovider/domain/src/main/kotlin/me/proton/core/drive/documentsprovider/domain/usecase/GetDocumentsProviderRoots.kt b/drive/documentsprovider/domain/src/main/kotlin/me/proton/core/drive/documentsprovider/domain/usecase/GetDocumentsProviderRoots.kt index 98587e97..62117be0 100644 --- a/drive/documentsprovider/domain/src/main/kotlin/me/proton/core/drive/documentsprovider/domain/usecase/GetDocumentsProviderRoots.kt +++ b/drive/documentsprovider/domain/src/main/kotlin/me/proton/core/drive/documentsprovider/domain/usecase/GetDocumentsProviderRoots.kt @@ -18,14 +18,8 @@ package me.proton.core.drive.documentsprovider.domain.usecase -import kotlinx.coroutines.flow.first -import me.proton.core.accountmanager.domain.AccountManager -import javax.inject.Inject +import me.proton.core.account.domain.entity.Account -class GetDocumentsProviderRoots @Inject constructor( - private val accountManager: AccountManager, -) { - - suspend operator fun invoke() = - accountManager.getAccounts().first() +interface GetDocumentsProviderRoots { + suspend operator fun invoke(): List } diff --git a/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetDecryptedDriveLinks.kt b/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetDecryptedDriveLinks.kt index a425cba3..4447f00d 100644 --- a/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetDecryptedDriveLinks.kt +++ b/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetDecryptedDriveLinks.kt @@ -34,4 +34,10 @@ class GetDecryptedDriveLinks @Inject constructor( .mapCatching { driveLinks -> decryptDriveLinks(driveLinks) } + + operator fun invoke(parentId: FolderId, fromIndex: Int, count: Int,): Flow>> = + getFolderChildrenDriveLinks(parentId, fromIndex, count) + .mapCatching { driveLinks -> + decryptDriveLinks(driveLinks) + } } diff --git a/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetDriveLinks.kt b/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetDriveLinks.kt index c756c8e6..731b3182 100644 --- a/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetDriveLinks.kt +++ b/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetDriveLinks.kt @@ -33,4 +33,8 @@ class GetDriveLinks @Inject constructor( operator fun invoke(parentId: FolderId): Flow> = repository.getDriveLinks(parentId) .map { driveLinks -> updateIsAnyAncestorMarkedAsOffline(driveLinks) } + + operator fun invoke(parentId: FolderId, fromIndex: Int, count: Int): Flow> = + repository.getDriveLinks(parentId, fromIndex, count) + .map { driveLinks -> updateIsAnyAncestorMarkedAsOffline(driveLinks) } } diff --git a/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetFolderChildrenDriveLinks.kt b/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetFolderChildrenDriveLinks.kt index a8ba0a2f..2a77a89d 100644 --- a/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetFolderChildrenDriveLinks.kt +++ b/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetFolderChildrenDriveLinks.kt @@ -59,4 +59,25 @@ class GetFolderChildrenDriveLinks @Inject constructor( } emitAll(getDriveLinks(folderId).map { driveLinks -> driveLinks.asSuccess }) } + + operator fun invoke( + folderId: FolderId, + fromIndex: Int, + count: Int, + refresh: Flow = flowOf { folderRepository.shouldInitiallyFetchFolderChildren(folderId) } + ): Flow>> = + refresh.transform { shouldRefresh -> + if (shouldRefresh) { + fetcher> { + val (_, saveAction) = folderRepository.fetchFolderChildren( + folderId = folderId, + pageIndex = 0, + pageSize = configurationProvider.uiPageSize, + sorting = getSorting(folderId.userId).first().toFolderSorting() + ).getOrThrow() + saveAction() + } + } + emitAll(getDriveLinks(folderId, fromIndex, count).map { driveLinks -> driveLinks.asSuccess }) + } } diff --git a/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetPagedDriveLinksList.kt b/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetPagedDriveLinksList.kt index bc0f9282..778f222c 100644 --- a/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetPagedDriveLinksList.kt +++ b/drive/drivelink-list/domain/src/main/kotlin/me/proton/core/drive/drivelink/list/domain/usecase/GetPagedDriveLinksList.kt @@ -27,6 +27,7 @@ import me.proton.core.domain.arch.DataResult import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.domain.extension.mapCatching import me.proton.core.drive.drivelink.crypto.domain.usecase.DecryptDriveLinks +import me.proton.core.drive.drivelink.domain.usecase.GetDriveLinksCount import me.proton.core.drive.drivelink.paged.domain.usecase.GetPagedDriveLinks import me.proton.core.drive.drivelink.sorting.domain.usecase.SortDriveLinks import me.proton.core.drive.link.domain.entity.FolderId @@ -42,6 +43,7 @@ class GetPagedDriveLinksList @Inject constructor( private val getMainShare: GetMainShare, private val getPagedDriveLinks: GetPagedDriveLinks, private val getDecryptedDriveLinks: GetDecryptedDriveLinks, + private val getDriveLinksCount: GetDriveLinksCount, private val getFolderChildrenDriveLinks: GetFolderChildrenDriveLinks, private val fetchDriveLinksListPage: FetchDriveLinksListPage, private val getSorting: GetSorting, @@ -74,22 +76,23 @@ class GetPagedDriveLinksList @Inject constructor( pageSize ) }, - localDriveLinks = { + localPagedDriveLinks = { fromIndex, count -> if (sorting.by == By.NAME || sorting.by == By.LAST_MODIFIED) { - getDecryptedDriveLinks(folderId) + getDecryptedDriveLinks(folderId, fromIndex, count) .mapCatching { driveLinks -> sortDriveLinks(sorting, driveLinks) } } else { - getFolderChildrenDriveLinks(folderId) + getFolderChildrenDriveLinks(folderId, fromIndex, count) .mapCatching { driveLinks -> sortDriveLinks(sorting, driveLinks) } } }, + localDriveLinksCount = { getDriveLinksCount(parentId = folderId) }, processPage = takeIf { sorting.by != By.NAME && sorting.by != By.LAST_MODIFIED }?.let { { page -> decryptDriveLinks(page) } - } + }, ) } } diff --git a/drive/drivelink-paged/domain/build.gradle.kts b/drive/drivelink-paged/domain/build.gradle.kts index 4040eab7..887f35d3 100644 --- a/drive/drivelink-paged/domain/build.gradle.kts +++ b/drive/drivelink-paged/domain/build.gradle.kts @@ -22,5 +22,5 @@ plugins { driveModule(hilt = true) { api(project(":drive:drivelink:domain")) api(libs.androidx.paging.common) - implementation(project(":drive:base:data")) + implementation(project(":drive:base:data")) // use for asPagingSource } diff --git a/drive/drivelink-paged/domain/src/main/kotlin/me/proton/core/drive/drivelink/paged/domain/usecase/GetObservablePageSize.kt b/drive/drivelink-paged/domain/src/main/kotlin/me/proton/core/drive/drivelink/paged/domain/usecase/GetObservablePageSize.kt new file mode 100644 index 00000000..1783fe40 --- /dev/null +++ b/drive/drivelink-paged/domain/src/main/kotlin/me/proton/core/drive/drivelink/paged/domain/usecase/GetObservablePageSize.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.drivelink.paged.domain.usecase + +import me.proton.core.drive.base.domain.extension.MiB +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.usecase.GetMemoryInfo +import javax.inject.Inject + +class GetObservablePageSize @Inject constructor( + private val getMemoryInfo: GetMemoryInfo, + private val configurationProvider: ConfigurationProvider, +) { + operator fun invoke(): Int { + val memoryInfo = getMemoryInfo().getOrNull() + return if (memoryInfo == null || memoryInfo.isLowOnMemory) { + configurationProvider.dbPageSize + } else { + val multiplier = when { + memoryInfo.memoryClass > 512.MiB -> 4 + memoryInfo.memoryClass > 256.MiB -> 3 + memoryInfo.memoryClass > 128.MiB -> 2 + else -> 1 + } + configurationProvider.dbPageSize * multiplier + } + } +} diff --git a/drive/drivelink-paged/domain/src/main/kotlin/me/proton/core/drive/drivelink/paged/domain/usecase/GetPagedDriveLinks.kt b/drive/drivelink-paged/domain/src/main/kotlin/me/proton/core/drive/drivelink/paged/domain/usecase/GetPagedDriveLinks.kt index fb43446d..9b6b7b8b 100644 --- a/drive/drivelink-paged/domain/src/main/kotlin/me/proton/core/drive/drivelink/paged/domain/usecase/GetPagedDriveLinks.kt +++ b/drive/drivelink-paged/domain/src/main/kotlin/me/proton/core/drive/drivelink/paged/domain/usecase/GetPagedDriveLinks.kt @@ -34,6 +34,7 @@ import javax.inject.Inject class GetPagedDriveLinks @Inject constructor( private val factory: DriveLinkRemoteMediatorFactory, private val configurationProvider: ConfigurationProvider, + private val getObservablePageSize: GetObservablePageSize, ) { @OptIn(ExperimentalPagingApi::class) @@ -55,4 +56,33 @@ class GetPagedDriveLinks @Inject constructor( pagingSourceFactory = { localDriveLinks().asPagingSource(processPage = processPage) } ).flow } + + @OptIn(ExperimentalPagingApi::class) + operator fun invoke( + userId: UserId, + pagedListKey: String, + remoteDriveLinks: suspend (page: Int, pageSize: Int) -> Result, + localPagedDriveLinks: (Int, Int) -> Flow>>, + localDriveLinksCount: () -> Flow, + pageSize: Int = configurationProvider.uiPageSize, + processPage: (suspend (List) -> List)? = null, + ): Flow> { + return Pager( + PagingConfig( + pageSize = pageSize, + initialLoadSize = pageSize, + enablePlaceholders = false, + ), + remoteMediator = factory.create(userId, pagedListKey, remoteDriveLinks), + pagingSourceFactory = { + { fromIndex: Int, count: Int -> + localPagedDriveLinks(fromIndex, count) + }.asPagingSource( + sourceSize = localDriveLinksCount(), + observablePageSize = getObservablePageSize(), + processPage = processPage, + ) + } + ).flow + } } diff --git a/drive/drivelink-selection/data/src/main/kotlin/me/proton/core/drive/drivelink/selection/data/db/dao/DriveLinkSelectionDao.kt b/drive/drivelink-selection/data/src/main/kotlin/me/proton/core/drive/drivelink/selection/data/db/dao/DriveLinkSelectionDao.kt index 6d2ccce0..2ae9616f 100644 --- a/drive/drivelink-selection/data/src/main/kotlin/me/proton/core/drive/drivelink/selection/data/db/dao/DriveLinkSelectionDao.kt +++ b/drive/drivelink-selection/data/src/main/kotlin/me/proton/core/drive/drivelink/selection/data/db/dao/DriveLinkSelectionDao.kt @@ -25,6 +25,7 @@ import me.proton.core.drive.drivelink.data.db.dao.DriveLinkDao import me.proton.core.drive.drivelink.data.db.entity.DriveLinkEntityWithBlock import me.proton.core.drive.drivelink.data.db.entity.DriveLinkEntityWithBlock.Companion.SELECTION_PREFIX import me.proton.core.drive.link.selection.domain.entity.SelectionId +import me.proton.core.drive.linktrash.data.db.dao.LinkTrashDao @Dao interface DriveLinkSelectionDao : DriveLinkDao { @@ -32,7 +33,7 @@ interface DriveLinkSelectionDao : DriveLinkDao { @Query( """ SELECT ${DriveLinkDao.DRIVE_LINK_SELECT} FROM ${DriveLinkDao.DRIVE_LINK_ENTITY} - WHERE ${SELECTION_PREFIX}_${Column.SELECTION_ID} = :selectionId + WHERE ${SELECTION_PREFIX}_${Column.SELECTION_ID} = :selectionId AND ${LinkTrashDao.NOT_TRASHED_CONDITION} """ ) fun getSelectedLinks(selectionId: SelectionId): Flow> diff --git a/drive/drivelink-selection/data/src/main/kotlin/me/proton/core/drive/drivelink/selection/data/repository/DriveLinkSelectionRepositoryImpl.kt b/drive/drivelink-selection/data/src/main/kotlin/me/proton/core/drive/drivelink/selection/data/repository/DriveLinkSelectionRepositoryImpl.kt index 2ea47367..bc23f7d4 100644 --- a/drive/drivelink-selection/data/src/main/kotlin/me/proton/core/drive/drivelink/selection/data/repository/DriveLinkSelectionRepositoryImpl.kt +++ b/drive/drivelink-selection/data/src/main/kotlin/me/proton/core/drive/drivelink/selection/data/repository/DriveLinkSelectionRepositoryImpl.kt @@ -18,20 +18,54 @@ package me.proton.core.drive.drivelink.selection.data.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.drivelink.data.extension.toDriveLinks import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase import javax.inject.Inject import me.proton.core.drive.drivelink.selection.domain.repository.DriveLinkSelectionRepository +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.entity.LinkId import me.proton.core.drive.link.selection.domain.entity.SelectionId class DriveLinkSelectionRepositoryImpl @Inject constructor( private val db: DriveLinkSelectionDatabase, + private val configurationProvider: ConfigurationProvider, ) : DriveLinkSelectionRepository { override fun getSelectedDriveLinks(selectionId: SelectionId): Flow> = db.driveLinkSelectionDao.getSelectedLinks(selectionId).map { entities -> entities.toDriveLinks() } + + override suspend fun selectAll( + parentId: FolderId, + selectionId: SelectionId?, + getDriveLinks: (fromIndex: Int, count: Int) -> Flow>, + selectLinks: suspend (SelectionId?, List) -> Result, + ) { + val pageSize = configurationProvider.dbPageSize + var id = selectionId + db.inTransaction { + var pageIndex = 0 + while (true) { + val driveLinks = getDriveLinks( + pageIndex++ * pageSize, + pageSize, + ) + .map { driveLinks -> driveLinks.map { driveLink -> driveLink.id } } + .first() + if (driveLinks.isNotEmpty()) { + id = selectLinks( + id, + driveLinks, + ).getOrNull() + } else { + break + } + } + } + } } diff --git a/drive/drivelink-selection/domain/src/main/kotlin/me/proton/core/drive/drivelink/selection/domain/repository/DriveLinkSelectionRepository.kt b/drive/drivelink-selection/domain/src/main/kotlin/me/proton/core/drive/drivelink/selection/domain/repository/DriveLinkSelectionRepository.kt index 9b9208a4..12cf3eb9 100644 --- a/drive/drivelink-selection/domain/src/main/kotlin/me/proton/core/drive/drivelink/selection/domain/repository/DriveLinkSelectionRepository.kt +++ b/drive/drivelink-selection/domain/src/main/kotlin/me/proton/core/drive/drivelink/selection/domain/repository/DriveLinkSelectionRepository.kt @@ -19,9 +19,18 @@ package me.proton.core.drive.drivelink.selection.domain.repository import kotlinx.coroutines.flow.Flow import me.proton.core.drive.drivelink.domain.entity.DriveLink +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.entity.LinkId import me.proton.core.drive.link.selection.domain.entity.SelectionId interface DriveLinkSelectionRepository { fun getSelectedDriveLinks(selectionId: SelectionId): Flow> + + suspend fun selectAll( + parentId: FolderId, + selectionId: SelectionId?, + getDriveLinks: (fromIndex: Int, count: Int) -> Flow>, + selectLinks: suspend (SelectionId?, List) -> Result, + ) } diff --git a/drive/drivelink-selection/domain/src/main/kotlin/me/proton/core/drive/drivelink/selection/domain/usecase/SelectAll.kt b/drive/drivelink-selection/domain/src/main/kotlin/me/proton/core/drive/drivelink/selection/domain/usecase/SelectAll.kt new file mode 100644 index 00000000..3ee1719d --- /dev/null +++ b/drive/drivelink-selection/domain/src/main/kotlin/me/proton/core/drive/drivelink/selection/domain/usecase/SelectAll.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.drivelink.selection.domain.usecase + +import me.proton.core.drive.drivelink.domain.repository.DriveLinkRepository +import me.proton.core.drive.drivelink.selection.domain.repository.DriveLinkSelectionRepository +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.selection.domain.entity.SelectionId +import me.proton.core.drive.link.selection.domain.usecase.SelectLinks +import javax.inject.Inject + +class SelectAll @Inject constructor( + private val selectLinks: SelectLinks, + private val driveLinkSelectionRepository: DriveLinkSelectionRepository, + private val driveLinkRepository: DriveLinkRepository, +) { + + suspend operator fun invoke(parentId: FolderId, selectionId: SelectionId?) = driveLinkSelectionRepository.selectAll( + parentId = parentId, + selectionId = selectionId, + getDriveLinks = { fromIndex, count -> + driveLinkRepository.getDriveLinks(parentId, fromIndex, count) + }, + selectLinks = { id, linkIds -> + id?.let { + selectLinks(id, linkIds) + Result.success(id) + } ?: selectLinks(linkIds) + } + ) +} diff --git a/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorter.kt b/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorter.kt new file mode 100644 index 00000000..b4789f36 --- /dev/null +++ b/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorter.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.sorting.domain.sorter + +import android.os.Build +import me.proton.core.drive.drivelink.domain.entity.DriveLink +import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted +import me.proton.core.drive.sorting.domain.entity.Direction +import java.text.Collator +import android.icu.text.Collator as IcuCollator +import android.icu.text.RuleBasedCollator as IcuRuleBasedCollator + +object LocaleNameSorter : Sorter() { + + private val comparator: Comparator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + (IcuCollator.getInstance() as IcuRuleBasedCollator).apply { + numericCollation = true + } + } else { + Collator.getInstance() + } + + override fun sort(driveLinks: List, direction: Direction): List = + driveLinks.sortedWith( + compareBy { driveLink -> if (driveLink is DriveLink.Folder) 0 else 1 } + .thenBy { driveLink -> if (driveLink.isNameEncrypted) 0 else 1 } + .thenComparator { a, b -> + when (direction) { + Direction.ASCENDING -> comparator.compare(a.name, b.name) + Direction.DESCENDING -> comparator.compare(b.name, a.name) + } + } + ) +} diff --git a/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Sorter.kt b/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Sorter.kt index 2c32920f..2dab3f6d 100644 --- a/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Sorter.kt +++ b/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Sorter.kt @@ -28,7 +28,7 @@ sealed class Sorter { companion object Factory { operator fun get(by: By): Sorter = when (by) { - By.NAME -> NameSorter + By.NAME -> LocaleNameSorter By.LAST_MODIFIED -> LastModifiedSorter By.SIZE -> SizeSorter By.TYPE -> TypeSorter diff --git a/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Files.kt b/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Files.kt new file mode 100644 index 00000000..2e9572dd --- /dev/null +++ b/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Files.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.sorting.domain.sorter + +import io.mockk.every +import io.mockk.mockk +import me.proton.core.crypto.common.pgp.VerificationStatus +import me.proton.core.drive.base.domain.entity.Bytes +import me.proton.core.drive.base.domain.entity.CryptoProperty +import me.proton.core.drive.base.domain.entity.TimestampS +import me.proton.core.drive.drivelink.domain.entity.DriveLink + +fun file(name: String, type: String = "", lastModified: Long = 0L, size: Long = 0L) = + mockk() + .apply(name, type, lastModified, size) + +fun cryptedFile(name: String, type: String, lastModified: Long, size: Long) = + mockk() + .apply(name, type, lastModified, size) + .apply { every { cryptoName } returns CryptoProperty.Encrypted(name) } + +fun folder(name: String, lastModified: Long, size: Long) = mockk() + .apply(name, "Folder", lastModified, size) + +fun cryptedFolder(name: String, lastModified: Long, size: Long) = + mockk() + .apply(name, "Folder", lastModified, size) + .apply { every { cryptoName } returns CryptoProperty.Encrypted(name) } + +fun T.apply( + name: String, + type: String, + lastModifiedS: Long, + sizeB: Long +) = apply { + every { cryptoName } returns CryptoProperty.Decrypted(name, VerificationStatus.Success) + every { this@apply.name } returns name + every { mimeType } returns type + every { lastModified } returns TimestampS(lastModifiedS) + every { size } returns Bytes(sizeB) +} \ No newline at end of file diff --git a/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorterTest.kt b/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorterTest.kt new file mode 100644 index 00000000..9f57e8d5 --- /dev/null +++ b/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorterTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.sorting.domain.sorter + +import me.proton.core.drive.sorting.domain.entity.By +import me.proton.core.drive.sorting.domain.entity.Direction +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class LocaleNameSorterTest { + + private val File1 = file("File1") + private val file2 = file("file2") + private val file22 = file("file22") + private val file3 = file("file3") + private val file4 = file("file4") + private val file45 = file("file45") + private val file5 = file("file5") + + private val files = listOf( + file2, + File1, + file4, + file22, + file3, + file45, + file5, + ) + + @Test + @Config(sdk = [24]) + fun `sort files by locale names ascending`() { + + val sorted = Sorter.Factory[By.NAME].sort(files, Direction.ASCENDING) + + assertEquals( + listOf( + File1, + file2, + file3, + file4, + file5, + file22, + file45, + ).map { it.name }, + sorted.map { it.name }, + ) + } + + @Test + @Config(sdk = [24]) + fun `sort files by locale names descending`() { + + val sorted = Sorter.Factory[By.NAME].sort(files, Direction.DESCENDING) + + assertEquals( + listOf( + file45, + file22, + file5, + file4, + file3, + file2, + File1, + ).map { it.name }, + sorted.map { it.name }, + ) + } + + @Test + @Config(sdk = [23]) + fun `sort files by names ascending`() { + + val sorted = Sorter.Factory[By.NAME].sort(files, Direction.ASCENDING) + + assertEquals( + listOf( + File1, + file2, + file22, + file3, + file4, + file45, + file5, + ).map { it.name }, + sorted.map { it.name }, + ) + } + + @Test + @Config(sdk = [23]) + fun `sort files by names descending`() { + + val sorted = Sorter.Factory[By.NAME].sort(files, Direction.DESCENDING) + + assertEquals( + listOf( + file5, + file45, + file4, + file3, + file22, + file2, + File1, + ).map { it.name }, + sorted.map { it.name }, + ) + } +} \ No newline at end of file diff --git a/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/SorterTest.kt b/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/SorterTest.kt index e2b5f10b..cc3aa904 100644 --- a/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/SorterTest.kt +++ b/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/SorterTest.kt @@ -18,18 +18,14 @@ package me.proton.core.drive.drivelink.sorting.domain.sorter -import io.mockk.every -import io.mockk.mockk import junit.framework.TestCase.assertEquals -import me.proton.core.crypto.common.pgp.VerificationStatus -import me.proton.core.drive.base.domain.entity.Bytes -import me.proton.core.drive.base.domain.entity.TimestampS -import me.proton.core.drive.base.domain.entity.CryptoProperty -import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.sorting.domain.entity.By import me.proton.core.drive.sorting.domain.entity.Direction import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class SorterTest { private val drivelinks = listOf( @@ -774,26 +770,4 @@ class SorterTest { } // endregion // endregion - - private fun file(name: String, type: String, lastModified: Long, size: Long) = mockk() - .apply(name, type, lastModified, size) - - private fun cryptedFile(name: String, type: String, lastModified: Long, size: Long) = mockk() - .apply(name, type, lastModified, size) - .apply { every { cryptoName } returns CryptoProperty.Encrypted(name) } - - private fun folder(name: String, lastModified: Long, size: Long) = mockk() - .apply(name, "Folder", lastModified, size) - - private fun cryptedFolder(name: String, lastModified: Long, size: Long) = mockk() - .apply(name, "Folder", lastModified, size) - .apply { every { cryptoName } returns CryptoProperty.Encrypted(name) } - - private fun T.apply(name: String, type: String, lastModifiedS: Long, sizeB: Long) = apply { - every { cryptoName } returns CryptoProperty.Decrypted(name, VerificationStatus.Success) - every { this@apply.name } returns name - every { mimeType } returns type - every { lastModified } returns TimestampS(lastModifiedS) - every { size } returns Bytes(sizeB) - } } diff --git a/drive/drivelink/data/src/main/kotlin/me/proton/core/drive/drivelink/data/db/dao/DriveLinkDao.kt b/drive/drivelink/data/src/main/kotlin/me/proton/core/drive/drivelink/data/db/dao/DriveLinkDao.kt index f3e9deda..4ba4d5d8 100644 --- a/drive/drivelink/data/src/main/kotlin/me/proton/core/drive/drivelink/data/db/dao/DriveLinkDao.kt +++ b/drive/drivelink/data/src/main/kotlin/me/proton/core/drive/drivelink/data/db/dao/DriveLinkDao.kt @@ -49,6 +49,18 @@ interface DriveLinkDao : LinkDao { ) fun getLink(userId: UserId, shareId: String, linkId: String?): Flow> + @Query( + """ + SELECT $DRIVE_LINK_SELECT FROM $DRIVE_LINK_ENTITY + WHERE + LinkEntity.user_id = :userId AND + LinkEntity.share_id = :shareId AND + LinkEntity.parent_id = :parentId AND + ${LinkTrashDao.NOT_TRASHED_CONDITION} + """ + ) + fun getLinks(userId: UserId, shareId: String, parentId: String): Flow> + @Query( """ SELECT $DRIVE_LINK_SELECT FROM $DRIVE_LINK_ENTITY @@ -57,9 +69,20 @@ interface DriveLinkDao : LinkDao { LinkEntity.share_id = :shareId AND LinkEntity.parent_id = :parentId AND ${LinkTrashDao.NOT_TRASHED_CONDITION} - """ + LIMIT :limit OFFSET :offset + """ ) - fun getLinks(userId: UserId, shareId: String, parentId: String?): Flow> + fun getLinks(userId: UserId, shareId: String, parentId: String?, limit: Int, offset: Int): Flow> + + @Query(""" + SELECT COUNT(*) FROM (SELECT DISTINCT LinkEntity.id FROM $DRIVE_LINK_ENTITY + WHERE + LinkEntity.user_id = :userId AND + LinkEntity.share_id = :shareId AND + LinkEntity.parent_id = :parentId AND + ${LinkTrashDao.NOT_TRASHED_CONDITION}) + """) + fun getLinksCountFlow(userId: UserId, shareId: String, parentId: String?): Flow @Query( """ diff --git a/drive/drivelink/data/src/main/kotlin/me/proton/core/drive/drivelink/data/repository/DriveLinkRepositoryImpl.kt b/drive/drivelink/data/src/main/kotlin/me/proton/core/drive/drivelink/data/repository/DriveLinkRepositoryImpl.kt index 9a32c7ee..4c9e0f4a 100644 --- a/drive/drivelink/data/src/main/kotlin/me/proton/core/drive/drivelink/data/repository/DriveLinkRepositoryImpl.kt +++ b/drive/drivelink/data/src/main/kotlin/me/proton/core/drive/drivelink/data/repository/DriveLinkRepositoryImpl.kt @@ -48,6 +48,13 @@ class DriveLinkRepositoryImpl @Inject constructor( .distinctUntilChanged() .map { entities -> entities.toDriveLinks() } + override fun getDriveLinksCount(parentId: FolderId): Flow = + driveLinkDao.getLinksCountFlow(parentId.userId, parentId.shareId.id, parentId.id) + + override fun getDriveLinks(parentId: FolderId, fromIndex: Int, count: Int): Flow> = + driveLinkDao.getLinks(parentId.userId, parentId.shareId.id, parentId.id, count, fromIndex) + .map { entities -> entities.toDriveLinks() } + override fun getDriveLinks(linkIds: List): Flow> = linkIds .groupBy({ linkId -> linkId.shareId }) { linkId -> linkId.id } diff --git a/drive/drivelink/domain/src/main/kotlin/me/proton/core/drive/drivelink/domain/repository/DriveLinkRepository.kt b/drive/drivelink/domain/src/main/kotlin/me/proton/core/drive/drivelink/domain/repository/DriveLinkRepository.kt index 866733d6..55208768 100644 --- a/drive/drivelink/domain/src/main/kotlin/me/proton/core/drive/drivelink/domain/repository/DriveLinkRepository.kt +++ b/drive/drivelink/domain/src/main/kotlin/me/proton/core/drive/drivelink/domain/repository/DriveLinkRepository.kt @@ -29,5 +29,9 @@ interface DriveLinkRepository { fun getDriveLinks(parentId: FolderId): Flow> + fun getDriveLinks(parentId: FolderId, fromIndex: Int, count: Int): Flow> + + fun getDriveLinksCount(parentId: FolderId): Flow + fun getDriveLinks(linkIds: List): Flow> } diff --git a/drive/drivelink/domain/src/main/kotlin/me/proton/core/drive/drivelink/domain/usecase/GetDriveLinksCount.kt b/drive/drivelink/domain/src/main/kotlin/me/proton/core/drive/drivelink/domain/usecase/GetDriveLinksCount.kt new file mode 100644 index 00000000..ed69abc1 --- /dev/null +++ b/drive/drivelink/domain/src/main/kotlin/me/proton/core/drive/drivelink/domain/usecase/GetDriveLinksCount.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.drivelink.domain.usecase + +import kotlinx.coroutines.flow.Flow +import me.proton.core.drive.drivelink.domain.repository.DriveLinkRepository +import me.proton.core.drive.link.domain.entity.FolderId +import javax.inject.Inject + +class GetDriveLinksCount @Inject constructor( + private val repository: DriveLinkRepository, +) { + operator fun invoke(parentId: FolderId): Flow = + repository.getDriveLinksCount(parentId) +} diff --git a/drive/event-manager/data/build.gradle.kts b/drive/event-manager/data/build.gradle.kts index 52be4aef..4d3d753b 100644 --- a/drive/event-manager/data/build.gradle.kts +++ b/drive/event-manager/data/build.gradle.kts @@ -32,4 +32,5 @@ driveModule( implementation(libs.core.dataRoom) implementation(libs.core.presentation) // AppLifecycleProvider implementation(libs.core.userSettings) + implementation(libs.core.user.data) } diff --git a/drive/file/base/domain/build.gradle.kts b/drive/file/base/domain/build.gradle.kts index 8896b74a..9eae8d83 100644 --- a/drive/file/base/domain/build.gradle.kts +++ b/drive/file/base/domain/build.gradle.kts @@ -26,5 +26,4 @@ driveModule( ) { api(project(":drive:link:domain")) implementation(project(":drive:event-manager:base:domain")) - implementation(project(":drive:base:data")) } diff --git a/drive/file/base/domain/src/main/kotlin/me/proton/core/drive/file/base/domain/entity/XAttr.kt b/drive/file/base/domain/src/main/kotlin/me/proton/core/drive/file/base/domain/entity/XAttr.kt index 33309a0a..e3df3355 100644 --- a/drive/file/base/domain/src/main/kotlin/me/proton/core/drive/file/base/domain/entity/XAttr.kt +++ b/drive/file/base/domain/src/main/kotlin/me/proton/core/drive/file/base/domain/entity/XAttr.kt @@ -17,8 +17,8 @@ */ package me.proton.core.drive.file.base.domain.entity -import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable @Serializable data class XAttr( @@ -35,6 +35,8 @@ data class XAttr( val size: Long? = null, @SerialName("BlockSizes") val blockSizes: List? = null, + @SerialName("Digests") + val digests: Map? = null, ) @Serializable diff --git a/drive/file/base/domain/src/main/kotlin/me/proton/core/drive/file/base/domain/usecase/CreateXAttr.kt b/drive/file/base/domain/src/main/kotlin/me/proton/core/drive/file/base/domain/usecase/CreateXAttr.kt index f0477ccb..1abf75ad 100644 --- a/drive/file/base/domain/src/main/kotlin/me/proton/core/drive/file/base/domain/usecase/CreateXAttr.kt +++ b/drive/file/base/domain/src/main/kotlin/me/proton/core/drive/file/base/domain/usecase/CreateXAttr.kt @@ -39,12 +39,14 @@ class CreateXAttr @Inject constructor( size: Bytes, blockSizes: List, mediaResolution: MediaResolution? = null, + digests: Map? = null, ) = XAttr( common = XAttr.Common( modificationTime = dateTimeFormatter.formatToIso8601String(modificationTime), size = size.value, - blockSizes = blockSizes.map { blockSize -> blockSize.value } + blockSizes = blockSizes.map { blockSize -> blockSize.value }, + digests = digests, ), media = mediaResolution?.let { XAttr.Media( diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/Files.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/Files.kt index 9f01c1bd..eaa976dd 100644 --- a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/Files.kt +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/Files.kt @@ -17,11 +17,11 @@ */ package me.proton.core.drive.files.presentation.component +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import me.proton.core.compose.flow.rememberFlowWithLifecycle +import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing import me.proton.core.drive.base.domain.entity.Percentage import me.proton.core.drive.base.presentation.component.TopAppBar import me.proton.core.drive.drivelink.domain.entity.DriveLink @@ -125,6 +126,7 @@ private inline fun ListContent( viewEvent: FilesViewEvent, uploadFileLinkList: List, lazyListState: LazyListState, + verticalArrangement : Arrangement.Vertical = Arrangement.Top, crossinline content: LazyListScope.() -> Unit, ) { val modifier = if (viewState.listContentState is ListContentState.Content) { @@ -132,7 +134,11 @@ private inline fun ListContent( } else { Modifier } - LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState, + verticalArrangement = verticalArrangement, + ) { itemsIndexed(uploadFileLinkList, key = { _, uploadFileLink -> uploadFileLink.id }) { index, uploadFileLink -> UploadItem(viewState, viewEvent, index, uploadFileLink) } @@ -188,8 +194,14 @@ private fun LazyColumnItems.DisplayAsGrid( BoxWithConstraints { val itemsPerRow = floor(maxWidth / GridItemWidth).roundToInt().coerceAtLeast(1) val lazyListState = this@DisplayAsGrid.rememberLazyListState() - ListContent(viewState, viewEvent, uploadFileLinkList, lazyListState) { - FilesGridContent(this@DisplayAsGrid, GridItemWidth, itemsPerRow) { driveLink: DriveLink -> + ListContent( + viewState = viewState, + viewEvent = viewEvent, + uploadFileLinkList = uploadFileLinkList, + lazyListState = lazyListState, + verticalArrangement = Arrangement.spacedBy(ExtraSmallSpacing) + ) { + FilesGridContent(this@DisplayAsGrid, itemsPerRow) { driveLink: DriveLink -> val selected = selectedDriveLinks.contains(driveLink.id) FilesGridItem( link = driveLink, @@ -200,7 +212,7 @@ private fun LazyColumnItems.DisplayAsGrid( isClickEnabled = viewState.isClickEnabled, isTextEnabled = viewState.isTextEnabled, transferProgressFlow = remember(driveLink.downloadState) { getTransferProgress(driveLink) }, - modifier = Modifier.width(GridItemWidth), + modifier = Modifier.weight(1F), isSelected = selected, inMultiselect = selected || selectedDriveLinks.isNotEmpty(), ) diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/CircleSelection.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/CircleSelection.kt new file mode 100644 index 00000000..79840c3e --- /dev/null +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/CircleSelection.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + + +package me.proton.core.drive.files.presentation.component.files + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.drive.base.presentation.R as BasePresentation +import me.proton.core.presentation.R as CorePresentation + + +@Composable +fun CircleSelection(isSelected: Boolean) { + if (isSelected) { + Image( + modifier = Modifier.size(ProtonDimens.DefaultIconSize), + painter = painterResource(id = BasePresentation.drawable.ic_checkmark_circle_filled), + contentDescription = null + ) + } else { + Icon( + modifier = Modifier.size(ProtonDimens.DefaultIconSize), + painter = painterResource(id = CorePresentation.drawable.ic_proton_circle), + tint = ProtonTheme.colors.iconWeak, + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesGridItem.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesGridItem.kt index c1e5a97c..a668d23e 100644 --- a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesGridItem.kt +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesGridItem.kt @@ -18,6 +18,7 @@ package me.proton.core.drive.files.presentation.component.files +import android.content.res.Configuration import androidx.compose.animation.Crossfade import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -29,14 +30,17 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Checkbox -import androidx.compose.material.CheckboxDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -44,29 +48,29 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.Flow +import me.proton.core.compose.flow.rememberFlowWithLifecycle import me.proton.core.compose.theme.ProtonDimens.DefaultButtonMinHeight import me.proton.core.compose.theme.ProtonDimens.DefaultIconSize -import me.proton.core.compose.theme.ProtonTheme -import me.proton.core.drive.drivelink.domain.entity.DriveLink -import me.proton.core.drive.linkdownload.domain.entity.DownloadState import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing import me.proton.core.compose.theme.ProtonDimens.SmallIconSize import me.proton.core.compose.theme.ProtonDimens.SmallSpacing +import me.proton.core.compose.theme.ProtonTheme import me.proton.core.compose.theme.defaultSmall import me.proton.core.drive.base.domain.entity.Percentage -import me.proton.core.compose.flow.rememberFlowWithLifecycle import me.proton.core.drive.base.presentation.component.EncryptedItem import me.proton.core.drive.base.presentation.component.LinearProgressIndicator -import me.proton.core.drive.base.presentation.component.protonColors import me.proton.core.drive.base.presentation.component.text.TextWithMiddleEllipsis +import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted +import me.proton.core.drive.linkdownload.domain.entity.DownloadState import me.proton.core.drive.thumbnail.presentation.extension.thumbnailPainter import me.proton.core.presentation.R as CorePresentation @@ -102,6 +106,10 @@ fun FilesGridItem( onLongClick(link) }, ) + .background( + color = if (isSelected) ProtonTheme.colors.backgroundSecondary else Color.Transparent, + shape = ProtonTheme.shapes.medium, + ) .padding(SmallSpacing), ) { Column( @@ -113,16 +121,11 @@ fun FilesGridItem( Crossfade(inMultiselect) { inMultiselect -> if (inMultiselect) { Box( - modifier = Modifier - .size(IconSize) - .padding(MoreButtonPadding), + modifier = Modifier.size(DefaultButtonMinHeight), contentAlignment = Alignment.Center, ) { - Checkbox( - checked = isSelected, - onCheckedChange = null, - modifier = Modifier.scale(1.25f), - colors = CheckboxDefaults.protonColors(), + CircleSelection( + isSelected = isSelected, ) } } else { @@ -189,7 +192,9 @@ fun GridItemImage( ) { Box( modifier = modifier - .size(ImageWidth, ImageHeight) + .defaultMinSize(ImageWidth, ImageHeight) + .fillMaxWidth() + .aspectRatio(ImageRatio) .border(1.dp, ProtonTheme.colors.separatorNorm, ProtonTheme.shapes.medium) .clip(ProtonTheme.shapes.medium) ) { @@ -252,5 +257,96 @@ private fun GridItemMoreButton( private val ImageHeight = 96.dp private val ImageWidth = 158.dp +private val ImageRatio = ImageWidth/ImageHeight private val MoreButtonPadding = 12.dp private const val MoreButtonAlpha = 0.7f + +@Preview( + name = "GridItem not downloaded, not favorite, not shared in light mode", + group = "light mode", + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, +) +@Preview( + name = "GridItem not downloaded, not favorite, not shared in dark mode", + group = "dark mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Suppress("unused") +@Composable +private fun PreviewGridItem() { + ProtonTheme { + Surface(modifier = Modifier.background(MaterialTheme.colors.background)) { + FilesGridItem( + link = PREVIEW_DRIVELINK, + onClick = {}, + onLongClick = {}, + onMoreOptionsClick = {}, + isClickEnabled = { false }, + isTextEnabled = { true }, + ) + } + } +} +@Preview( + name = "GridItem in multi select, selected in light mode", + group = "light mode", + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, +) +@Preview( + name = "GridItem in multi select, selected in dark mode", + group = "dark mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Suppress("unused") +@Composable +private fun PreviewSelectedGridItem() { + ProtonTheme { + Surface(modifier = Modifier.background(MaterialTheme.colors.background)) { + FilesGridItem( + link = PREVIEW_DRIVELINK, + onClick = {}, + onLongClick = {}, + onMoreOptionsClick = {}, + isClickEnabled = { false }, + isTextEnabled = { true }, + isSelected = true, + inMultiselect = true, + ) + } + } +} +@Preview( + name = "GridItem in multi select, unselected in light mode", + group = "light mode", + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, +) +@Preview( + name = "GridItem in multi select, unselected in dark mode", + group = "dark mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Suppress("unused") +@Composable +private fun PreviewUnselectedGridItem() { + ProtonTheme { + Surface(modifier = Modifier.background(MaterialTheme.colors.background)) { + FilesGridItem( + link = PREVIEW_DRIVELINK, + onClick = {}, + onLongClick = {}, + onMoreOptionsClick = {}, + isClickEnabled = { false }, + isTextEnabled = { true }, + isSelected = false, + inMultiselect = true, + ) + } + } +} + diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesList.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesList.kt index add7fa90..34d128bb 100644 --- a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesList.kt +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesList.kt @@ -24,11 +24,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items @@ -39,7 +39,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.paging.compose.items import me.proton.core.compose.component.DeferredCircularProgressIndicator import me.proton.core.compose.component.ErrorPadding @@ -47,6 +46,7 @@ import me.proton.core.compose.component.ProtonErrorMessage import me.proton.core.compose.component.ProtonErrorMessageWithAction import me.proton.core.compose.component.ProtonSecondaryButton import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing +import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing import me.proton.core.compose.theme.ProtonDimens.MediumSpacing import me.proton.core.compose.theme.ProtonDimens.SmallSpacing import me.proton.core.compose.theme.ProtonTheme @@ -199,22 +199,21 @@ fun LazyListScope.FilesListContent( fun LazyListScope.FilesGridContent( driveLinks: LazyColumnItems, - itemSize: Dp, itemsPerRow: Int, - onItemsIndexed: @Composable (DriveLink) -> Unit, + onItemsIndexed: @Composable RowScope.(DriveLink) -> Unit, ) { require(itemsPerRow > 0) { "itemsPerRow must be > 0, value passed $itemsPerRow" } items(ceil(driveLinks.size.toFloat() / itemsPerRow).toInt()) { row -> Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth().padding(horizontal = ExtraSmallSpacing), + horizontalArrangement = Arrangement.spacedBy(ExtraSmallSpacing), ) { repeat(itemsPerRow) { repeat -> val driveLink = driveLinks[row * itemsPerRow + repeat] if (driveLink != null) { onItemsIndexed(driveLink) } else { - Spacer(modifier = Modifier.width(itemSize)) + Spacer(modifier = Modifier.weight(1F)) } } } diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesListItem.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesListItem.kt index 3f980510..a6a3f809 100644 --- a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesListItem.kt +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesListItem.kt @@ -31,8 +31,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Checkbox -import androidx.compose.material.CheckboxDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -44,7 +42,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext @@ -65,31 +62,20 @@ import me.proton.core.compose.theme.ProtonDimens.SmallSpacing import me.proton.core.compose.theme.ProtonTheme import me.proton.core.compose.theme.captionWeak import me.proton.core.compose.theme.default -import me.proton.core.domain.entity.UserId -import me.proton.core.drive.base.domain.entity.Attributes -import me.proton.core.drive.base.domain.entity.Bytes import me.proton.core.drive.base.domain.entity.Percentage -import me.proton.core.drive.base.domain.entity.Permissions -import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.toPercentString import me.proton.core.drive.base.presentation.component.EncryptedItem import me.proton.core.drive.base.presentation.component.LinearProgressIndicator -import me.proton.core.drive.base.presentation.component.protonColors import me.proton.core.drive.base.presentation.component.text.TextWithMiddleEllipsis import me.proton.core.drive.base.presentation.extension.currentLocale import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted -import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.Folder -import me.proton.core.drive.link.domain.entity.FolderId -import me.proton.core.drive.link.domain.entity.Link import me.proton.core.drive.link.domain.extension.isSharedUrlExpired import me.proton.core.drive.link.presentation.extension.getSize import me.proton.core.drive.link.presentation.extension.lastModifiedRelative import me.proton.core.drive.linkdownload.domain.entity.DownloadState -import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.thumbnail.presentation.extension.thumbnailPainter -import me.proton.core.drive.volume.domain.entity.VolumeId import me.proton.core.drive.base.presentation.R as BasePresentation import me.proton.core.presentation.R as CorePresentation @@ -118,7 +104,7 @@ fun FilesListItem( Box( modifier = modifier .fillMaxWidth() - .background(color = if (isSelected) ProtonTheme.colors.interactionWeakNorm else Color.Transparent) + .background(color = if (isSelected) ProtonTheme.colors.backgroundSecondary else Color.Transparent) .combinedClickable( enabled = onClick != null && isClickEnabled(link), onClick = { onClick?.invoke(link) }, @@ -135,30 +121,14 @@ fun FilesListItem( .padding(start = StartPadding, end = EndPadding) .padding(vertical = VerticalPadding), ) { - Crossfade(isSelected) { isSelected -> - if (isSelected) { - Box( - modifier = Modifier - .size(IconSize), - contentAlignment = Alignment.Center, - ) { - Checkbox( - checked = true, - onCheckedChange = null, - modifier = Modifier.scale(1.75f), - colors = CheckboxDefaults.protonColors(), - ) - } - } else { - Image( - modifier = Modifier - .size(IconSize) - .clip(RoundedCornerShape(DefaultCornerRadius)), - painter = link.thumbnailPainter().painter, - contentDescription = null, - ) - } - } + + Image( + modifier = Modifier + .size(IconSize) + .clip(RoundedCornerShape(DefaultCornerRadius)), + painter = link.thumbnailPainter().painter, + contentDescription = null, + ) Details( modifier = Modifier .weight(1f) @@ -186,6 +156,15 @@ fun FilesListItem( ) } } + } else { + Crossfade(isSelected) { isSelected -> + Box( + modifier = Modifier.size(DefaultButtonMinHeight), + contentAlignment = Alignment.Center, + ) { + CircleSelection(isSelected) + } + } } } } @@ -409,6 +388,67 @@ fun PreviewListItem() { } } } +@Preview( + name = "ListItem in multiselect, selected, in light mode", + group = "light mode", + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, +) +@Preview( + name = "ListItem in multiselect, selected, in dark mode", + group = "dark mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Suppress("unused") +@Composable +fun PreviewSelectedListItem() { + ProtonTheme { + Surface(modifier = Modifier.background(MaterialTheme.colors.background)) { + FilesListItem( + link = PREVIEW_DRIVELINK, + onClick = {}, + onLongClick = {}, + onMoreOptionsClick = {}, + isClickEnabled = { false }, + isTextEnabled = { true }, + isSelected = true, + inMultiselect = true, + ) + } + } +} + +@Preview( + name = "ListItem in multiselect, unselected, in light mode", + group = "light mode", + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, +) +@Preview( + name = "ListItem in multiselect, unselected, in dark mode", + group = "dark mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, +) +@Suppress("unused") +@Composable +fun PreviewUnselectedListItem() { + ProtonTheme { + Surface(modifier = Modifier.background(MaterialTheme.colors.background)) { + FilesListItem( + link = PREVIEW_DRIVELINK, + onClick = {}, + onLongClick = {}, + onMoreOptionsClick = {}, + isClickEnabled = { false }, + isTextEnabled = { true }, + isSelected = false, + inMultiselect = true, + ) + } + } +} @Preview( name = "ListItem downloaded but not favorite, not shared in light mode", @@ -553,49 +593,6 @@ val Height = 68.dp val TitleStartPadding = 20.dp val ExtraSmallIconSize = 12.dp -private val PREVIEW_LINK = Link.File( - id = FileId(ShareId(UserId("USER_ID"), "SHARE_ID"), "FILE_ID"), - parentId = FolderId(ShareId(UserId("USER_ID"), "SHARE_ID"), "PARENT_ID"), - name = "revision_id", - size = Bytes(0L), - lastModified = TimestampS(0), - mimeType = "text/plain", - isShared = false, - key = "", - passphrase = "", - passphraseSignature = "", - numberOfAccesses = 0L, - uploadedBy = "He-Who-Must-Not-Be-Named", - isFavorite = false, - hasThumbnail = false, - activeRevisionId = "", - xAttr = null, - contentKeyPacket = "", - contentKeyPacketSignature = "", - attributes = Attributes(0), - permissions = Permissions(0), - state = Link.State.ACTIVE, - nameSignatureEmail = "", - hash = "", - expirationTime = null, - nodeKey = "", - nodePassphrase = "", - nodePassphraseSignature = "", - signatureAddress = "", - creationTime = TimestampS(0), - trashedTime = null, - shareUrlExpirationTime = null, - shareUrlId = null, -) -private val PREVIEW_DRIVELINK = DriveLink.File( - link = PREVIEW_LINK, - volumeId = VolumeId("VOLUME_ID"), - isMarkedAsOffline = false, - isAnyAncestorMarkedAsOffline = false, - downloadState = null, - trashState = null, -) - object FilesListItemComponentTestTag { const val item = "file list item" const val folder = "folder item" diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/Previews.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/Previews.kt new file mode 100644 index 00000000..c28f165e --- /dev/null +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/Previews.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.files.presentation.component.files + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.entity.Attributes +import me.proton.core.drive.base.domain.entity.Bytes +import me.proton.core.drive.base.domain.entity.Permissions +import me.proton.core.drive.base.domain.entity.TimestampS +import me.proton.core.drive.drivelink.domain.entity.DriveLink +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.entity.Link +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.volume.domain.entity.VolumeId + +internal val PREVIEW_LINK = Link.File( + id = FileId(ShareId(UserId("USER_ID"), "SHARE_ID"), "FILE_ID"), + parentId = FolderId(ShareId(UserId("USER_ID"), "SHARE_ID"), "PARENT_ID"), + name = "revision_id", + size = Bytes(0L), + lastModified = TimestampS(0), + mimeType = "text/plain", + isShared = false, + key = "", + passphrase = "", + passphraseSignature = "", + numberOfAccesses = 0L, + uploadedBy = "He-Who-Must-Not-Be-Named", + isFavorite = false, + hasThumbnail = false, + activeRevisionId = "", + xAttr = null, + contentKeyPacket = "", + contentKeyPacketSignature = "", + attributes = Attributes(0), + permissions = Permissions(0), + state = Link.State.ACTIVE, + nameSignatureEmail = "", + hash = "", + expirationTime = null, + nodeKey = "", + nodePassphrase = "", + nodePassphraseSignature = "", + signatureAddress = "", + creationTime = TimestampS(0), + trashedTime = null, + shareUrlExpirationTime = null, + shareUrlId = null, +) +internal val PREVIEW_DRIVELINK = DriveLink.File( + link = PREVIEW_LINK, + volumeId = VolumeId("VOLUME_ID"), + isMarkedAsOffline = false, + isAnyAncestorMarkedAsOffline = false, + downloadState = null, + trashState = null, +) \ No newline at end of file diff --git a/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolder.kt b/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolder.kt index 66d1ee02..e38c3a79 100644 --- a/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolder.kt +++ b/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolder.kt @@ -27,11 +27,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.delay @@ -77,7 +81,7 @@ fun CreateFolder( fun CreateFolder( @StringRes titleResId: Int, folderName: String, - selection: IntRange, + selection: IntRange?, showProgress: Boolean, modifier: Modifier = Modifier, inputError: String? = null, @@ -116,7 +120,7 @@ fun CreateFolder( @Composable fun CreateFolderContent( folderName: String, - selection: IntRange, + selection: IntRange?, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() }, inputError: String? = null, @@ -127,12 +131,38 @@ fun CreateFolderContent( .height(intrinsicSize = IntrinsicSize.Max) ) { Spacer(modifier = Modifier.size(DefaultSpacing)) + var state by remember { + mutableStateOf( + TextFieldValue( + text = folderName, + selection = if (selection == null) { + TextRange.Zero + } else { + TextRange(selection.first, selection.last) + } + ) + ) + } + LaunchedEffect(folderName, selection) { + state = state.copy( + text = folderName, + selection = if (selection != null) { + TextRange(selection.first, selection.last) + } else { + state.selection + } + ) + } OutlinedTextFieldWithError( - text = folderName, - selection = selection, + textFieldValue = state, errorText = inputError, focusRequester = focusRequester, - onValueChanged = onValueChanged, + onValueChanged = { textFieldValue -> + if (state != textFieldValue) { + state = textFieldValue + onValueChanged(textFieldValue.text) + } + }, modifier = Modifier .testTag(CreateFolderComponentTestTag.folderNameTextField), ) diff --git a/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolderViewModel.kt b/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolderViewModel.kt index 16b52a96..2f0d7118 100644 --- a/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolderViewModel.kt +++ b/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolderViewModel.kt @@ -71,7 +71,7 @@ class CreateFolderViewModel @Inject constructor( ) { name, error, inProgress -> initialViewState.copy( name = name, - selection = if (error != null) name.selectAll else name.endPosition, + selection = if (error != null) name.selectAll else null, error = error, inProgress = inProgress ) @@ -133,9 +133,6 @@ class CreateFolderViewModel @Inject constructor( private val String.selectAll get() = IntRange(0, length) - private val String.endPosition - get() = IntRange(length, length) - companion object { const val KEY_SHARE_ID = "shareId" const val KEY_PARENT_ID = "parentId" diff --git a/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolderViewState.kt b/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolderViewState.kt index ff6d8fd2..2731b5a1 100644 --- a/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolderViewState.kt +++ b/drive/folder-create/presentation/src/main/kotlin/me/proton/core/drive/folder/create/presentation/CreateFolderViewState.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Immutable data class CreateFolderViewState( @StringRes val titleResId: Int, val name: String, - val selection: IntRange = IntRange(name.length, name.length), + val selection: IntRange? = null, val error: String? = null, val inProgress: Boolean = false ) diff --git a/drive/link-trash/data-test/build.gradle.kts b/drive/link-trash/data-test/build.gradle.kts new file mode 100644 index 00000000..3c52a90a --- /dev/null +++ b/drive/link-trash/data-test/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +driveModule( + hilt = true, +) { + api(project(":drive:link-trash:domain")) + implementation(project(":drive:link:data-test")) +} + +configureJacoco() diff --git a/drive/link-trash/data-test/src/main/AndroidManifest.xml b/drive/link-trash/data-test/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0b69d23f --- /dev/null +++ b/drive/link-trash/data-test/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + diff --git a/drive/link-trash/data-test/src/main/kotlin/me/proton/core/drive/linktrash/data/test/di/TestLinkTrashBindModule.kt b/drive/link-trash/data-test/src/main/kotlin/me/proton/core/drive/linktrash/data/test/di/TestLinkTrashBindModule.kt new file mode 100644 index 00000000..179c0d35 --- /dev/null +++ b/drive/link-trash/data-test/src/main/kotlin/me/proton/core/drive/linktrash/data/test/di/TestLinkTrashBindModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.linktrash.data.test.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import me.proton.core.drive.linktrash.data.test.repository.StubbedLinkTrashRepository +import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository +import javax.inject.Singleton + +@Suppress("unused") +@ExperimentalCoroutinesApi +@Module +@InstallIn(SingletonComponent::class) +interface TestLinkTrashBindModule { + @Binds + @Singleton + fun bindLinkTrashRepository(repository: StubbedLinkTrashRepository): LinkTrashRepository +} diff --git a/drive/link-trash/data-test/src/main/kotlin/me/proton/core/drive/linktrash/data/test/repository/StubbedLinkTrashRepository.kt b/drive/link-trash/data-test/src/main/kotlin/me/proton/core/drive/linktrash/data/test/repository/StubbedLinkTrashRepository.kt new file mode 100644 index 00000000..46a06e87 --- /dev/null +++ b/drive/link-trash/data-test/src/main/kotlin/me/proton/core/drive/linktrash/data/test/repository/StubbedLinkTrashRepository.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.linktrash.data.test.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.transform +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.ResponseSource +import me.proton.core.drive.link.data.test.NullableFile +import me.proton.core.drive.link.data.test.NullableFolder +import me.proton.core.drive.link.domain.entity.BaseLink +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.entity.Link +import me.proton.core.drive.link.domain.entity.LinkId +import me.proton.core.drive.linktrash.domain.entity.TrashState +import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository +import me.proton.core.drive.share.domain.entity.ShareId +import javax.inject.Inject + +class StubbedLinkTrashRepository @Inject constructor() : LinkTrashRepository { + + private val workIds = MutableStateFlow(emptyMap>()) + private val stateFlow = MutableStateFlow(emptyMap, TrashState>()) + private var trashContent = emptyList() + + val state: Map, TrashState> + get() = stateFlow.value + + override suspend fun insertOrUpdateTrashState(linkIds: List, trashState: TrashState) { + stateFlow.value = stateFlow.value + (linkIds to trashState) + } + + override suspend fun removeTrashState(linkIds: List) { + stateFlow.value = stateFlow.value.filterKeys { it != linkIds } + } + + override suspend fun markTrashedLinkAsDeleted(shareId: ShareId) { + stateFlow.value = stateFlow.value.map { (linkIds, trashState) -> + if (linkIds.any { linkId -> linkId.shareId == shareId }) { + linkIds to TrashState.DELETED + } else { + linkIds to trashState + } + }.toMap() + } + + override fun hasTrashContent(shareId: ShareId): Flow { + return stateFlow.transform { state -> + emit( + state.filterKeys { it.any { linkId -> linkId.shareId == shareId } } + .filterValues { it in listOf(TrashState.TRASHING, TrashState.DELETED) } + .isNotEmpty() + ) + } + } + + override suspend fun hasWorkWithId(workId: String): Boolean { + return workIds.value.containsKey(workId) + } + + override suspend fun insertOrIgnoreWorkId(linkIds: List, workId: String) { + workIds.value = workIds.value + (workId to linkIds) + } + + override suspend fun insertWork(linkIds: List, retries: Int): DataResult { + val workId = "work-id-${linkIds.first().shareId.id}" + workIds.value = workIds.value + (workId to linkIds) + return DataResult.Success(ResponseSource.Local, workId) + } + + override suspend fun getLinksAndRemoveWorkFromCache(workId: String): List { + val work = workIds.value.filterKeys { id -> id == workId } + workIds.value = workIds.value.filterNot { (id, _) -> id == workId } + return work.values.map { linkIds -> + linkIds.map { linkId -> + val parentId = FolderId(linkId.shareId, "folder-id") + when (linkId) { + is FolderId -> NullableFolder(id = linkId, parentId = parentId) + is FileId -> NullableFile(id = linkId, parentId = parentId) + } as Link + } + }.flatten() + } + + override suspend fun markTrashContentAsFetched(shareId: ShareId) { + trashContent = trashContent + shareId + } + + override suspend fun shouldInitiallyFetchTrashContent(shareId: ShareId): Boolean { + return trashContent.contains(shareId).not() + } + + override suspend fun isTrashed(linkId: LinkId): Boolean { + return stateFlow.value + .filterKeys { linkIds -> linkId in linkIds } + .filterValues { trashState -> trashState.isTrashed() } + .isNotEmpty() + } + + override suspend fun isAnyTrashed(linkIds: Set): Boolean { + return linkIds.fold(false) { acc, linkId -> + acc || isTrashed(linkId) + } + } + + private fun TrashState.isTrashed(): Boolean { + return this !in listOf(TrashState.TRASHING, TrashState.DELETED) + } +} + +val LinkTrashRepository.state + get() = (this as StubbedLinkTrashRepository).state + +fun LinkTrashRepository.stateForLinks(vararg links: BaseLink): TrashState = + (this as StubbedLinkTrashRepository).state.getValue(links.map { it.id }) \ No newline at end of file diff --git a/drive/link-trash/data-test/src/test/kotlin/me/proton/core/drive/linktrash/data/test/repository/StubbedLinkTrashRepositoryTest.kt b/drive/link-trash/data-test/src/test/kotlin/me/proton/core/drive/linktrash/data/test/repository/StubbedLinkTrashRepositoryTest.kt new file mode 100644 index 00000000..8b36767b --- /dev/null +++ b/drive/link-trash/data-test/src/test/kotlin/me/proton/core/drive/linktrash/data/test/repository/StubbedLinkTrashRepositoryTest.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.linktrash.data.test.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.linktrash.domain.entity.TrashState +import me.proton.core.drive.share.domain.entity.ShareId +import org.junit.Assert.* +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class StubbedLinkTrashRepositoryTest { + + private val repository = StubbedLinkTrashRepository() + + + private val userId = UserId("user-id") + private val shareId = ShareId(userId, "share-id") + private val folderId = FolderId(shareId, "folder-id") + private val otherShareId = ShareId(userId, "other-share-id") + private val otherFolderId = FolderId(otherShareId, "other-folder-id") + + @Test + fun insertOrUpdateTrashState() = runTest { + repository.insertOrUpdateTrashState(listOf(folderId), TrashState.TRASHING) + + assertEquals(TrashState.TRASHING, repository.state[listOf(folderId)]) + } + + @Test + fun removeTrashState() = runTest { + repository.insertOrUpdateTrashState(listOf(folderId), TrashState.TRASHING) + repository.insertOrUpdateTrashState(listOf(otherFolderId), TrashState.TRASHING) + + repository.removeTrashState(listOf(folderId)) + + assertEquals(mapOf(listOf(otherFolderId) to TrashState.TRASHING), repository.state) + } + + @Test + fun markTrashedLinkAsDeleted() = runTest { + repository.insertOrUpdateTrashState(listOf(folderId), TrashState.TRASHING) + repository.insertOrUpdateTrashState(listOf(otherFolderId), TrashState.TRASHING) + + repository.markTrashedLinkAsDeleted(shareId) + + assertEquals( + mapOf( + listOf(folderId) to TrashState.DELETED, + listOf(otherFolderId) to TrashState.TRASHING, + ), repository.state + ) + } + + @Test + fun hasTrashContent() = runTest { + val hasTrashContent = repository.hasTrashContent(shareId) + assertFalse(hasTrashContent.first()) + + repository.insertOrUpdateTrashState(listOf(folderId), TrashState.TRASHING) + + assertTrue(hasTrashContent.first()) + } + + @Test + fun hasWorkWithId() = runTest { + assertFalse(repository.hasWorkWithId("work-id-share-id")) + + repository.insertWork(listOf(folderId)) + + assertTrue(repository.hasWorkWithId("work-id-share-id")) + } + + @Test + fun insertOrIgnoreWorkId() = runTest { + val workId = "work-id" + + repository.insertOrIgnoreWorkId(listOf(folderId), workId) + + assertTrue(repository.hasWorkWithId(workId)) + } + + @Test + fun insertWork() = runTest { + repository.insertWork(listOf(folderId)) + + assertTrue(repository.hasWorkWithId("work-id-share-id")) + } + + @Test + fun `Given folder id When getLinksAndRemoveWorkFromCache Then returns a folder`() = runTest { + val workId = "work-id" + + repository.insertOrIgnoreWorkId(listOf(folderId), workId) + + val links = repository.getLinksAndRemoveWorkFromCache(workId) + + assertEquals(folderId, links.first().id) + assertFalse(repository.hasWorkWithId(workId)) + } + + @Test + fun `Given file id When getLinksAndRemoveWorkFromCache Then returns a file`() = runTest { + val workId = "work-id" + val fileId = FileId(shareId, "file-id") + + repository.insertOrIgnoreWorkId(listOf(fileId), workId) + + val links = repository.getLinksAndRemoveWorkFromCache(workId) + + assertEquals(fileId, links.first().id) + assertFalse(repository.hasWorkWithId(workId)) + } + + @Test + fun shouldInitiallyFetchTrashContent() = runTest { + assertTrue(repository.shouldInitiallyFetchTrashContent(shareId)) + + repository.markTrashContentAsFetched(shareId) + + assertFalse(repository.shouldInitiallyFetchTrashContent(shareId)) + } + + @Test + fun `Given empty When isTrashed Then returns false`() = runTest { + assertFalse(repository.isTrashed(folderId)) + } + + @Test + fun `Given trashed folder When isTrashed Then returns true`() = runTest { + repository.insertOrUpdateTrashState(listOf(folderId), TrashState.TRASHED) + + assertTrue(repository.isTrashed(folderId)) + } + + @Test + fun `Given trashing folder When isTrashed Then returns false`() = runTest { + repository.insertOrUpdateTrashState(listOf(folderId), TrashState.TRASHING) + + assertFalse(repository.isTrashed(folderId)) + } + + @Test + fun `Given empty When isAnyTrashed Then returns false`() = runTest { + assertFalse(repository.isAnyTrashed(setOf(folderId))) + } + + @Test + fun `Given trashed folder When isAnyTrashed Then returns true`() = runTest { + repository.insertOrUpdateTrashState(listOf(folderId), TrashState.TRASHED) + + assertTrue(repository.isAnyTrashed(setOf(folderId))) + } + + @Test + fun `Given trashing folder When isAnyTrashed Then returns false`() = runTest { + repository.insertOrUpdateTrashState(listOf(folderId), TrashState.TRASHING) + + assertFalse(repository.isAnyTrashed(setOf(folderId))) + } +} \ No newline at end of file diff --git a/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/db/dao/LinkUploadDao.kt b/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/db/dao/LinkUploadDao.kt index bfe42840..b6632a80 100644 --- a/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/db/dao/LinkUploadDao.kt +++ b/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/db/dao/LinkUploadDao.kt @@ -131,6 +131,12 @@ abstract class LinkUploadDao : BaseDao() { WHERE id = :id """) abstract fun updateMediaResolution(id: Long, mediaResolutionWidth: Long, mediaResolutionHeight: Long) + @Query(""" + UPDATE LinkUploadEntity SET + digests = :digests + WHERE id = :id + """) + abstract fun updateDigests(id: Long, digests: String) open fun getDistinctFlow(id: Long) = getFlow(id).distinctUntilChanged() } diff --git a/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/db/entity/LinkUploadEntity.kt b/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/db/entity/LinkUploadEntity.kt index c5ee3479..4275d765 100644 --- a/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/db/entity/LinkUploadEntity.kt +++ b/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/db/entity/LinkUploadEntity.kt @@ -27,6 +27,7 @@ import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.data.db.Column import me.proton.core.drive.base.data.db.Column.CONTENT_KEY_PACKET import me.proton.core.drive.base.data.db.Column.CONTENT_KEY_PACKET_SIGNATURE +import me.proton.core.drive.base.data.db.Column.DIGESTS import me.proton.core.drive.base.data.db.Column.ID import me.proton.core.drive.base.data.db.Column.LAST_MODIFIED import me.proton.core.drive.base.data.db.Column.LINK_ID @@ -113,4 +114,6 @@ data class LinkUploadEntity( val mediaResolutionWidth: Long? = null, @ColumnInfo(name = MEDIA_RESOLUTION_HEIGHT, defaultValue = "NULL") val mediaResolutionHeight: Long? = null, + @ColumnInfo(name = DIGESTS, defaultValue = "NULL") + val digests: String? = null, ) diff --git a/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/extension/LinkUploadEntity.kt b/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/extension/LinkUploadEntity.kt index 565a303a..ff0c0400 100644 --- a/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/extension/LinkUploadEntity.kt +++ b/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/extension/LinkUploadEntity.kt @@ -17,14 +17,18 @@ */ package me.proton.core.drive.linkupload.data.extension +import kotlinx.serialization.SerializationException +import me.proton.core.data.room.BuildConfig import me.proton.core.drive.base.domain.entity.MediaResolution import me.proton.core.drive.base.domain.entity.TimestampMs import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.data.db.entity.LinkUploadEntity +import me.proton.core.drive.linkupload.domain.entity.UploadDigests import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.volume.domain.entity.VolumeId +import me.proton.core.util.kotlin.deserialize import me.proton.core.util.kotlin.takeIfNotEmpty fun LinkUploadEntity.toUploadFileLink() = @@ -54,5 +58,15 @@ fun LinkUploadEntity.toUploadFileLink() = width = requireNotNull(mediaResolutionWidth), height = requireNotNull(mediaResolutionHeight), ) - } + }, + digests = digests?.let { json -> + try { + UploadDigests(json.deserialize()) + }catch (e: SerializationException){ + if (BuildConfig.DEBUG) { + throw e + } + UploadDigests() + } + } ?: UploadDigests() ) diff --git a/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/repository/LinkUploadRepositoryImpl.kt b/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/repository/LinkUploadRepositoryImpl.kt index a4d8a859..8e9e04e9 100644 --- a/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/repository/LinkUploadRepositoryImpl.kt +++ b/drive/link-upload/data/src/main/kotlin/me/proton/core/drive/linkupload/data/repository/LinkUploadRepositoryImpl.kt @@ -37,10 +37,12 @@ import me.proton.core.drive.linkupload.data.extension.toUploadBulkEntity import me.proton.core.drive.linkupload.data.extension.toUploadFileLink import me.proton.core.drive.linkupload.domain.entity.UploadBlock import me.proton.core.drive.linkupload.domain.entity.UploadBulk +import me.proton.core.drive.linkupload.domain.entity.UploadDigests import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import me.proton.core.drive.linkupload.domain.entity.UploadState import me.proton.core.drive.linkupload.domain.factory.UploadBlockFactory import me.proton.core.drive.linkupload.domain.repository.LinkUploadRepository +import me.proton.core.util.kotlin.serialize import javax.inject.Inject class LinkUploadRepositoryImpl @Inject constructor( @@ -143,6 +145,11 @@ class LinkUploadRepositoryImpl @Inject constructor( mediaResolutionWidth = mediaResolution.width, mediaResolutionHeight = mediaResolution.height, ) + override suspend fun updateUploadFileLinkDigests(uploadFileLinkId: Long, digests: UploadDigests) = + db.linkUploadDao.updateDigests( + id = uploadFileLinkId, + digests = digests.values.serialize() + ) override suspend fun removeUploadFileLink(uploadFileLinkId: Long) = db.linkUploadDao.delete(uploadFileLinkId) diff --git a/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/entity/UploadDigests.kt b/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/entity/UploadDigests.kt new file mode 100644 index 00000000..fa3c18fd --- /dev/null +++ b/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/entity/UploadDigests.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.linkupload.domain.entity + +data class UploadDigests(val values: Map = emptyMap()) diff --git a/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/entity/UploadFileLink.kt b/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/entity/UploadFileLink.kt index abff3069..93d0baa1 100644 --- a/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/entity/UploadFileLink.kt +++ b/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/entity/UploadFileLink.kt @@ -47,4 +47,5 @@ data class UploadFileLink( val uriString: String? = null, val shouldDeleteSourceUri: Boolean = false, val mediaResolution: MediaResolution? = null, + val digests : UploadDigests = UploadDigests() ) diff --git a/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/repository/LinkUploadRepository.kt b/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/repository/LinkUploadRepository.kt index 660afc12..7bc5a935 100644 --- a/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/repository/LinkUploadRepository.kt +++ b/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/repository/LinkUploadRepository.kt @@ -26,6 +26,7 @@ import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.domain.entity.UploadBlock import me.proton.core.drive.linkupload.domain.entity.UploadBulk +import me.proton.core.drive.linkupload.domain.entity.UploadDigests import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import me.proton.core.drive.linkupload.domain.entity.UploadState @@ -75,6 +76,8 @@ interface LinkUploadRepository { suspend fun updateUploadFileLinkMediaResolution(uploadFileLinkId: Long, mediaResolution: MediaResolution) + suspend fun updateUploadFileLinkDigests(uploadFileLinkId: Long, digests: UploadDigests) + suspend fun removeUploadFileLink(uploadFileLinkId: Long) suspend fun insertUploadBlocks(uploadFileLink: UploadFileLink, uploadBlocks: List) diff --git a/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/usecase/UpdateDigests.kt b/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/usecase/UpdateDigests.kt new file mode 100644 index 00000000..f9a62db2 --- /dev/null +++ b/drive/link-upload/domain/src/main/kotlin/me/proton/core/drive/linkupload/domain/usecase/UpdateDigests.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022-2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.linkupload.domain.usecase + +import me.proton.core.drive.linkupload.domain.entity.UploadDigests +import me.proton.core.drive.linkupload.domain.entity.UploadFileLink +import me.proton.core.drive.linkupload.domain.repository.LinkUploadRepository +import javax.inject.Inject + +class UpdateDigests @Inject constructor( + private val linkUploadRepository: LinkUploadRepository, + private val getUploadFileLinkAfterOperation: GetUploadFileLinkAfterOperation, +) { + suspend operator fun invoke( + uploadFileLinkId: Long, + digests: UploadDigests, + ): Result = getUploadFileLinkAfterOperation(uploadFileLinkId) { + linkUploadRepository.updateUploadFileLinkDigests(uploadFileLinkId, digests) + } +} diff --git a/drive/link/data-test/build.gradle.kts b/drive/link/data-test/build.gradle.kts new file mode 100644 index 00000000..befc3d36 --- /dev/null +++ b/drive/link/data-test/build.gradle.kts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +driveModule() { + api(project(":drive:link:domain")) +} diff --git a/drive/link/data-test/src/main/AndroidManifest.xml b/drive/link/data-test/src/main/AndroidManifest.xml new file mode 100644 index 00000000..954a3398 --- /dev/null +++ b/drive/link/data-test/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + diff --git a/drive/link/data-test/src/main/kotlin/me/proton/core/drive/link/data/test/NullableBaseLink.kt b/drive/link/data-test/src/main/kotlin/me/proton/core/drive/link/data/test/NullableBaseLink.kt new file mode 100644 index 00000000..b595e42e --- /dev/null +++ b/drive/link/data-test/src/main/kotlin/me/proton/core/drive/link/data/test/NullableBaseLink.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +@file:Suppress("FunctionName") +/* + * Copyright (c) 2023 Proton Technologies AG + * This file is part of Proton Technologies AG and ProtonCore. + * + * ProtonCore is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonCore is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonCore. If not, see . + */ +package me.proton.core.drive.link.data.test + +import me.proton.core.drive.base.domain.entity.Attributes +import me.proton.core.drive.base.domain.entity.Bytes +import me.proton.core.drive.base.domain.entity.Permissions +import me.proton.core.drive.base.domain.entity.TimestampS +import me.proton.core.drive.link.domain.entity.File +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.Folder +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.entity.Link +import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId + +fun NullableFile(parentId: FolderId, filename: String = "test-file"): File { + return NullableFile(id = FileId(parentId.shareId, filename), parentId = parentId) +} + +fun NullableFile( + id: FileId, + parentId: FolderId, + name: String = "", + size: Bytes = Bytes(0L), + lastModified: TimestampS = TimestampS(0L), + mimeType: String = "", + isShared: Boolean = false, + key: String = "", + passphrase: String = "", + passphraseSignature: String = "", + numberOfAccesses: Long = 0, + shareUrlExpirationTime: TimestampS? = null, + uploadedBy: String = "", + isFavorite: Boolean = false, + attributes: Attributes = Attributes(0L), + permissions: Permissions = Permissions(0L), + state: Link.State = Link.State.ACTIVE, + nameSignatureEmail: String? = null, + hash: String = "", + expirationTime: TimestampS? = null, + nodeKey: String = "", + nodePassphrase: String = "", + nodePassphraseSignature: String = "", + signatureAddress: String = "", + creationTime: TimestampS = TimestampS(0L), + trashedTime: TimestampS? = null, + hasThumbnail: Boolean = false, + activeRevisionId: String = "", + xAttr: String? = null, + shareUrlId: ShareUrlId? = null, + contentKeyPacket: String = "", + contentKeyPacketSignature: String? = null, +): File { + return Link.File( + id = id, + parentId = parentId, + name = name, + size = size, + lastModified = lastModified, + mimeType = mimeType, + isShared = isShared, + key = key, + passphrase = passphrase, + passphraseSignature = passphraseSignature, + numberOfAccesses = numberOfAccesses, + shareUrlExpirationTime = shareUrlExpirationTime, + uploadedBy = uploadedBy, + isFavorite = isFavorite, + attributes = attributes, + permissions = permissions, + state = state, + nameSignatureEmail = nameSignatureEmail, + hash = hash, + expirationTime = expirationTime, + nodeKey = nodeKey, + nodePassphrase = nodePassphrase, + nodePassphraseSignature = nodePassphraseSignature, + signatureAddress = signatureAddress, + creationTime = creationTime, + trashedTime = trashedTime, + hasThumbnail = hasThumbnail, + activeRevisionId = activeRevisionId, + xAttr = xAttr, + shareUrlId = shareUrlId, + contentKeyPacket = contentKeyPacket, + contentKeyPacketSignature = contentKeyPacketSignature, + ) +} + +fun NullableFolder(parentId: FolderId, filename: String = "test-folder"): Folder { + return NullableFolder(id = FolderId(parentId.shareId, filename), parentId = parentId) +} + +fun NullableFolder( + id: FolderId, + parentId: FolderId, + name: String = "", + size: Bytes = Bytes(0L), + lastModified: TimestampS = TimestampS(0L), + mimeType: String = "", + isShared: Boolean = false, + key: String = "", + passphrase: String = "", + passphraseSignature: String = "", + numberOfAccesses: Long = 0, + shareUrlExpirationTime: TimestampS? = null, + uploadedBy: String = "", + isFavorite: Boolean = false, + attributes: Attributes = Attributes(0L), + permissions: Permissions = Permissions(0L), + state: Link.State = Link.State.ACTIVE, + nameSignatureEmail: String? = null, + hash: String = "", + expirationTime: TimestampS? = null, + nodeKey: String = "", + nodePassphrase: String = "", + nodePassphraseSignature: String = "", + signatureAddress: String = "", + creationTime: TimestampS = TimestampS(0L), + trashedTime: TimestampS? = null, + xAttr: String? = null, + shareUrlId: ShareUrlId? = null, + nodeHashKey: String = "", +): Folder { + return Link.Folder( + id = id, + parentId = parentId, + name = name, + size = size, + lastModified = lastModified, + mimeType = mimeType, + isShared = isShared, + key = key, + passphrase = passphrase, + passphraseSignature = passphraseSignature, + numberOfAccesses = numberOfAccesses, + shareUrlExpirationTime = shareUrlExpirationTime, + uploadedBy = uploadedBy, + isFavorite = isFavorite, + attributes = attributes, + permissions = permissions, + state = state, + nameSignatureEmail = nameSignatureEmail, + hash = hash, + expirationTime = expirationTime, + nodeKey = nodeKey, + nodePassphrase = nodePassphrase, + nodePassphraseSignature = nodePassphraseSignature, + signatureAddress = signatureAddress, + creationTime = creationTime, + trashedTime = trashedTime, + xAttr = xAttr, + shareUrlId = shareUrlId, + nodeHashKey = nodeHashKey + ) +} \ No newline at end of file diff --git a/drive/link/data/build.gradle.kts b/drive/link/data/build.gradle.kts index 907c8892..5f6a550d 100644 --- a/drive/link/data/build.gradle.kts +++ b/drive/link/data/build.gradle.kts @@ -32,5 +32,6 @@ driveModule( implementation(project(":drive:share:data")) implementation(libs.retrofit) androidTestImplementation(libs.bundles.core) + androidTestImplementation(project(":drive:db")) kaptAndroidTest(libs.androidx.room.compiler) } diff --git a/drive/link/data/src/androidTest/kotlin/me/proton/core/drive/link/data/db/LinkWithPropertiesTest.kt b/drive/link/data/src/androidTest/kotlin/me/proton/core/drive/link/data/db/LinkWithPropertiesTest.kt index 02c71359..996ae251 100644 --- a/drive/link/data/src/androidTest/kotlin/me/proton/core/drive/link/data/db/LinkWithPropertiesTest.kt +++ b/drive/link/data/src/androidTest/kotlin/me/proton/core/drive/link/data/db/LinkWithPropertiesTest.kt @@ -18,11 +18,13 @@ package me.proton.core.drive.link.data.db import android.content.Context +import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import me.proton.android.drive.db.DriveDatabase import me.proton.core.account.data.entity.AccountEntity import me.proton.core.account.domain.entity.AccountState import me.proton.core.domain.entity.UserId @@ -40,12 +42,12 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) class LinkWithPropertiesTest { - private lateinit var db: TestDatabase + private lateinit var db: DriveDatabase @Before fun createDb() { val context = ApplicationProvider.getApplicationContext() - db = buildDatabase(context) + db = Room.inMemoryDatabaseBuilder(context, DriveDatabase::class.java).build() runBlocking { prepareDb(db) } } @@ -93,10 +95,10 @@ class LinkWithPropertiesTest { val link = insertAndGetLink(LinkWithProperties(testParentLink, testFolderProperties(testParentLink.id))) // endregion // region When - db.linkDao().delete(link) + db.linkDao.delete(link) // endregion //region Then - assertFalse(db.linkDao().hasLinkEntity(testParentLink.userId, testParentLink.shareId, testParentLink.id).first()) + assertFalse(db.linkDao.hasLinkEntity(testParentLink.userId, testParentLink.shareId, testParentLink.id).first()) // endregion } @@ -107,14 +109,14 @@ class LinkWithPropertiesTest { insertAndGetLink(LinkWithProperties(testLink, testFileProperties(testLink.id))) // endregion // region When - db.linkDao().delete(parentLink) + db.linkDao.delete(parentLink) // endregion //region Then - assertFalse(db.linkDao().hasLinkEntity(testLink.userId, testLink.shareId, testLink.id).first()) + assertFalse(db.linkDao.hasLinkEntity(testLink.userId, testLink.shareId, testLink.id).first()) // endregion } - private suspend fun insertAndGetLink(linkWithProperties: LinkWithProperties) = with (db.linkDao()) { + private suspend fun insertAndGetLink(linkWithProperties: LinkWithProperties) = with (db.linkDao) { insertOrUpdate(linkWithProperties) getLinkWithPropertiesFlow( userId = linkWithProperties.link.userId, @@ -125,9 +127,9 @@ class LinkWithPropertiesTest { .first() } - private suspend fun prepareDb(db: TestDatabase) { + private suspend fun prepareDb(db: DriveDatabase) { db.accountDao().insertOrUpdate(testAccount) - db.shareDao().insertOrUpdate(testShare) + db.shareDao.insertOrUpdate(testShare) } private val testAccount = diff --git a/drive/link/data/src/androidTest/kotlin/me/proton/core/drive/link/data/db/TestDatabase.kt b/drive/link/data/src/androidTest/kotlin/me/proton/core/drive/link/data/db/TestDatabase.kt deleted file mode 100644 index 120e5d36..00000000 --- a/drive/link/data/src/androidTest/kotlin/me/proton/core/drive/link/data/db/TestDatabase.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2021-2023 Proton AG. - * This file is part of Proton Core. - * - * Proton Core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Proton Core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Proton Core. If not, see . - */ -package me.proton.core.drive.link.data.db - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import me.proton.core.account.data.db.AccountDao -import me.proton.core.account.data.entity.AccountEntity -import me.proton.core.account.data.entity.SessionEntity -import me.proton.core.data.room.db.CommonConverters -import me.proton.core.drive.link.data.db.entity.LinkEntity -import me.proton.core.drive.link.data.db.entity.LinkFilePropertiesEntity -import me.proton.core.drive.link.data.db.entity.LinkFolderPropertiesEntity -import me.proton.core.drive.share.data.db.ShareDao -import me.proton.core.drive.share.data.db.ShareEntity -import me.proton.core.user.data.db.UserConverters - -@Database( - entities = [ - AccountEntity::class, - SessionEntity::class, - LinkEntity::class, - LinkFilePropertiesEntity::class, - LinkFolderPropertiesEntity::class, - ShareEntity::class, - ], - version = 1, - exportSchema = false, -) -@TypeConverters( - CommonConverters::class, - UserConverters::class, -) -abstract class TestDatabase : RoomDatabase() { - abstract fun linkDao(): LinkDao - abstract fun accountDao(): AccountDao - abstract fun shareDao(): ShareDao -} - -fun buildDatabase(context: Context): TestDatabase = - Room.inMemoryDatabaseBuilder( - context, TestDatabase::class.java - ).build() diff --git a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/api/extension/GetLinkResponses.kt b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/api/extension/GetLinkResponses.kt index f193a8e4..f2800b71 100644 --- a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/api/extension/GetLinkResponses.kt +++ b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/api/extension/GetLinkResponses.kt @@ -18,10 +18,10 @@ package me.proton.core.drive.link.data.api.extension -import me.proton.core.data.arch.toDataResult import me.proton.core.domain.arch.DataResult import me.proton.core.domain.arch.ResponseSource import me.proton.core.drive.base.data.api.ProtonApiCode +import me.proton.core.drive.base.domain.extension.toDataResult import me.proton.core.drive.link.data.api.response.LinkResponses import me.proton.core.drive.link.domain.entity.LinkId import me.proton.core.network.domain.ApiException @@ -53,6 +53,6 @@ inline fun associateResults( try { block().mapResults(links) } catch (e: ApiException) { - val result = e.error.toDataResult() + val result = e.toDataResult() links.associateWith { result } } diff --git a/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawer.kt b/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawer.kt index cfb99b55..2cbe155a 100644 --- a/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawer.kt +++ b/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawer.kt @@ -246,6 +246,7 @@ fun NavigationDrawerListItem( ) { ProtonListItem( icon = icon, + iconTintColor = ProtonTheme.colors.iconWeak, title = title, modifier = modifier .clickable { diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/ImagePreview.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/ImagePreview.kt index 37b1eaef..514d5891 100644 --- a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/ImagePreview.kt +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/ImagePreview.kt @@ -27,41 +27,31 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import coil.annotation.ExperimentalCoilApi import coil.compose.rememberImagePainter import coil.request.ImageRequest -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.isNightMode import me.proton.core.drive.files.preview.R -import me.proton.core.drive.files.preview.presentation.component.state.ZoomEffect @Composable -@OptIn(ExperimentalCoilApi::class) fun ImagePreview( uri: Uri, - zoomEffect: Flow, + transformationState: TransformationState, isFullScreen: Boolean, modifier: Modifier = Modifier, ) { val backgroundColor by animateColorAsState( - targetValue = if (isFullScreen) { + targetValue = if (isFullScreen && isNightMode().not()) { MaterialTheme.colors.onBackground } else { MaterialTheme.colors.background @@ -76,37 +66,24 @@ fun ImagePreview( ImagePreview( modifier = modifier.background(backgroundColor), painter = painter, - zoomEffect = zoomEffect, + transformationState = transformationState, ) } @Composable fun ImagePreview( painter: Painter, - zoomEffect: Flow, + transformationState: TransformationState, modifier: Modifier = Modifier, ) { - var scale by remember { mutableStateOf(1f) } - var offset by remember { mutableStateOf(Offset.Zero) } - - LaunchedEffect(LocalContext.current) { - zoomEffect - .onEach { zoomEffect -> - if (zoomEffect is ZoomEffect.Reset) { - scale = 1f - offset = Offset.Zero - } - } - .launchIn(this) - } - val state = rememberTransformableState { zoomChange, offsetChange, _ -> - scale *= zoomChange - if (scale < 1f) scale = 1f - offset += offsetChange + transformationState.scale = (transformationState.scale * zoomChange) + transformationState.addOffset(offsetChange) } Box( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .onPlaced { transformationState.containerLayout = it }, contentAlignment = Alignment.Center ) { Image( @@ -114,12 +91,13 @@ fun ImagePreview( contentDescription = stringResource(id = R.string.content_description_file_preview), contentScale = ContentScale.Fit, modifier = modifier - .fillMaxSize() + .onPlaced { transformationState.contentLayout = it } .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offset.x, - translationY = offset.y + scaleX = transformationState.scale, + scaleY = transformationState.scale, + translationX = transformationState.offset.x, + translationY = transformationState.offset.y, + clip = true, ) .transformable( state = state @@ -134,7 +112,7 @@ fun PreviewImagePreview() { ProtonTheme { ImagePreview( uri = Uri.parse("https://protonmail.com/images/media/live/protonmail-shot-decrypt.jpg"), - zoomEffect = emptyFlow(), + transformationState = rememberTransformationState(), isFullScreen = false, ) } diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/MediaPreview.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/MediaPreview.kt index af1d0ee5..e5797cd7 100644 --- a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/MediaPreview.kt +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/MediaPreview.kt @@ -42,6 +42,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.isNightMode import me.proton.core.drive.base.presentation.extension.conditional @Composable @@ -54,7 +55,7 @@ fun MediaPreview( mediaControllerVisibility: (Boolean) -> Unit = {} ) { val backgroundColor by animateColorAsState( - targetValue = if (isFullScreen) { + targetValue = if (isFullScreen && isNightMode().not()) { MaterialTheme.colors.onBackground } else { MaterialTheme.colors.background diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/PdfPreview.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/PdfPreview.kt index 5cb7721d..72f63c33 100644 --- a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/PdfPreview.kt +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/PdfPreview.kt @@ -17,68 +17,90 @@ */ package me.proton.core.drive.files.preview.presentation.component +import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.pdf.PdfRenderer import android.net.Uri -import androidx.annotation.WorkerThread +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import coil.annotation.ExperimentalCoilApi +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import me.proton.core.compose.theme.ProtonTheme import me.proton.core.compose.theme.overline -import me.proton.core.drive.files.preview.presentation.component.state.ZoomEffect +import java.io.IOException +import kotlin.math.min +import kotlin.math.roundToInt @Composable fun PdfPreview( uri: Uri, - zoomEffect: Flow, + transformationState: TransformationState, modifier: Modifier = Modifier, onRenderFailed: (Throwable) -> Unit, ) { - var pages by remember { mutableStateOf>(emptyList()) } val context = LocalContext.current - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { + + val pdfReader by produceState(initialValue = null, uri) { + value = PdfReader(context).apply { try { - pages = context.renderPdf(uri) - } catch (t: Throwable) { - onRenderFailed(t) + openPdf(uri) + } catch (e: IOException){ + onRenderFailed(e) + } + } + awaitDispose { + launch { + value?.close() } } } - if (pages.isEmpty()) { + val reader = pdfReader + if (reader == null) { PdfLoading(modifier) } else { PdfPreview( modifier = modifier, - zoomEffect = zoomEffect, - pages = pages, + transformationState = transformationState, + onRenderFailed = onRenderFailed, + reader = reader, ) } } @@ -94,27 +116,73 @@ fun PdfLoading(modifier: Modifier = Modifier) { } @Composable -@OptIn(ExperimentalCoilApi::class) +@OptIn(ExperimentalFoundationApi::class) fun PdfPreview( - zoomEffect: Flow, - pages: List, + transformationState: TransformationState, + reader: PdfReader, + onRenderFailed: (Throwable) -> Unit, modifier: Modifier = Modifier, ) { + val lazyListState = rememberLazyListState() + + val density = LocalDensity.current.density + val maxWidth = with(LocalDensity.current) { + LocalConfiguration.current.smallestScreenWidthDp.dp.roundToPx() * 2 + } + LazyColumn( modifier = modifier .fillMaxSize() - .background(Color.White) + .onPlaced { transformationState.containerLayout = it } + .background(Color.White), + userScrollEnabled = !transformationState.hasScale(), + state = lazyListState, + flingBehavior = rememberSnapFlingBehavior(lazyListState) ) { - itemsIndexed(pages) { index, item -> - Column { - ImagePreview( - painter = BitmapPainter(item), - modifier = Modifier.fillParentMaxHeight(), - zoomEffect = zoomEffect - ) + items(reader.pageCount) { index -> + var item by remember { mutableStateOf(null) } + + LaunchedEffect(index) { + try { + item = reader.renderPage(index, density, maxWidth) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + onRenderFailed(e) + } + } + + val transformableState = rememberTransformableState { zoomChange, offsetChange, _ -> + transformationState.scale = transformationState.scale * zoomChange + transformationState.addOffset(offsetChange) + } + Box( + modifier = Modifier + .fillParentMaxSize() + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + if (item != null) { + Image( + painter = BitmapPainter(item as ImageBitmap), + contentDescription = null, + modifier = Modifier + .onPlaced { transformationState.contentLayout = it } + .graphicsLayer( + scaleX = transformationState.scale, + scaleY = transformationState.scale, + translationX = transformationState.offset.x, + translationY = transformationState.offset.y, + ) + .transformable(transformableState), + ) + } PageNumber( pageNumber = index + 1, - pageCount = pages.size, + pageCount = reader.pageCount, + modifier = Modifier + .navigationBarsPadding() + .align(Alignment.BottomCenter) ) } } @@ -141,27 +209,56 @@ fun PageNumber( } } -@WorkerThread -fun Context.renderPdf(uri: Uri): List { - val pfd = contentResolver.openAssetFileDescriptor(uri, "r")?.parcelFileDescriptor ?: return emptyList() - return PdfRenderer(pfd).use { pdfRenderer -> - (0 until pdfRenderer.pageCount).map { pageIndex -> - renderPage( - pdfRenderer = pdfRenderer, - pageIndex = pageIndex, - ) +class PdfReader(private val context: Context) { + private var pdfRenderer: PdfRenderer? = null + + val pageCount: Int + get() = pdfRenderer?.pageCount ?: -1 + + private val mutex = Mutex() + + @SuppressLint("Recycle") + suspend fun openPdf(uri: Uri) { + withContext(Dispatchers.IO) { + pdfRenderer = context.contentResolver.openAssetFileDescriptor(uri, "r")?.let { + PdfRenderer(it.parcelFileDescriptor) + } } } + + suspend fun close() { + withContext(Dispatchers.IO) { + mutex.withLock { + pdfRenderer?.close() + pdfRenderer = null + } + } + } + + suspend fun renderPage( + pageIndex: Int, + density: Float, + maxWidth: Int, + ): ImageBitmap { + return withContext(Dispatchers.IO) { + mutex.withLock { + val page = pdfRenderer?.openPage(pageIndex) + ?: throw IllegalStateException("Open page with index $pageIndex failed.") + page.use { + val width = min(page.width.fromPtToPx(density), maxWidth) + Bitmap.createBitmap( + width, + page.height * width / page.width, + Bitmap.Config.ARGB_8888 + ).also { bitmap -> + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + }.asImageBitmap() + } + } + } + } + } -internal fun renderPage( - pdfRenderer: PdfRenderer, - pageIndex: Int, -): ImageBitmap { - val page = pdfRenderer.openPage(pageIndex) ?: throw IllegalStateException("Open page with index $pageIndex failed.") - return page.use { - Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888).also { bitmap -> - page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) - }.asImageBitmap() - } -} +private fun Int.fromPtToPx(density: Float): Int = + (this.toFloat() / 72F * 160F * density).roundToInt() diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/Preview.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/Preview.kt index 21b17a37..03f83ada 100644 --- a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/Preview.kt +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/Preview.kt @@ -17,13 +17,13 @@ */ package me.proton.core.drive.files.preview.presentation.component -import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets @@ -35,7 +35,9 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,11 +47,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -61,7 +63,6 @@ import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.rememberPagerState import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -80,12 +81,12 @@ import me.proton.core.drive.base.presentation.component.TopAppBar import me.proton.core.drive.base.presentation.entity.FileTypeCategory import me.proton.core.drive.base.presentation.extension.conditional import me.proton.core.drive.base.presentation.extension.debugOnly +import me.proton.core.drive.base.presentation.extension.isLandscape import me.proton.core.drive.files.preview.R import me.proton.core.drive.files.preview.presentation.component.event.PreviewViewEvent import me.proton.core.drive.files.preview.presentation.component.state.ContentState import me.proton.core.drive.files.preview.presentation.component.state.PreviewContentState import me.proton.core.drive.files.preview.presentation.component.state.PreviewViewState -import me.proton.core.drive.files.preview.presentation.component.state.ZoomEffect import me.proton.core.util.kotlin.exhaustive import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -98,19 +99,11 @@ import me.proton.core.presentation.R as CorePresentation fun Preview( viewState: PreviewViewState, viewEvent: PreviewViewEvent, - zoomEffect: Flow, modifier: Modifier = Modifier, onPageChanged: FlowCollector? = null, ) { - val detectTapGestureModifier = Modifier - .pointerInput(Unit) { - detectTapGestures( - onTap = { viewEvent.onSingleTap() }, - onDoubleTap = { viewEvent.onDoubleTap() } - ) - } val pagerState = rememberPagerState(initialPage = viewState.currentIndex) - + val userScrollEnabled = remember { mutableStateOf(true) } val isFullScreen by rememberFlowWithLifecycle(viewState.isFullscreen).collectAsState(false) onPageChanged?.let { @@ -133,16 +126,16 @@ fun Preview( HorizontalPager( state = pagerState, count = viewState.items.size, + userScrollEnabled = userScrollEnabled.value, key = { page -> viewState.items[page].key } ) { page -> PreviewContent( viewState.items[page], isFullScreen, viewEvent, - zoomEffect, with(LocalDensity.current) { topBarHeightAnimated.toDp() }, isFocused = pagerState.currentPage == page, - detectTapGestureModifier, + userScrollEnabled, ) } AnimatedVisibility( @@ -150,7 +143,7 @@ fun Preview( enter = slideInVertically(initialOffsetY = { fullHeight: Int -> -fullHeight }), exit = slideOutVertically(targetOffsetY = { fullHeight: Int -> -fullHeight }), ) { - val item = viewState.items.getOrNull(viewState.currentIndex) + val item = viewState.items.getOrNull(pagerState.currentPage) TopAppBar( modifier = Modifier .background(appBarGradient) @@ -160,7 +153,8 @@ fun Preview( } .onSizeChanged { size -> topBarHeight = size.height - }.testTag(PreviewComponentTestTag.screen), + } + .testTag(PreviewComponentTestTag.screen), navigationIcon = painterResource(id = viewState.navigationIconResId), onNavigationIcon = { viewEvent.onTopAppBarNavigation() }, title = item?.title ?: "", @@ -178,9 +172,6 @@ fun Preview( } } -private val isLandscape: Boolean @Composable get() = - LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - private val appBarGradient: Brush @Composable get() = Brush.verticalGradient( colors = listOf(0.8f, 0.7f, 0.6f, 0.5f, 0f).map { alpha -> ProtonTheme.colors.backgroundNorm.copy(alpha = alpha) @@ -192,10 +183,9 @@ fun PreviewContent( item: PreviewViewState.Item, isFullScreen: Boolean, viewEvent: PreviewViewEvent, - zoomEffect: Flow, topBarHeight: Dp, isFocused: Boolean, - modifier: Modifier = Modifier, + userScrollEnabled: MutableState, ) { val contentState by rememberFlowWithLifecycle(item.contentState).collectAsState( ContentState.Downloading(null) @@ -215,10 +205,9 @@ fun PreviewContent( item.category.toComposable(), isFullScreen, viewEvent, - zoomEffect, topBarHeight, isFocused, - modifier, + userScrollEnabled ) ContentState.NotFound -> PreviewNotFound() is ContentState.Error -> { @@ -275,21 +264,55 @@ fun PreviewContentAvailable( previewComposable: PreviewComposable, isFullScreen: Boolean, viewEvent: PreviewViewEvent, - zoomEffect: Flow, topBarHeight: Dp, isFocused: Boolean, + userScrollEnabled: MutableState, + transformationState : TransformationState = rememberTransformationState(), + onDoubleTap : () -> Unit = { transformationState.scale = 2F }, modifier: Modifier = Modifier, ) { + val dragEnable = transformationState.hasScale() + + if (isFocused) { + DisposableEffect(dragEnable) { + userScrollEnabled.value = !dragEnable + onDispose { + userScrollEnabled.value = true + } + } + } + + val pointerInputModifier = modifier + .pointerInput(Unit) { + detectTapGestures( + onTap = { viewEvent.onSingleTap() }, + onDoubleTap = { + if (transformationState.scale == 1F) { + onDoubleTap() + } else { + transformationState.scale = 1F + transformationState.offset = Offset.Zero + } + } + ) + } + .pointerInput(Unit, dragEnable) { + if (!dragEnable) return@pointerInput + detectDragGestures { _, dragAmount -> + transformationState.addOffset(dragAmount) + } + } + when (previewComposable) { PreviewComposable.Image -> ImagePreview( - modifier = modifier, + modifier = pointerInputModifier, uri = contentState.uri, - zoomEffect = zoomEffect, + transformationState = transformationState, isFullScreen = isFullScreen, ) PreviewComposable.Sound, PreviewComposable.Video -> MediaPreview( - modifier = modifier, + modifier = pointerInputModifier, uri = contentState.uri, isFullScreen = isFullScreen, play = isFocused, @@ -297,13 +320,13 @@ fun PreviewContentAvailable( ) PreviewComposable.Pdf -> PdfPreview( uri = contentState.uri, - modifier = modifier.padding(top = topBarHeight), - zoomEffect = zoomEffect, + modifier = pointerInputModifier.padding(top = topBarHeight), + transformationState = transformationState, onRenderFailed = viewEvent.onRenderFailed, ) PreviewComposable.Text -> TextPreview( uri = contentState.uri, - modifier = modifier.padding(top = topBarHeight), + modifier = pointerInputModifier.padding(top = topBarHeight), onRenderFailed = viewEvent.onRenderFailed, ) PreviewComposable.Unknown -> UnknownPreview() @@ -448,11 +471,9 @@ fun PreviewPreviewLoadingState() { override val onTopAppBarNavigation: () -> Unit = {} override val onMoreOptions: () -> Unit = {} override val onSingleTap: () -> Unit = {} - override val onDoubleTap: () -> Unit = {} override val onRenderFailed: (Throwable) -> Unit = {} override val mediaControllerVisibility: (Boolean) -> Unit = {} }, - zoomEffect = emptyFlow(), ) } } diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/TransformationState.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/TransformationState.kt new file mode 100644 index 00000000..8320e446 --- /dev/null +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/TransformationState.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.files.preview.presentation.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.unit.IntSize + +@Composable +fun rememberTransformationState( + initialScale: Float = 1F, + initialOffset: Offset = Offset.Zero, + initialMinScale: Float = 1F, + initialMaxScale: Float = 4F, +): TransformationState { + return rememberSaveable(saver = TransformationState.Saver) { + TransformationState( + initialScale = initialScale, + initialOffset = initialOffset, + initialMinScale = initialMinScale, + initialMaxScale = initialMaxScale, + ) + } +} + +class TransformationState( + private val initialScale: Float = 1F, + private val initialOffset: Offset = Offset.Zero, + val initialMinScale: Float = 1F, + val initialMaxScale: Float = 4F, +) { + + var containerLayout: LayoutCoordinates? = null + var contentLayout: LayoutCoordinates? = null + set(value) { + // Do not take LayoutCoordinates with a size of zero it would stop updating the offset + if(value?.size != IntSize.Zero) { + field = value + } + } + + private var _scale by mutableStateOf(initialScale) + var scale + get() = _scale + set(value) { + _scale = value.coerceIn(minScale, maxScale) + } + var offset by mutableStateOf(initialOffset) + var minScale by mutableStateOf(initialMinScale) + var maxScale by mutableStateOf(initialMaxScale) + + fun hasScale() = scale > 1F + + fun addOffset(dragAmount: Offset) { + val containerSize = containerLayout?.size + val contentSize = contentLayout?.size + if (containerSize == null || contentSize == null) { + // let the offset go outside bounds sizes are not set + offset += dragAmount + } else { + val horizontalLimit = ((contentSize.width * scale - containerSize.width) / 2F) + .coerceAtLeast(0F) + val verticalLimit = ((contentSize.height * scale - containerSize.height) / 2F) + .coerceAtLeast(0F) + + val offsetX = (offset.x + dragAmount.x) + .coerceIn(minimumValue = -horizontalLimit, maximumValue = horizontalLimit) + val offsetY = (offset.y + dragAmount.y) + .coerceIn(minimumValue = -verticalLimit, maximumValue = verticalLimit) + offset = Offset(x = offsetX, y = offsetY) + } + } + + companion object { + val Saver: Saver = listSaver( + save = { + listOf( + it.scale, + it.offset.x, + it.offset.y, + it.minScale, + it.maxScale, + ) + }, + restore = { + TransformationState( + initialScale = it[0], + initialOffset = Offset(it[1], it[2]), + initialMinScale = it[3], + initialMaxScale = it[4], + ) + } + ) + } +} \ No newline at end of file diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/event/PreviewViewEvent.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/event/PreviewViewEvent.kt index 8c66329c..2fb6a485 100644 --- a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/event/PreviewViewEvent.kt +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/event/PreviewViewEvent.kt @@ -22,7 +22,6 @@ interface PreviewViewEvent { val onTopAppBarNavigation: () -> Unit val onMoreOptions: () -> Unit val onSingleTap: () -> Unit - val onDoubleTap: () -> Unit val onRenderFailed: (Throwable) -> Unit val mediaControllerVisibility: (Boolean) -> Unit } diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/state/PreviewViewState.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/state/PreviewViewState.kt index 544523bf..e3fd1510 100644 --- a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/state/PreviewViewState.kt +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/state/PreviewViewState.kt @@ -40,10 +40,6 @@ data class PreviewViewState( ) } -sealed class ZoomEffect { - object Reset : ZoomEffect() -} - sealed interface PreviewContentState { object Loading : PreviewContentState object Empty : PreviewContentState diff --git a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/Settings.kt b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/Settings.kt index baad9947..be6a0dc6 100644 --- a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/Settings.kt +++ b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/Settings.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -46,10 +47,12 @@ import me.proton.core.drive.settings.presentation.component.DebugSettings import me.proton.core.drive.settings.presentation.component.ExternalSettingsEntry import me.proton.core.drive.settings.presentation.component.ThemeChooserDialog import me.proton.core.drive.settings.presentation.event.SettingsViewEvent +import me.proton.core.drive.settings.presentation.extension.toString import me.proton.core.drive.settings.presentation.state.LegalLink import me.proton.core.drive.settings.presentation.state.SettingsViewState import me.proton.core.usersettings.presentation.compose.view.CrashReportSettingToggleItem import me.proton.core.usersettings.presentation.compose.view.TelemetrySettingToggleItem +import kotlin.time.Duration.Companion.seconds import me.proton.core.drive.base.presentation.R as BasePresentation import me.proton.core.presentation.R as CorePresentation @@ -80,6 +83,24 @@ fun Settings( ) Column(Modifier.verticalScroll(rememberScrollState())) { + ProtonSettingsHeader(title = R.string.settings_section_security) + + ProtonSettingsItem( + name = stringResource(id = R.string.settings_app_lock), + hint = stringResource(id = viewState.appAccessSubtitleResId), + ) { + viewEvent.onAppAccess() + } + + if (viewState.isAutoLockDurationsVisible) { + ProtonSettingsItem( + name = stringResource(id = R.string.settings_auto_lock), + hint = viewState.autoLockDuration.toString(LocalContext.current), + ) { + viewEvent.onAutoLockDurations() + } + } + ProtonSettingsHeader(title = R.string.settings_section_appearance_settings) ProtonSettingsItem( @@ -89,14 +110,7 @@ fun Settings( showThemeDialog = true } - if (viewState.isAppLockEnabled) { - ProtonSettingsHeader(title = R.string.settings_section_app_settings) - ProtonSettingsItem( - name = stringResource(id = R.string.settings_pin_biomtric_lock_entry), - hint = stringResource(R.string.settings_pin_biomtric_lock_status_off), - ) - } if (viewState.legalLinks.isNotEmpty()) { ProtonSettingsHeader(title = R.string.settings_section_about) @@ -158,11 +172,16 @@ private fun SettingsPreview() { ), availableStyles = emptyList(), currentStyle = BasePresentation.string.common_cancel_action, + appAccessSubtitleResId = BasePresentation.string.common_cancel_action, + isAutoLockDurationsVisible = true, + autoLockDuration = 0.seconds, ), viewEvent = SettingsViewEvent( navigateBack = {}, onLinkClicked = {}, onThemeStyleChanged = {}, + onAppAccess = {}, + onAutoLockDurations = {} ) ) } diff --git a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/component/SettingsEntry.kt b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/component/SettingsEntry.kt index 15b87c63..7e15f65f 100644 --- a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/component/SettingsEntry.kt +++ b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/component/SettingsEntry.kt @@ -206,7 +206,7 @@ private fun PreviewExternalSettingsEntry() { modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), link = LegalLink.External( text = BasePresentation.string.title_app, - url = R.string.settings_section_app_settings, + url = R.string.settings_section_security, )) { } } diff --git a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/event/SettingsViewEvent.kt b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/event/SettingsViewEvent.kt index a6b22dc5..3d24b1f1 100644 --- a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/event/SettingsViewEvent.kt +++ b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/event/SettingsViewEvent.kt @@ -24,4 +24,6 @@ data class SettingsViewEvent( val navigateBack: () -> Unit, val onLinkClicked: (LegalLink) -> Unit, val onThemeStyleChanged: (Int) -> Unit, + val onAppAccess: () -> Unit, + val onAutoLockDurations: () -> Unit, ) diff --git a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/extension/Duration.kt b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/extension/Duration.kt new file mode 100644 index 00000000..32f233ed --- /dev/null +++ b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/extension/Duration.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.settings.presentation.extension + +import android.content.Context +import me.proton.core.drive.base.presentation.extension.quantityString +import kotlin.time.Duration +import me.proton.core.drive.settings.R as SettingsPresentation + +fun Duration.toString(context: Context): String = when { + inWholeSeconds == 0L -> context.getString(SettingsPresentation.string.settings_auto_lock_durations_immediately) + inWholeSeconds < 60 -> context.quantityString(SettingsPresentation.plurals.settings_auto_lock_durations_seconds, inWholeSeconds.toInt()) + inWholeMinutes < 60 -> context.quantityString(SettingsPresentation.plurals.settings_auto_lock_durations_minutes, inWholeMinutes.toInt()) + inWholeHours < 24 -> context.quantityString(SettingsPresentation.plurals.settings_auto_lock_durations_hours, inWholeHours.toInt()) + else -> context.quantityString(SettingsPresentation.plurals.settings_auto_lock_durations_days, inWholeDays.toInt()) +} diff --git a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/state/SettingsViewState.kt b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/state/SettingsViewState.kt index b30910ff..37c4e0c5 100644 --- a/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/state/SettingsViewState.kt +++ b/drive/settings/src/main/java/me/proton/core/drive/settings/presentation/state/SettingsViewState.kt @@ -21,6 +21,7 @@ package me.proton.core.drive.settings.presentation.state import androidx.annotation.DrawableRes import androidx.annotation.StringRes import me.proton.core.drive.settings.presentation.component.DebugSettingsStateAndEvent +import kotlin.time.Duration data class SettingsViewState( @DrawableRes val navigationIcon: Int, @@ -30,7 +31,9 @@ data class SettingsViewState( val availableStyles: List, @StringRes val currentStyle: Int, val debugSettingsStateAndEvent: DebugSettingsStateAndEvent? = null, - val isAppLockEnabled: Boolean = false, + @StringRes val appAccessSubtitleResId: Int, + val isAutoLockDurationsVisible: Boolean, + val autoLockDuration: Duration, ) sealed class LegalLink( diff --git a/drive/settings/src/main/res/values/strings.xml b/drive/settings/src/main/res/values/strings.xml index 6756fd7c..122cf3d8 100644 --- a/drive/settings/src/main/res/values/strings.xml +++ b/drive/settings/src/main/res/values/strings.xml @@ -17,10 +17,27 @@ --> - - Off - App settings + App Lock + Security About + Automatically lock the app + Immediately + + After %1$d second + After %1$d seconds + + + After %1$d minute + After %1$d minutes + + + After %1$d hour + After %1$d hours + + + After %1$d day + After %1$d days + + + diff --git a/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/di/TestModule.kt b/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/di/TestModule.kt new file mode 100644 index 00000000..14fa858c --- /dev/null +++ b/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/di/TestModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.share.data.test.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.core.drive.share.data.test.repository.StubbedShareRepository +import me.proton.core.drive.share.domain.repository.ShareRepository +import javax.inject.Singleton + +@Suppress("unused") +@Module +@InstallIn(SingletonComponent::class) +interface TestModule { + @Binds + @Singleton + fun bindsShareRepository(manager: StubbedShareRepository): ShareRepository +} diff --git a/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/nullable/NullableShare.kt b/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/nullable/NullableShare.kt new file mode 100644 index 00000000..c1e664d8 --- /dev/null +++ b/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/nullable/NullableShare.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +@file:Suppress("FunctionName") +/* + * Copyright (c) 2023 Proton Technologies AG + * This file is part of Proton Technologies AG and ProtonCore. + * + * ProtonCore is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonCore is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonCore. If not, see . + */ +package me.proton.core.drive.share.data.test.nullable + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.entity.TimestampS +import me.proton.core.drive.share.domain.entity.Share +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.volume.domain.entity.VolumeId +import me.proton.core.user.domain.entity.AddressId + +fun NullableShare( + id: ShareId = ShareId(UserId("user-id"), "share-id"), + volumeId: VolumeId = VolumeId("volume-id"), + rootLinkId: String = "", + addressId: AddressId? = null, + isMain: Boolean = false, + isLocked: Boolean = false, + key: String = "", + passphrase: String = "", + passphraseSignature: String = "", + creationTime: TimestampS? = null +) = Share( + id = id, + volumeId = volumeId, + rootLinkId = rootLinkId, + addressId = addressId, + isMain = isMain, + isLocked = isLocked, + key = key, + passphrase = passphrase, + passphraseSignature = passphraseSignature, + creationTime = creationTime +) \ No newline at end of file diff --git a/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/repository/StubbedShareRepository.kt b/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/repository/StubbedShareRepository.kt new file mode 100644 index 00000000..94bf52e0 --- /dev/null +++ b/drive/share/data-test/src/main/kotlin/me/proton/core/drive/share/data/test/repository/StubbedShareRepository.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.share.data.test.repository +/* + * Copyright (c) 2023 Proton Technologies AG + * This file is part of Proton Technologies AG and ProtonCore. + * + * ProtonCore is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonCore is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonCore. If not, see . + */ +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.asSuccess +import me.proton.core.drive.share.data.test.nullable.NullableShare +import me.proton.core.drive.share.domain.entity.Share +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.share.domain.entity.ShareInfo +import me.proton.core.drive.share.domain.repository.ShareRepository +import me.proton.core.drive.volume.domain.entity.VolumeId +import javax.inject.Inject + +class StubbedShareRepository @Inject constructor() : ShareRepository { + + private val sharesFlow = MutableStateFlow( + listOf( + NullableShare( + mainShareId, + isMain = true, + key = "key", + passphrase = "passphrase", + passphraseSignature = "passphraseSignature", + ) + ) + ) + + override fun getSharesFlow(userId: UserId): Flow>> = + filterShares { share -> share.id.userId == userId }.map { it.asSuccess } + + override fun getSharesFlow(userId: UserId, volumeId: VolumeId): Flow>> = + filterShares { share -> share.id.userId == userId && share.volumeId == volumeId }.map { it.asSuccess } + + override suspend fun hasShares(userId: UserId): Boolean { + return fetchShares(userId).isNotEmpty() + } + + override suspend fun hasShares(userId: UserId, volumeId: VolumeId): Boolean { + return filterShares { share -> share.id.userId == userId }.first().isNotEmpty() + } + + override suspend fun fetchShares(userId: UserId): List { + return filterShares { share -> share.id.userId == userId }.first() + } + + override fun getShareFlow(shareId: ShareId): Flow> { + return filterShares { share -> share.id == shareId }.map { it.first().asSuccess } + } + + override suspend fun hasShare(shareId: ShareId): Boolean { + return filterShares { share -> share.id == shareId }.firstOrNull() != null + } + + override suspend fun hasShareWithKey(shareId: ShareId): Boolean { + return filterShares { share -> share.id == shareId && share.key.isNotEmpty() }.firstOrNull() != null + } + + override suspend fun fetchShare(shareId: ShareId) { + sharesFlow.value = sharesFlow.value + NullableShare(shareId) + } + + override suspend fun deleteShare(shareId: ShareId, locallyOnly: Boolean) { + deleteShares(listOf(shareId)) + } + + override suspend fun deleteShares(shareIds: List) { + sharesFlow.value = sharesFlow.value.filterNot { share -> share.id in shareIds } + } + + override suspend fun createShare( + userId: UserId, + volumeId: VolumeId, + shareInfo: ShareInfo + ): Result { + val id = ShareId(userId, "share-${shareInfo.name}") + sharesFlow.value = sharesFlow.value + NullableShare( + id = id, + volumeId = volumeId, + addressId = shareInfo.addressId, + key = shareInfo.shareKey, + passphrase = shareInfo.sharePassphrase, + passphraseSignature = shareInfo.sharePassphraseSignature, + rootLinkId = shareInfo.rootLinkId, + ) + return Result.success(id) + } + + private fun filterShares( + filter: (Share) -> Boolean + ) = sharesFlow.filter { shares -> shares.any(filter) } + + companion object { + val mainShareId = ShareId(UserId("user-id"), "share-main") + } +} \ No newline at end of file diff --git a/drive/share/data-test/src/test/kotlin/me/proton/core/drive/share/data/test/repository/StubbedShareRepositoryTest.kt b/drive/share/data-test/src/test/kotlin/me/proton/core/drive/share/data/test/repository/StubbedShareRepositoryTest.kt new file mode 100644 index 00000000..6c1959ce --- /dev/null +++ b/drive/share/data-test/src/test/kotlin/me/proton/core/drive/share/data/test/repository/StubbedShareRepositoryTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.share.data.test.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.onSuccess +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.share.data.test.nullable.NullableShare +import me.proton.core.drive.share.data.test.repository.StubbedShareRepository.Companion.mainShareId +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.share.domain.entity.ShareInfo +import me.proton.core.drive.volume.domain.entity.VolumeId +import me.proton.core.user.domain.entity.AddressId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class StubbedShareRepositoryTest { + + private val repository = StubbedShareRepository() + + private val userId = UserId("user-id") + private val volumeId = VolumeId("volume-id") + + @Test + fun getSharesFlow() = runTest { + assertNotNull(repository.getSharesFlow(userId).first()) + } + + + @Test + fun `getSharesFlow volumeId`() = runTest { + assertNotNull(repository.getSharesFlow(userId, volumeId).first()) + } + + @Test + fun `hasShares userId`() = runTest { + assertTrue(repository.hasShares(userId)) + } + + @Test + fun `hasShares userId volumeId`() = runTest { + assertTrue(repository.hasShares(userId, volumeId)) + } + + @Test + fun `hasShare shareId`() = runTest { + assertTrue(repository.hasShare(mainShareId)) + } + + @Test + fun `hasShareWithKey shareId`() = runTest { + assertTrue(repository.hasShareWithKey(mainShareId)) + } + + @Test + fun getShareFlow() = runTest { + assertTrue(repository.getShareFlow(mainShareId).first() is DataResult.Success) + } + + @Test + fun fetchShare() = runTest { + val sharesFlow = repository.getSharesFlow(userId) + val shareId2 = ShareId(userId, "share-2") + repository.fetchShare(shareId2) + sharesFlow.first().onSuccess { shares -> + assertEquals(listOf(mainShareId, shareId2), shares.map { it.id }) + } + } + + @Test + fun createShare() = runTest { + val sharesFlow = repository.getSharesFlow(userId) + val shareIdResult = repository.createShare( + userId, volumeId, ShareInfo( + addressId = AddressId("address-id"), + name = "create", + rootLinkId = "rootLinkId", + shareKey = "shareKey", + sharePassphrase = "sharePassphrase", + sharePassphraseSignature = "sharePassphraseSignature", + passphraseKeyPacket = "", + nameKeyPacket = "" + ) + ) + sharesFlow.first().onSuccess { shares -> + assertEquals(NullableShare( + ShareId(userId, "share-create"), + volumeId, + addressId = AddressId("address-id"), + rootLinkId = "rootLinkId", + key = "shareKey", + passphrase = "sharePassphrase", + passphraseSignature = "sharePassphraseSignature", + ), shares.first { it.id == shareIdResult.getOrThrow() }) + } + } + + @Test + fun deleteShare() = runTest { + val sharesFlow = repository.getSharesFlow(userId) + val shareId2 = ShareId(userId, "share-2") + repository.fetchShare(shareId2) + repository.deleteShare(shareId2, false) + sharesFlow.first().onSuccess { shares -> + assertEquals(listOf(mainShareId), shares.map { it.id }) + } + } +} \ No newline at end of file diff --git a/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/di/ThumbnailModule.kt b/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/di/ThumbnailModule.kt index 1a58c8d0..47ce6941 100644 --- a/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/di/ThumbnailModule.kt +++ b/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/di/ThumbnailModule.kt @@ -23,15 +23,21 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet +import me.proton.core.drive.thumbnail.data.provider.AudioThumbnailProvider import me.proton.core.drive.thumbnail.data.provider.ImageThumbnailProvider import me.proton.core.drive.thumbnail.data.provider.PdfThumbnailProvider import me.proton.core.drive.thumbnail.data.provider.SvgThumbnailProvider +import me.proton.core.drive.thumbnail.data.provider.VideoThumbnailProvider import me.proton.core.drive.thumbnail.domain.usecase.CreateThumbnail @Module @InstallIn(SingletonComponent::class) interface ThumbnailModule { + @Binds + @IntoSet + fun bindsAudioThumbnailProviderIntoList(provider: AudioThumbnailProvider): CreateThumbnail.Provider + @Binds @IntoSet fun bindsImageThumbnailProviderIntoList(provider: ImageThumbnailProvider): CreateThumbnail.Provider @@ -43,4 +49,8 @@ interface ThumbnailModule { @Binds @IntoSet fun bindsSvgThumbnailProviderIntoList(provider: SvgThumbnailProvider): CreateThumbnail.Provider + + @Binds + @IntoSet + fun bindsVideoThumbnailProviderIntoList(provider: VideoThumbnailProvider): CreateThumbnail.Provider } diff --git a/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/AudioThumbnailProvider.kt b/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/AudioThumbnailProvider.kt new file mode 100644 index 00000000..2d543bdf --- /dev/null +++ b/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/AudioThumbnailProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.thumbnail.data.provider + +import android.content.Context +import android.media.ThumbnailUtils +import android.os.Build +import android.util.Size +import dagger.hilt.android.qualifiers.ApplicationContext +import me.proton.core.drive.base.presentation.entity.FileTypeCategory +import java.io.File +import javax.inject.Inject + +@Suppress("BlockingMethodInNonBlockingContext") +class AudioThumbnailProvider @Inject constructor( + @ApplicationContext private val context: Context, +) : FileThumbnailProvider( + context = context, + category = FileTypeCategory.Audio, + prefix = "audio_thumbnail_", +) { + + override fun fileToBitmap(file: File, size: Size) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ThumbnailUtils.createAudioThumbnail( + file, + size, + null + ) + } else { + null + } +} diff --git a/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/FileThumbnailProvider.kt b/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/FileThumbnailProvider.kt new file mode 100644 index 00000000..9f22e4f0 --- /dev/null +++ b/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/FileThumbnailProvider.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.thumbnail.data.provider + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Size +import me.proton.core.drive.base.domain.entity.Bytes +import me.proton.core.drive.base.presentation.entity.FileTypeCategory +import me.proton.core.drive.base.presentation.entity.toFileTypeCategory +import me.proton.core.drive.base.presentation.extension.compress +import me.proton.core.drive.thumbnail.domain.usecase.CreateThumbnail +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +@Suppress("BlockingMethodInNonBlockingContext") +abstract class FileThumbnailProvider( + private val context: Context, + private val category: FileTypeCategory, + private val prefix: String, +) : CreateThumbnail.Provider { + + override suspend fun getThumbnail( + uriString: String, + mimeType: String, + maxWidth: Int, + maxHeight: Int, + maxSize: Bytes, + ): ByteArray? { + if (mimeType.toFileTypeCategory() != category) { + return null + } + var tmpFile: File? = null + return try { + tmpFile = File.createTempFile(prefix, "", context.cacheDir) + val uri = Uri.parse(uriString) + context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + FileInputStream(pfd.fileDescriptor).use { input -> + FileOutputStream(tmpFile).use { output -> + input.copyTo(output) + } + } + } + val bitmap = fileToBitmap(tmpFile, Size(maxWidth, maxHeight)) + bitmap?.compress(maxSize)?.also { + bitmap.recycle() + } + } catch (e: OutOfMemoryError) { + System.gc() + null + } catch (e: IllegalArgumentException) { + null + } finally { + tmpFile?.delete() + } + } + + abstract fun fileToBitmap(file: File, size: Size): Bitmap? +} diff --git a/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/VideoThumbnailProvider.kt b/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/VideoThumbnailProvider.kt new file mode 100644 index 00000000..2ff0cc7d --- /dev/null +++ b/drive/thumbnail/data/src/main/kotlin/me/proton/core/drive/thumbnail/data/provider/VideoThumbnailProvider.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.thumbnail.data.provider + +import android.content.Context +import android.media.ThumbnailUtils +import android.os.Build +import android.provider.MediaStore +import android.util.Size +import dagger.hilt.android.qualifiers.ApplicationContext +import me.proton.core.drive.base.presentation.entity.FileTypeCategory +import java.io.File +import javax.inject.Inject + +@Suppress("BlockingMethodInNonBlockingContext") +class VideoThumbnailProvider @Inject constructor( + @ApplicationContext private val context: Context, +) : FileThumbnailProvider( + context = context, + category = FileTypeCategory.Video, + prefix = "video_thumbnail_", +) { + + override fun fileToBitmap(file: File, size: Size) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ThumbnailUtils.createVideoThumbnail( + file, + size, + null + ) + } else { + @Suppress("DEPRECATION") + ThumbnailUtils.createVideoThumbnail( + file.absolutePath, + MediaStore.Video.Thumbnails.MINI_KIND + ) + } +} diff --git a/drive/trash/data-test/build.gradle.kts b/drive/trash/data-test/build.gradle.kts new file mode 100644 index 00000000..0a262388 --- /dev/null +++ b/drive/trash/data-test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +driveModule( + hilt = true, +) { + api(project(":drive:trash:domain")) + api(project(":drive:base:data-test")) +} diff --git a/drive/trash/data-test/src/main/AndroidManifest.xml b/drive/trash/data-test/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0feeff58 --- /dev/null +++ b/drive/trash/data-test/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/di/TestDriveTrashModule.kt b/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/di/TestDriveTrashModule.kt new file mode 100644 index 00000000..6ab5cdbb --- /dev/null +++ b/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/di/TestDriveTrashModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.data.test.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import me.proton.core.drive.trash.data.test.manager.StubbedTrashManager +import me.proton.core.drive.trash.domain.TrashManager + +@Suppress("unused") +@ExperimentalCoroutinesApi +@Module +@InstallIn(SingletonComponent::class) +interface TestDriveTrashModule { + @Binds + fun bindTrashManager(manager: StubbedTrashManager): TrashManager + +} diff --git a/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/manager/StubbedTrashManager.kt b/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/manager/StubbedTrashManager.kt new file mode 100644 index 00000000..ec224580 --- /dev/null +++ b/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/manager/StubbedTrashManager.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.data.test.manager + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.data.test.manager.StubbedWorkManager +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.entity.LinkId +import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.trash.domain.TrashManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@OptIn(ExperimentalCoroutinesApi::class) +class StubbedTrashManager @Inject constructor( + private val repository: LinkTrashRepository, + private val manager: StubbedWorkManager, +) : TrashManager { + + override suspend fun trash( + userId: UserId, + folderId: FolderId, + linkIds: List + ): DataResult = manager.add("trash", userId, folderId, linkIds) + + override suspend fun restore( + userId: UserId, + shareId: ShareId, + linkIds: List + ): DataResult = manager.add("restore", userId, shareId, linkIds) + + override suspend fun delete( + userId: UserId, + shareId: ShareId, + linkIds: List + ): DataResult = manager.add("delete", userId, shareId, linkIds) + + override fun emptyTrash(userId: UserId, shareId: ShareId) { + manager.add("emptyTrash", userId, shareId) + } + + override fun getEmptyTrashState( + userId: UserId, + shareId: ShareId + ): Flow { + return manager.works.flatMapLatest { works -> + if (works.isNotEmpty()) { + flowOf(TrashManager.EmptyTrashState.TRASHING) + } else { + repository.hasTrashContent(shareId).map { hasTrashContent -> + if (hasTrashContent) { + TrashManager.EmptyTrashState.INACTIVE + } else { + TrashManager.EmptyTrashState.NO_FILES_TO_TRASH + } + } + } + } + + } +} \ No newline at end of file diff --git a/drive/trash/domain/build.gradle.kts b/drive/trash/domain/build.gradle.kts index 53de4047..d129630f 100644 --- a/drive/trash/domain/build.gradle.kts +++ b/drive/trash/domain/build.gradle.kts @@ -26,8 +26,17 @@ driveModule( ) { api(project(":drive:link-trash:domain")) api(libs.androidx.paging.common) - implementation(project(":drive:base:presentation")) implementation(project(":drive:crypto:domain")) implementation(project(":drive:message-queue:domain")) implementation(project(":drive:share:domain")) + + testImplementation(libs.dagger.hilt.android.testing) + add("kaptTest", libs.dagger.hilt.compiler) + + testImplementation(project(":drive:trash:data-test")) + testImplementation(project(":drive:link-trash:data-test")) + testImplementation(project(":drive:link:data-test")) + testImplementation(project(":drive:share:data-test")) } + +configureJacoco() \ No newline at end of file diff --git a/drive/trash/domain/src/main/res/values/strings.xml b/drive/trash/domain/src/main/res/values/strings.xml index 11f41583..c80e8bdf 100644 --- a/drive/trash/domain/src/main/res/values/strings.xml +++ b/drive/trash/domain/src/main/res/values/strings.xml @@ -17,6 +17,6 @@ --> - @string/common_undo_action - @string/common_retry_action + + \ No newline at end of file diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/notification/TrashExtraActionProviderTest.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/notification/TrashExtraActionProviderTest.kt new file mode 100644 index 00000000..4e3ce03d --- /dev/null +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/notification/TrashExtraActionProviderTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.domain.notification + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.linktrash.data.test.repository.state +import me.proton.core.drive.linktrash.domain.entity.TrashState +import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository +import me.proton.core.drive.share.domain.entity.ShareId +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class TrashExtraActionProviderTest { + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var repository: LinkTrashRepository + + @Inject + lateinit var actionProvider: TrashExtraActionProvider + + private val userId = UserId("user-id") + private val shareId = ShareId(userId, "share-id") + private val folderId = FolderId(shareId, "folder-id") + private val fileId = FileId(shareId, "file-id") + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun `no exception during delete`() = runTest { + assertNull( + actionProvider.provideAction( + DeleteFilesExtra( + userId = userId, + shareId = shareId, + links = listOf(fileId), + ) + ) + ) + } + + @Test + fun `exception during delete`() = runTest { + actionProvider.provideAction( + DeleteFilesExtra( + userId = userId, + shareId = shareId, + links = listOf(fileId), + exception = RuntimeException(), + ) + )?.invoke() + + assertEquals(TrashState.DELETING, repository.state[listOf(fileId)]) + } + + @Test + fun `no exception during empty trash`() = runTest { + assertNull( + actionProvider.provideAction( + EmptyTrashExtra( + userId = userId, + shareId = shareId, + ) + ) + ) + } + + @Test + fun `exception during empty trash`() = runTest { + assertNull( + actionProvider.provideAction( + EmptyTrashExtra( + userId = userId, + shareId = shareId, + exception = RuntimeException(), + + ) + ) + ) + } + + @Test + fun `no exception during restore`() = runTest { + assertNull( + actionProvider.provideAction( + RestoreFilesExtra( + userId = userId, + shareId = shareId, + links = listOf(fileId), + ) + ) + ) + } + + @Test + fun `exception during restore`() = runTest { + actionProvider.provideAction( + RestoreFilesExtra( + userId = userId, + shareId = shareId, + links = listOf(fileId), + exception = RuntimeException(), + ) + )?.invoke() + + assertEquals(TrashState.RESTORING, repository.state[listOf(fileId)]) + } + + @Test + fun `no exception during trash`() = runTest { + actionProvider.provideAction( + TrashFilesExtra( + userId = userId, + folderId = folderId, + links = listOf(fileId), + ) + )?.invoke() + + assertEquals(TrashState.RESTORING, repository.state[listOf(fileId)]) + } + + @Test + fun `exception during trash`() = runTest { + actionProvider.provideAction( + TrashFilesExtra( + userId = userId, + folderId = folderId, + links = listOf(fileId), + exception = RuntimeException(), + ) + )?.invoke() + + assertEquals(TrashState.TRASHING, repository.state[listOf(fileId)]) + } +} \ No newline at end of file diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/DefaultValues.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/DefaultValues.kt new file mode 100644 index 00000000..0156452f --- /dev/null +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/DefaultValues.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.trash.domain.usecase + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.share.domain.entity.ShareId + + +internal val userId = UserId("user-id") +internal val shareId = ShareId(userId, "share-id") +internal val folderId = FolderId(shareId, "folder-id") +internal val fileId = FileId(shareId, "file-id") \ No newline at end of file diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/DeleteFromTrashTest.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/DeleteFromTrashTest.kt new file mode 100644 index 00000000..49b98979 --- /dev/null +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/DeleteFromTrashTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.base.data.test.manager.StubbedWorkManager +import me.proton.core.drive.linktrash.data.test.repository.state +import me.proton.core.drive.linktrash.domain.entity.TrashState +import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class DeleteFromTrashTest { + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var repository: LinkTrashRepository + + @Inject + lateinit var manager: StubbedWorkManager + + @Inject + lateinit var deleteFromTrash: DeleteFromTrash + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun success() = runTest { + deleteFromTrash(userId, fileId) + + assertEquals(TrashState.DELETING, repository.state[listOf(fileId)]) + } + + @Test + fun failing() = runTest { + manager.behavior = StubbedWorkManager.BEHAVIOR_ERROR + + deleteFromTrash(userId, fileId) + + assertEquals(TrashState.TRASHED, repository.state[listOf(fileId)]) + } +} \ No newline at end of file diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/EmptyTrashTest.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/EmptyTrashTest.kt new file mode 100644 index 00000000..38a5eb5c --- /dev/null +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/EmptyTrashTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.base.data.test.manager.StubbedWorkManager +import me.proton.core.drive.base.data.test.manager.assertHasWork +import me.proton.core.drive.share.data.test.repository.StubbedShareRepository.Companion.mainShareId +import me.proton.core.drive.share.domain.repository.ShareRepository +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class EmptyTrashTest { + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var shareRepository: ShareRepository + + @Inject + lateinit var manager: StubbedWorkManager + + @Inject + lateinit var emptyTrash: EmptyTrash + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun `with shareId`() = runTest { + emptyTrash(userId, shareId) + + manager.assertHasWork("emptyTrash", userId, shareId) + } + + @Test + @Suppress("DEPRECATION") + fun `without shareId`() = runTest { + emptyTrash(userId) + + manager.assertHasWork("emptyTrash", userId, mainShareId) + } +} \ No newline at end of file diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/GetEmptyTrashTest.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/GetEmptyTrashTest.kt new file mode 100644 index 00000000..4a1891fa --- /dev/null +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/GetEmptyTrashTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.base.data.test.manager.StubbedWorkManager +import me.proton.core.drive.link.data.test.NullableFile +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.share.data.test.repository.StubbedShareRepository.Companion.mainShareId +import me.proton.core.drive.trash.domain.TrashManager +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class GetEmptyTrashTest { + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var manager: StubbedWorkManager + + @Inject + lateinit var getEmptyTrashState: GetEmptyTrashState + + @Inject + lateinit var sendToTrash: SendToTrash + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun `with share id`() = runTest { + val state = getEmptyTrashState(userId, shareId) + + assertEquals(TrashManager.EmptyTrashState.NO_FILES_TO_TRASH, state.first()) + + sendToTrash(userId, NullableFile(folderId)) + + assertEquals(TrashManager.EmptyTrashState.TRASHING, state.first()) + + manager.execute() + + assertEquals(TrashManager.EmptyTrashState.INACTIVE, state.first()) + + } + + @Test + fun `without share id`() = runTest { + val state = getEmptyTrashState(userId) + + assertEquals(TrashManager.EmptyTrashState.NO_FILES_TO_TRASH, state.first()) + + sendToTrash( + userId = userId, + link = NullableFile( + id = FileId(shareId = mainShareId, id = "file-id"), + parentId = folderId + ) + ) + + assertEquals(TrashManager.EmptyTrashState.TRASHING, state.first()) + + manager.execute() + + assertEquals(TrashManager.EmptyTrashState.INACTIVE, state.first()) + } +} \ No newline at end of file diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/RestoreFromTrashTest.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/RestoreFromTrashTest.kt new file mode 100644 index 00000000..8d6bc964 --- /dev/null +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/RestoreFromTrashTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.base.data.test.manager.StubbedWorkManager +import me.proton.core.drive.linktrash.data.test.repository.state +import me.proton.core.drive.linktrash.domain.entity.TrashState +import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class RestoreFromTrashTest { + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var repository: LinkTrashRepository + + @Inject + lateinit var manager: StubbedWorkManager + + @Inject + lateinit var restoreFromTrash: RestoreFromTrash + + @Before + fun setUp() { + hiltRule.inject() + } + + + @Test + fun success() = runTest { + restoreFromTrash(userId, fileId) + + assertEquals(TrashState.RESTORING, repository.state[listOf(fileId)]) + } + + @Test + fun failing() = runTest { + manager.behavior = StubbedWorkManager.BEHAVIOR_ERROR + + restoreFromTrash(userId, fileId) + + assertEquals(TrashState.TRASHED, repository.state[listOf(fileId)]) + } +} \ No newline at end of file diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/SendToTrashTest.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/SendToTrashTest.kt new file mode 100644 index 00000000..e1d8e2c1 --- /dev/null +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/SendToTrashTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.base.data.test.manager.StubbedWorkManager +import me.proton.core.drive.link.data.test.NullableFile +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.entity.LinkId +import me.proton.core.drive.linktrash.data.test.repository.state +import me.proton.core.drive.linktrash.data.test.repository.stateForLinks +import me.proton.core.drive.linktrash.domain.entity.TrashState +import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository +import me.proton.core.drive.share.domain.entity.ShareId +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class SendToTrashTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var repository: LinkTrashRepository + + @Inject + lateinit var manager: StubbedWorkManager + + @Inject + lateinit var sendToTrash: SendToTrash + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun `same folder`() = runTest { + val link1 = NullableFile(folderId, "file-1") + val link2 = NullableFile(folderId, "file-2") + + sendToTrash(userId, listOf(link1, link2)) + + assertEquals(TrashState.TRASHING, repository.stateForLinks(link1, link2)) + } + + @Test + fun `two folders`() = runTest { + + val share1 = ShareId(userId, "share-1") + val share2 = ShareId(userId, "share-2") + val folder1 = FolderId(share1, "folder-1") + val folder2 = FolderId(share2, "folder-2") + val link1 = NullableFile(folder1, "file-1") + val link2 = NullableFile(folder2, "file-2") + + sendToTrash(userId, listOf(link1, link2)) + + assertEquals(TrashState.TRASHING, repository.stateForLinks(link1)) + assertEquals(TrashState.TRASHING, repository.stateForLinks(link2)) + } + + @Test + fun failing() = runTest { + manager.behavior = StubbedWorkManager.BEHAVIOR_ERROR + + sendToTrash(userId, NullableFile(folderId)) + + assertEquals(emptyMap(), repository.state) + } +} \ No newline at end of file diff --git a/drive/trash/domain/src/test/resources/robolectric.properties b/drive/trash/domain/src/test/resources/robolectric.properties new file mode 100644 index 00000000..2484ede0 --- /dev/null +++ b/drive/trash/domain/src/test/resources/robolectric.properties @@ -0,0 +1,19 @@ +# +# Copyright (c) 2023 Proton AG. +# This file is part of Proton Core. +# +# Proton Core is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Proton Core is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Proton Core. If not, see . +# + +application = dagger.hilt.android.testing.HiltTestApplication \ No newline at end of file diff --git a/drive/trash/presentation/build.gradle.kts b/drive/trash/presentation/build.gradle.kts new file mode 100644 index 00000000..bdc422d4 --- /dev/null +++ b/drive/trash/presentation/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021-2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +driveModule { + implementation(project(":drive:trash:domain")) + implementation(project(":drive:base:presentation")) +} diff --git a/drive/trash/presentation/src/main/AndroidManifest.xml b/drive/trash/presentation/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e9c81fc9 --- /dev/null +++ b/drive/trash/presentation/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/drive/trash/presentation/src/main/res/values/strings.xml b/drive/trash/presentation/src/main/res/values/strings.xml new file mode 100644 index 00000000..11f41583 --- /dev/null +++ b/drive/trash/presentation/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + @string/common_undo_action + @string/common_retry_action + \ No newline at end of file diff --git a/drive/upload/data/src/main/kotlin/me/proton/core/drive/upload/data/worker/GetBlocksUploadUrlWorker.kt b/drive/upload/data/src/main/kotlin/me/proton/core/drive/upload/data/worker/GetBlocksUploadUrlWorker.kt index df1cc1a6..9757d8b1 100644 --- a/drive/upload/data/src/main/kotlin/me/proton/core/drive/upload/data/worker/GetBlocksUploadUrlWorker.kt +++ b/drive/upload/data/src/main/kotlin/me/proton/core/drive/upload/data/worker/GetBlocksUploadUrlWorker.kt @@ -122,13 +122,18 @@ class GetBlocksUploadUrlWorker @AssistedInject constructor( else latest + continuations.subList(requests.size, continuations.size) } }.also { continuations -> - WorkContinuation.combine(continuations) - .then( + if (continuations.isNotEmpty()) { + WorkContinuation.combine(continuations) + .then( + UpdateRevisionWorker.getWorkRequest(userId, uploadFileLinkId, uploadTag) + ) + } else { + workManager.beginWith( UpdateRevisionWorker.getWorkRequest(userId, uploadFileLinkId, uploadTag) ) - .then( - UploadSuccessCleanupWorker.getWorkRequest(userId, uploadFileLinkId, uploadTag) - ) + }.then( + UploadSuccessCleanupWorker.getWorkRequest(userId, uploadFileLinkId, uploadTag) + ) .enqueue() } } diff --git a/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/extension/InputStream.kt b/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/extension/InputStream.kt index 78335263..3feb49d7 100644 --- a/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/extension/InputStream.kt +++ b/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/extension/InputStream.kt @@ -18,9 +18,14 @@ package me.proton.core.drive.upload.domain.extension import me.proton.core.drive.base.domain.entity.Bytes +import me.proton.core.drive.base.domain.log.LogTag import me.proton.core.drive.upload.domain.outputstream.MultipleFileOutputStream +import me.proton.core.util.kotlin.CoreLogger import java.io.File import java.io.InputStream +import java.security.DigestInputStream +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException fun InputStream.saveToBlocks( destinationFolder: File, @@ -29,3 +34,18 @@ fun InputStream.saveToBlocks( MultipleFileOutputStream(destinationFolder, blockMaxSize) .apply { use { outputStream -> copyTo(outputStream) } } .files + +internal fun InputStream.injectMessageDigests(algorithms: List): Pair> { + val messageDigests = algorithms.mapNotNull { algorithm -> + try { + MessageDigest.getInstance(algorithm) + } catch (e: NoSuchAlgorithmException) { + CoreLogger.i(LogTag.UPLOAD, e, "Algorithm not supported") + null + } + } + val digestsInputStream = messageDigests.fold(this) { acc, messageDigest -> + DigestInputStream(acc, messageDigest) + } + return digestsInputStream to messageDigests +} \ No newline at end of file diff --git a/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/usecase/SplitFileToBlocksAndEncrypt.kt b/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/usecase/SplitFileToBlocksAndEncrypt.kt index 24b41fe9..cd988fa9 100644 --- a/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/usecase/SplitFileToBlocksAndEncrypt.kt +++ b/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/usecase/SplitFileToBlocksAndEncrypt.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.base.domain.extension.size +import me.proton.core.drive.base.domain.extension.toHex import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.domain.usecase.GetSignatureAddress import me.proton.core.drive.base.domain.util.coRunCatching @@ -37,17 +38,19 @@ import me.proton.core.drive.key.domain.usecase.GetNodeKey import me.proton.core.drive.link.domain.entity.Link.Companion.THUMBNAIL_INDEX import me.proton.core.drive.link.domain.entity.Link.Companion.THUMBNAIL_NAME import me.proton.core.drive.linkupload.domain.entity.UploadBlock +import me.proton.core.drive.linkupload.domain.entity.UploadDigests import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import me.proton.core.drive.linkupload.domain.entity.UploadState import me.proton.core.drive.linkupload.domain.factory.UploadBlockFactory +import me.proton.core.drive.linkupload.domain.usecase.UpdateDigests import me.proton.core.drive.linkupload.domain.usecase.UpdateManifestSignature import me.proton.core.drive.linkupload.domain.usecase.UpdateUploadState import me.proton.core.drive.thumbnail.domain.usecase.CreateThumbnail import me.proton.core.drive.upload.domain.extension.blockFile +import me.proton.core.drive.upload.domain.extension.injectMessageDigests import me.proton.core.drive.upload.domain.extension.saveToBlocks import me.proton.core.drive.upload.domain.provider.FileProvider import me.proton.core.drive.upload.domain.resolver.UriResolver -import me.proton.core.util.kotlin.takeIfNotEmpty import java.io.File import javax.inject.Inject import kotlin.coroutines.CoroutineContext @@ -70,6 +73,7 @@ class SplitFileToBlocksAndEncrypt @Inject constructor( private val updateManifestSignature: UpdateManifestSignature, private val fileProvider: FileProvider, private val getSignatureAddress: GetSignatureAddress, + private val updateDigests: UpdateDigests, ) { suspend operator fun invoke( uploadFileLink: UploadFileLink, @@ -77,9 +81,16 @@ class SplitFileToBlocksAndEncrypt @Inject constructor( shouldDeleteSource: Boolean = false, coroutineContext: CoroutineContext = Job() + Dispatchers.IO, ): Result = coRunCatching(coroutineContext) { + val (unencryptedBlocks, digests) = uploadFileLink.splitUriToBlocks( + uriString = uriString + ) + updateDigests( + uploadFileLinkId = uploadFileLink.id, + digests = digests + ) encryptBlocks( uploadFileLink = uploadFileLink, - unencryptedBlocks = uploadFileLink.splitUriToBlocks(uriString), + unencryptedBlocks = unencryptedBlocks, uriString = uriString, coroutineContext = coroutineContext, ).getOrThrow().also { @@ -107,14 +118,14 @@ class SplitFileToBlocksAndEncrypt @Inject constructor( signKey = addressKey, coroutineContext = coroutineContext, ) + - listOfNotNull( - getThumbnailUploadBlock( - uploadFileContentKey = uploadFileContentKey, - signKey = addressKey, - uriString = uriString, - coroutineContext = coroutineContext, + listOfNotNull( + getThumbnailUploadBlock( + uploadFileContentKey = uploadFileContentKey, + signKey = addressKey, + uriString = uriString, + coroutineContext = coroutineContext, + ) ) - ) addUploadBlocks(uploadBlocks) updateManifestSignature( uploadFileLinkId = id, @@ -146,13 +157,23 @@ class SplitFileToBlocksAndEncrypt @Inject constructor( fileKey = uploadFileKey, ).getOrThrow() - private suspend fun UploadFileLink.splitUriToBlocks(uriString: String): List = + private suspend fun UploadFileLink.splitUriToBlocks( + uriString: String, + ): Pair, UploadDigests> = uriResolver.useInputStream(uriString) { inputStream -> - inputStream.saveToBlocks( + val (digestsInputStream, messageDigests) = inputStream.injectMessageDigests( + algorithms = configurationProvider.digestAlgorithms, + ) + val files = digestsInputStream.saveToBlocks( destinationFolder = getBlockFolder(userId, this).getOrThrow(), blockMaxSize = configurationProvider.blockMaxSize, - ).takeIfNotEmpty() - } ?: listOf(File(getBlockFolder(userId, this).getOrThrow(), "empty")) + ) + val uploadDigests = messageDigests + .associate { messageDigest -> + messageDigest.algorithm to messageDigest.digest().toHex() + }.let(::UploadDigests) + files to uploadDigests + } ?: (emptyList() to UploadDigests()) private suspend fun List.encryptBlocksAndDeleteSourceFiles( uploadFileContentKey: ContentKey, diff --git a/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/usecase/UpdateRevision.kt b/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/usecase/UpdateRevision.kt index 802ae42b..97d5683b 100644 --- a/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/usecase/UpdateRevision.kt +++ b/drive/upload/domain/src/main/kotlin/me/proton/core/drive/upload/domain/usecase/UpdateRevision.kt @@ -17,8 +17,8 @@ */ package me.proton.core.drive.upload.domain.usecase -import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.base.domain.usecase.GetSignatureAddress +import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.crypto.domain.usecase.link.EncryptAndSignXAttr import me.proton.core.drive.file.base.domain.usecase.CreateXAttr import me.proton.core.drive.file.base.domain.usecase.UpdateRevision @@ -73,6 +73,7 @@ class UpdateRevision @Inject constructor( .filterNot { uploadBlock -> uploadBlock.isThumbnail } .map { uploadBlock -> uploadBlock.rawSize }, mediaResolution = uploadFileLink.mediaResolution, + digests = uploadFileLink.digests.values, ), ).getOrThrow() ).getOrThrow() diff --git a/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/extension/InputStreamKtTest.kt b/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/extension/InputStreamKtTest.kt new file mode 100644 index 00000000..11281196 --- /dev/null +++ b/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/extension/InputStreamKtTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.upload.domain.extension + +import me.proton.core.drive.base.domain.extension.toHex +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.security.MessageDigest + + +@RunWith(RobolectricTestRunner::class) +class InputStreamKtTest { + + @Test + fun injectDigests_sha1() { + val (inputStream, digests) = "".byteInputStream().injectMessageDigests(listOf("SHA1")) + + inputStream.readAllBytes() + + assertEquals(sha1_empty, digests.first().digest().toHex()) + } + + @Test + fun injectDigests_unsupported() { + val (_, digests) = "".byteInputStream().injectMessageDigests(listOf("unsupported")) + + assertEquals(emptyList(), digests) + } +} + +private const val sha1_empty = "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709" diff --git a/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/storage/Storage.kt b/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/storage/Storage.kt index 73cca91b..ee55ab30 100644 --- a/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/storage/Storage.kt +++ b/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/storage/Storage.kt @@ -61,7 +61,7 @@ fun StorageIndicator( Row { Icon( painter = painterResource(CorePresentation.drawable.ic_proton_cloud), - tint = ProtonTheme.colors.iconNorm, + tint = ProtonTheme.colors.iconWeak, contentDescription = null, ) Text( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f044bfe..b75a7175 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ material = "1.6.1" androidx-activity = "1.5.0" androidx-annotation = "1.4.0" androidx-appCompat = "1.4.2" +androidx-biometric = "1.2.0-alpha05" androidx-compose = "1.3.2" androidx-compose-compiler = "1.3.2" androidx-compose-foundation = "1.3.1" @@ -29,11 +30,11 @@ androidx-work = "2.7.1" # Coil coil = "1.4.0" # Core -core = "9.9.3" +core = "9.12.0" # Dagger dagger = "2.44.2" # Gradle -android-gradle-plugin = "7.3.1" +android-gradle-plugin = "7.4.1" proton-detekt-plugin = "1.1.2" #Android tools android-tools = "1.1.5" @@ -54,6 +55,8 @@ retrofit = "2.9.0" # Test junit = "4.13.2" mockk = "1.12.2" +robolectric = "4.9.2" +fusion = "0.9.50" [libraries] # Gradle @@ -73,11 +76,15 @@ accompanist-pager = { module = "com.google.accompanist:accompanist-pager", versi accompanist-systemUiController = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } accompanist-swipeRefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanist" } # AndroidX ## Activity androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +## Biometric +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidx-biometric" } +androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "androidx-biometric" } ## Compose androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose-foundation" } androidx-compose-foundationLayout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidx-compose-foundation" } @@ -126,22 +133,37 @@ coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" } # Core core-account = { module = "me.proton.core:account", version.ref = "core" } +core-account-data = { module = "me.proton.core:account-data", version.ref = "core" } core-accountManager = { module = "me.proton.core:account-manager", version.ref = "core" } +core-accountManager-data = { module = "me.proton.core:account-manager-data", version.ref = "core" } +core-accountManager-domain = { module = "me.proton.core:account-manager-domain", version.ref = "core" } core-auth = { module = "me.proton.core:auth", version.ref = "core" } +core-auth-domain = { module = "me.proton.core:auth-domain", version.ref = "core" } core-challenge = { module = "me.proton.core:challenge", version.ref = "core" } +core-challenge-data = { module = "me.proton.core:challenge-data", version.ref = "core" } core-country = { module = "me.proton.core:country", version.ref = "core" } core-crypto = { module = "me.proton.core:crypto", version.ref = "core" } +core-crypto-android = { module = "me.proton.core:crypto-android", version.ref = "core" } core-cryptoCommon = { module = "me.proton.core:crypto-common", version.ref = "core" } core-cryptoValidator = { module = "me.proton.core:crypto-validator", version.ref = "core" } core-data = { module = "me.proton.core:data", version.ref = "core" } core-dataRoom = { module = "me.proton.core:data-room", version.ref = "core" } core-domain = { module = "me.proton.core:domain", version.ref = "core" } core-eventManager = { module = "me.proton.core:event-manager", version.ref = "core" } +core-eventManager-data = { module = "me.proton.core:event-manager-data", version.ref = "core" } core-featureFlag = { module = "me.proton.core:feature-flag", version.ref = "core" } +core-featureFlag-data = { module = "me.proton.core:feature-flag-data", version.ref = "core" } core-humanVerification = { module = "me.proton.core:human-verification", version.ref = "core" } +core-humanVerification-data = { module = "me.proton.core:human-verification-data", version.ref = "core" } core-key = { module = "me.proton.core:key", version.ref = "core" } +core-key-data = { module = "me.proton.core:key-data", version.ref = "core" } +core-key-domain = { module = "me.proton.core:key-domain", version.ref = "core" } core-network = { module = "me.proton.core:network", version.ref = "core" } +core-network-domain = { module = "me.proton.core:network-domain", version.ref = "core" } +core-observability = { module = "me.proton.core:observability", version.ref = "core" } +core-observability-data = { module = "me.proton.core:observability-data", version.ref = "core" } core-payment = { module = "me.proton.core:payment", version.ref = "core" } +core-payment-data = { module = "me.proton.core:payment-data", version.ref = "core" } # core-payment-iap = { module = "me.proton.core:payment-iap", version.ref = "core" } core-plan = { module = "me.proton.core:plan", version.ref = "core" } core-presentation = { module = "me.proton.core:presentation", version.ref = "core" } @@ -152,7 +174,10 @@ core-test-android-instrumented = { module = "me.proton.core:test-android-instrum core-test-kotlin = { module = "me.proton.core:test-kotlin", version.ref = "core" } core-test-quark = { module = "me.proton.core:test-quark", version.ref = "core" } core-user = { module = "me.proton.core:user", version.ref = "core" } +core-user-data = { module = "me.proton.core:user-data", version.ref = "core" } +core-user-domain = { module = "me.proton.core:user-domain", version.ref = "core" } core-userSettings = { module = "me.proton.core:user-settings", version.ref = "core" } +core-userSettings-data = { module = "me.proton.core:user-settings-data", version.ref = "core" } core-utilAndroidDagger = { module = "me.proton.core:util-android-dagger", version.ref = "core" } core-utilKotlin = { module = "me.proton.core:util-kotlin", version.ref = "core" } @@ -197,12 +222,14 @@ coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve junit = { module = "junit:junit", version.ref = "junit" } mockk-jvm = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric"} +fusion = { module = "me.proton.test:fusion", version.ref = "fusion"} [bundles] -accompanist = ["accompanist-insets", "accompanist-navigation-animation", "accompanist-pager", "accompanist-systemUiController", "accompanist-swipeRefresh", "accompanist-permissions"] -core = ["core-account", "core-accountManager", "core-auth", "core-challenge", "core-country", "core-crypto", "core-cryptoValidator", "core-data", "core-dataRoom", "core-domain", "core-eventManager", "core-featureFlag", "core-humanVerification", "core-key", "core-network", "core-payment", "core-plan", "core-report", "core-presentation", "core-presentation-compose", "core-user", "core-userSettings", "core-utilAndroidDagger", "core-utilKotlin"] -test-android = ["junit", "mockk-android", "coroutines-test", "androidx-test-core-ktx", "androidx-test-runner", "androidx-test-rules", "androidx-compose-ui-test", "androidx-compose-ui-test-junit", "androidx-test-uiautomator", "core-test-android-instrumented" ] -test-jvm = ["junit", "mockk-jvm", "coroutines-test", "core-test-kotlin", "core-test-quark"] +accompanist = ["accompanist-insets", "accompanist-navigation-animation", "accompanist-pager", "accompanist-systemUiController", "accompanist-swipeRefresh", "accompanist-permissions", "accompanist-drawablepainter"] +core = ["core-account", "core-accountManager", "core-auth", "core-challenge", "core-country", "core-crypto", "core-cryptoValidator", "core-data", "core-dataRoom", "core-domain", "core-eventManager", "core-featureFlag", "core-humanVerification", "core-key", "core-network", "core-observability", "core-payment", "core-plan", "core-report", "core-presentation", "core-presentation-compose", "core-user", "core-userSettings", "core-utilAndroidDagger", "core-utilKotlin"] +test-android = ["junit", "mockk-android", "coroutines-test", "androidx-test-core-ktx", "androidx-test-runner", "androidx-test-rules", "androidx-compose-ui-test", "androidx-compose-ui-test-junit", "androidx-test-uiautomator", "core-test-android-instrumented"] +test-jvm = ["junit", "mockk-jvm", "coroutines-test", "core-test-kotlin", "core-test-quark", "robolectric"] [plugins] proton-detekt = { id = "me.proton.core.gradle-plugins.detekt", version.ref = "proton-detekt-plugin" }