From 080dc333af5f608f9b8d2d77e8e22367f93a2a40 Mon Sep 17 00:00:00 2001 From: Mateusz Armatys Date: Wed, 2 Apr 2025 18:24:59 +0200 Subject: [PATCH] feat(auth, device-migration)!: Perform post-login steps after obtaining forked session. MIGRATION: AccountDatabase.MIGRATION_10 --- .../61.json | 3562 +++++++++++++++++ .../data/db/AccountManagerDatabase.kt | 3 +- .../db/AccountManagerDatabaseMigrations.kt | 6 + .../core/account/data/db/AccountDatabase.kt | 26 + .../core/account/data/db/SessionDetailsDao.kt | 4 +- .../data/entity/SessionDetailsEntity.kt | 5 +- .../data/repository/AccountRepositoryImpl.kt | 3 +- .../repository/AccountRepositoryImplTest.kt | 6 +- .../core/account/domain/entity/Account.kt | 4 +- .../auth/domain/entity/EncryptedAuthSecret.kt | 32 + .../entity/SessionForkPayloadWithKey.kt | 2 +- .../domain/usecase/CreateLoginLessSession.kt | 1 + .../auth/domain/usecase/CreateLoginSession.kt | 1 + .../usecase/CreateLoginSessionFromFork.kt | 71 + .../domain/usecase/CreateLoginSsoSession.kt | 1 + .../domain/usecase/PostLoginAccountSetup.kt | 62 +- .../domain/usecase/UnlockUserPrimaryKey.kt | 23 +- .../usecase/fork/DecryptPassphrasePayload.kt | 7 +- .../fork/GetEncryptedPassphrasePayload.kt | 18 +- .../usecase/PostLoginSsoAccountSetupTest.kt | 3 +- .../usecase/UnlockUserPrimaryKeyTest.kt | 77 +- auth/presentation/build.gradle.kts | 1 + .../auth/presentation/AuthOrchestrator.kt | 23 +- .../presentation/entity/AuthHelpResult.kt | 4 +- .../presentation/entity/ChooseAddressInput.kt | 12 +- .../auth/presentation/ui/AuthHelpActivity.kt | 40 +- .../presentation/ui/ChooseAddressActivity.kt | 13 +- .../auth/presentation/ui/LoginActivity.kt | 6 +- .../presentation/ui/LoginTwoStepActivity.kt | 6 +- .../viewmodel/ChooseAddressViewModel.kt | 9 +- .../auth/presentation/AuthOrchestratorTest.kt | 16 +- .../viewmodel/ChooseAddressViewModelTest.kt | 7 +- .../viewmodel/SecondFactorViewModelTest.kt | 5 +- .../viewmodel/TwoPassModeViewModelTest.kt | 5 +- .../61.json | 3562 +++++++++++++++++ .../core/coreexample/db/AppDatabase.kt | 3 +- .../coreexample/db/AppDatabaseMigrations.kt | 6 + crypto/android/build.gradle.kts | 1 + .../android/keystore/CryptoConverters.kt | 16 + .../dagger/CoreDeviceMigrationModule.kt | 9 +- device-migration/data/build.gradle.kts | 7 +- .../IsEasyDeviceMigrationAvailableImpl.kt | 73 + .../IsEasyDeviceMigrationAvailableImplTest.kt | 95 +- device-migration/domain/build.gradle.kts | 3 +- .../usecase/IsEasyDeviceMigrationAvailable.kt | 27 +- .../domain/usecase/PullEdmSessionFork.kt | 2 +- .../presentation/build.gradle.kts | 1 + .../presentation/ActivityResultContracts.kt | 7 +- .../TargetDeviceMigrationActivity.kt | 21 +- .../TargetDeviceMigrationRoutes.kt | 4 +- .../intro/SignInIntroViewModel.kt | 13 +- .../presentation/signin/SignInEvent.kt | 1 + .../presentation/signin/SignInOperation.kt | 2 +- .../presentation/signin/SignInScreen.kt | 60 +- .../presentation/signin/SignInState.kt | 15 +- .../presentation/signin/SignInViewModel.kt | 98 +- .../src/main/res/values/strings.xml | 3 +- .../intro/SignInIntroViewModelTest.kt | 28 - .../presentation/signin/SignInScreenTest.kt | 21 +- .../signin/SignInViewModelTest.kt | 103 +- ....signin_SignInScreenTest_idle state[0].png | 3 + ....signin_SignInScreenTest_idle state[1].png | 3 + ...ignInScreenTest_unrecoverable error[0].png | 4 +- ...ignInScreenTest_unrecoverable error[1].png | 4 +- 64 files changed, 7984 insertions(+), 275 deletions(-) create mode 100644 account-manager/data-db/schemas/me.proton.core.accountmanager.data.db.AccountManagerDatabase/61.json create mode 100644 auth/domain/src/main/kotlin/me/proton/core/auth/domain/entity/EncryptedAuthSecret.kt create mode 100644 auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSessionFromFork.kt create mode 100644 coreexample/schemas/me.proton.android.core.coreexample.db.AppDatabase/61.json create mode 100644 device-migration/data/src/main/kotlin/me/proton/core/devicemigration/data/usecase/IsEasyDeviceMigrationAvailableImpl.kt rename device-migration/{domain/src/test/kotlin/me/proton/core/devicemigration/domain => data/src/test/kotlin/me/proton/core/devicemigration/data}/usecase/IsEasyDeviceMigrationAvailableImplTest.kt (51%) create mode 100644 device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_idle state[0].png create mode 100644 device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_idle state[1].png diff --git a/account-manager/data-db/schemas/me.proton.core.accountmanager.data.db.AccountManagerDatabase/61.json b/account-manager/data-db/schemas/me.proton.core.accountmanager.data.db.AccountManagerDatabase/61.json new file mode 100644 index 000000000..abe158a49 --- /dev/null +++ b/account-manager/data-db/schemas/me.proton.core.accountmanager.data.db.AccountManagerDatabase/61.json @@ -0,0 +1,3562 @@ +{ + "formatVersion": 1, + "database": { + "version": 61, + "identityHash": "98441bcc53a06467a4ae2cf750419112", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `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": false + }, + { + "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": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "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, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `passphrase` BLOB, `password` TEXT, `fido2AuthenticationOptionsJson` 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": false + }, + { + "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": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "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, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `flags` TEXT, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, 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": "createdAtUtc", + "columnName": "createdAtUtc", + "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": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "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 + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "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, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, 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 + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "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, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, 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 + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "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, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, 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": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "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": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "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 + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "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": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `autoDeleteSpamAndTrashDays` INTEGER, `almostAllMail` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, `mobileSettings_listToolbar_isCustom` INTEGER, `mobileSettings_listToolbar_actions` TEXT, `mobileSettings_messageToolbar_isCustom` INTEGER, `mobileSettings_messageToolbar_actions` TEXT, `mobileSettings_conversationToolbar_isCustom` INTEGER, `mobileSettings_conversationToolbar_actions` TEXT, 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": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "autoDeleteSpamAndTrashDays", + "columnName": "autoDeleteSpamAndTrashDays", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "almostAllMail", + "columnName": "almostAllMail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.isCustom", + "columnName": "mobileSettings_listToolbar_isCustom", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.actions", + "columnName": "mobileSettings_listToolbar_actions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.isCustom", + "columnName": "mobileSettings_messageToolbar_isCustom", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.actions", + "columnName": "mobileSettings_messageToolbar_actions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.isCustom", + "columnName": "mobileSettings_conversationToolbar_isCustom", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.actions", + "columnName": "mobileSettings_conversationToolbar_actions", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `easyDeviceMigrationOptOut` 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_registeredKeys` TEXT, 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": "density", + "columnName": "density", + "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": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "easyDeviceMigrationOptOut", + "columnName": "easyDeviceMigrationOptOut", + "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.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "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, `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": "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": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, `lastUsedTime` INTEGER NOT NULL, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsedTime", + "columnName": "lastUsedTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "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, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` 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": "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 + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "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": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "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": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "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": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "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": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER 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": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceSecretEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `secret` TEXT NOT NULL, `token` TEXT NOT NULL, 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": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_DeviceSecretEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceSecretEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AuthDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_AuthDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AuthDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MemberDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `memberId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_MemberDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MemberDeviceEntity_memberId", + "unique": false, + "columnNames": [ + "memberId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_memberId` ON `${TABLE_NAME}` (`memberId`)" + }, + { + "name": "index_MemberDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "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, '98441bcc53a06467a4ae2cf750419112')" + ] + } +} \ No newline at end of file diff --git a/account-manager/data-db/src/main/kotlin/me/proton/core/accountmanager/data/db/AccountManagerDatabase.kt b/account-manager/data-db/src/main/kotlin/me/proton/core/accountmanager/data/db/AccountManagerDatabase.kt index f69b75d43..2ac41c12a 100644 --- a/account-manager/data-db/src/main/kotlin/me/proton/core/accountmanager/data/db/AccountManagerDatabase.kt +++ b/account-manager/data-db/src/main/kotlin/me/proton/core/accountmanager/data/db/AccountManagerDatabase.kt @@ -203,7 +203,7 @@ abstract class AccountManagerDatabase : companion object { const val name = "db-account-manager" - const val version = 60 + const val version = 61 val migrations = listOf( AccountManagerDatabaseMigrations.MIGRATION_1_2, @@ -265,6 +265,7 @@ abstract class AccountManagerDatabase : AccountManagerDatabaseMigrations.MIGRATION_57_58, AccountManagerDatabaseMigrations.MIGRATION_58_59, AccountManagerDatabaseMigrations.MIGRATION_59_60, + AccountManagerDatabaseMigrations.MIGRATION_60_61, ) fun databaseBuilder(context: Context): Builder = diff --git a/account-manager/data-db/src/main/kotlin/me/proton/core/accountmanager/data/db/AccountManagerDatabaseMigrations.kt b/account-manager/data-db/src/main/kotlin/me/proton/core/accountmanager/data/db/AccountManagerDatabaseMigrations.kt index 39252ba1b..0ceb5db11 100644 --- a/account-manager/data-db/src/main/kotlin/me/proton/core/accountmanager/data/db/AccountManagerDatabaseMigrations.kt +++ b/account-manager/data-db/src/main/kotlin/me/proton/core/accountmanager/data/db/AccountManagerDatabaseMigrations.kt @@ -417,4 +417,10 @@ object AccountManagerDatabaseMigrations { UserSettingsDatabase.MIGRATION_8.migrate(db) } } + + val MIGRATION_60_61 = object : Migration(60, 61) { + override fun migrate(db: SupportSQLiteDatabase) { + AccountDatabase.MIGRATION_10.migrate(db) + } + } } diff --git a/account/data/src/main/kotlin/me/proton/core/account/data/db/AccountDatabase.kt b/account/data/src/main/kotlin/me/proton/core/account/data/db/AccountDatabase.kt index 74dccd1fe..294f95a98 100644 --- a/account/data/src/main/kotlin/me/proton/core/account/data/db/AccountDatabase.kt +++ b/account/data/src/main/kotlin/me/proton/core/account/data/db/AccountDatabase.kt @@ -187,5 +187,31 @@ interface AccountDatabase : Database { } } + /** + * Add [me.proton.core.account.data.entity.SessionDetailsEntity.passphrase] column. + * Column [me.proton.core.account.data.entity.SessionDetailsEntity.initialEventId] is now nullable. + */ + val MIGRATION_10 = object : DatabaseMigration { + override fun migrate(database: SupportSQLiteDatabase) { + val tableName = "SessionDetailsEntity" + + database.addTableColumn( + table = tableName, + column = "passphrase", + type = "BLOB" + ) + + // `initialEventId` is now nullable. + database.recreateTable( + table = tableName, + createTable = { + execSQL("CREATE TABLE IF NOT EXISTS `${tableName}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `passphrase` BLOB, `password` TEXT, `fido2AuthenticationOptionsJson` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )") + }, + createIndices = { + execSQL("CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${tableName}` (`sessionId`)") + } + ) + } + } } } diff --git a/account/data/src/main/kotlin/me/proton/core/account/data/db/SessionDetailsDao.kt b/account/data/src/main/kotlin/me/proton/core/account/data/db/SessionDetailsDao.kt index c677fd550..a700f9aaf 100644 --- a/account/data/src/main/kotlin/me/proton/core/account/data/db/SessionDetailsDao.kt +++ b/account/data/src/main/kotlin/me/proton/core/account/data/db/SessionDetailsDao.kt @@ -37,6 +37,6 @@ abstract class SessionDetailsDao : BaseDao() { @Query("DELETE FROM SessionDetailsEntity WHERE sessionId = :sessionId") abstract suspend fun delete(sessionId: SessionId) - @Query("UPDATE SessionDetailsEntity SET password = null WHERE sessionId = :sessionId") - abstract suspend fun clearPassword(sessionId: SessionId) + @Query("UPDATE SessionDetailsEntity SET passphrase = null, password = null WHERE sessionId = :sessionId") + abstract suspend fun clearAuthSecrets(sessionId: SessionId) } diff --git a/account/data/src/main/kotlin/me/proton/core/account/data/entity/SessionDetailsEntity.kt b/account/data/src/main/kotlin/me/proton/core/account/data/entity/SessionDetailsEntity.kt index c9ae736ef..f1fc9c5ee 100644 --- a/account/data/src/main/kotlin/me/proton/core/account/data/entity/SessionDetailsEntity.kt +++ b/account/data/src/main/kotlin/me/proton/core/account/data/entity/SessionDetailsEntity.kt @@ -23,6 +23,7 @@ import androidx.room.ForeignKey import androidx.room.Index import me.proton.core.account.domain.entity.AccountType import me.proton.core.account.domain.entity.SessionDetails +import me.proton.core.crypto.common.keystore.EncryptedByteArray import me.proton.core.crypto.common.keystore.EncryptedString import me.proton.core.network.domain.session.SessionId import me.proton.core.util.kotlin.deserializeOrNull @@ -41,10 +42,11 @@ import me.proton.core.util.kotlin.deserializeOrNull ) data class SessionDetailsEntity( val sessionId: SessionId, - val initialEventId: String, + val initialEventId: String?, val requiredAccountType: AccountType, val secondFactorEnabled: Boolean, val twoPassModeEnabled: Boolean, + val passphrase: EncryptedByteArray?, val password: EncryptedString?, val fido2AuthenticationOptionsJson: String? = null ) { @@ -53,6 +55,7 @@ data class SessionDetailsEntity( requiredAccountType = requiredAccountType, secondFactorEnabled = secondFactorEnabled, twoPassModeEnabled = twoPassModeEnabled, + passphrase = passphrase, password = password, fido2AuthenticationOptionsJson = fido2AuthenticationOptionsJson ) diff --git a/account/data/src/main/kotlin/me/proton/core/account/data/repository/AccountRepositoryImpl.kt b/account/data/src/main/kotlin/me/proton/core/account/data/repository/AccountRepositoryImpl.kt index 0e12d948a..76c5c28cc 100644 --- a/account/data/src/main/kotlin/me/proton/core/account/data/repository/AccountRepositoryImpl.kt +++ b/account/data/src/main/kotlin/me/proton/core/account/data/repository/AccountRepositoryImpl.kt @@ -278,13 +278,14 @@ class AccountRepositoryImpl @Inject constructor( requiredAccountType = details.requiredAccountType, secondFactorEnabled = details.secondFactorEnabled, twoPassModeEnabled = details.twoPassModeEnabled, + passphrase = details.passphrase, password = details.password, fido2AuthenticationOptionsJson = details.fido2AuthenticationOptionsJson ) ) override suspend fun clearSessionDetails(sessionId: SessionId) = - sessionDetailsDao.clearPassword(sessionId = sessionId) + sessionDetailsDao.clearAuthSecrets(sessionId = sessionId) override suspend fun addMigration(userId: UserId, migration: String) = db.inTransaction { diff --git a/account/data/src/test/kotlin/me/proton/core/account/data/repository/AccountRepositoryImplTest.kt b/account/data/src/test/kotlin/me/proton/core/account/data/repository/AccountRepositoryImplTest.kt index 4bb73e662..88674859c 100644 --- a/account/data/src/test/kotlin/me/proton/core/account/data/repository/AccountRepositoryImplTest.kt +++ b/account/data/src/test/kotlin/me/proton/core/account/data/repository/AccountRepositoryImplTest.kt @@ -260,7 +260,7 @@ class AccountRepositoryImplTest { fun `clear account session details`() = runTest { accountRepository.clearSessionDetails(testAccountEntity.toAccount(ad).sessionId!!) - coVerify(exactly = 1) { sessionDetailsDao.clearPassword(any()) } + coVerify(exactly = 1) { sessionDetailsDao.clearAuthSecrets(any()) } } @Test @@ -278,6 +278,7 @@ class AccountRepositoryImplTest { requiredAccountType = AccountType.Internal, secondFactorEnabled = true, twoPassModeEnabled = true, + passphrase = null, password = "encrypted-password", fido2AuthenticationOptionsJson = null ) @@ -293,6 +294,7 @@ class AccountRepositoryImplTest { requiredAccountType = AccountType.Internal, secondFactorEnabled = true, twoPassModeEnabled = true, + passphrase = null, password = "encrypted-password", fido2AuthenticationOptionsJson = null ) @@ -500,6 +502,7 @@ class AccountRepositoryImplTest { requiredAccountType = AccountType.Internal, secondFactorEnabled = true, twoPassModeEnabled = true, + passphrase = null, password = "encrypted-password", fido2AuthenticationOptionsJson = null ) @@ -513,6 +516,7 @@ class AccountRepositoryImplTest { requiredAccountType = AccountType.Internal, secondFactorEnabled = true, twoPassModeEnabled = true, + passphrase = null, password = "encrypted-password", fido2AuthenticationOptionsJson = null ) diff --git a/account/domain/src/main/kotlin/me/proton/core/account/domain/entity/Account.kt b/account/domain/src/main/kotlin/me/proton/core/account/domain/entity/Account.kt index 18d225715..cb33149f7 100644 --- a/account/domain/src/main/kotlin/me/proton/core/account/domain/entity/Account.kt +++ b/account/domain/src/main/kotlin/me/proton/core/account/domain/entity/Account.kt @@ -18,6 +18,7 @@ package me.proton.core.account.domain.entity +import me.proton.core.crypto.common.keystore.EncryptedByteArray import me.proton.core.crypto.common.keystore.EncryptedString import me.proton.core.domain.entity.UserId import me.proton.core.network.domain.session.SessionId @@ -43,10 +44,11 @@ data class AccountMetadataDetails( ) data class SessionDetails( - val initialEventId: String, + val initialEventId: String?, val requiredAccountType: AccountType, val secondFactorEnabled: Boolean, val twoPassModeEnabled: Boolean, + val passphrase: EncryptedByteArray?, val password: EncryptedString?, val fido2AuthenticationOptionsJson: String? ) diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/entity/EncryptedAuthSecret.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/entity/EncryptedAuthSecret.kt new file mode 100644 index 000000000..dab11b452 --- /dev/null +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/entity/EncryptedAuthSecret.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Proton AG + * This file is part of Proton 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.auth.domain.entity + +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.EncryptedString + +sealed interface EncryptedAuthSecret { + data object Absent : EncryptedAuthSecret + + @JvmInline + value class Password(val password: EncryptedString) : EncryptedAuthSecret + + @JvmInline + value class Passphrase(val passphrase: EncryptedByteArray) : EncryptedAuthSecret +} diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/entity/SessionForkPayloadWithKey.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/entity/SessionForkPayloadWithKey.kt index 435651e5d..b4aca77a1 100644 --- a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/entity/SessionForkPayloadWithKey.kt +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/entity/SessionForkPayloadWithKey.kt @@ -25,7 +25,7 @@ import kotlinx.serialization.Serializable @Serializable data class SessionForkPayloadWithKey( @SerialName("keyPassword") - val keyPassword: String, + val keyPassword: String?, @EncodeDefault @SerialName("type") diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginLessSession.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginLessSession.kt index 8ae5ff424..e1be6713a 100644 --- a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginLessSession.kt +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginLessSession.kt @@ -67,6 +67,7 @@ class CreateLoginLessSession @Inject constructor( requiredAccountType = requiredAccountType, secondFactorEnabled = sessionInfo.isSecondFactorNeeded, twoPassModeEnabled = sessionInfo.isTwoPassModeNeeded, + passphrase = null, password = null, fido2AuthenticationOptionsJson = sessionInfo.getFido2AuthOptions()?.serialize() ) diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSession.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSession.kt index 7f81e56e2..bf5a05ff2 100644 --- a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSession.kt +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSession.kt @@ -74,6 +74,7 @@ class CreateLoginSession @Inject constructor( requiredAccountType = requiredAccountType, secondFactorEnabled = sessionInfo.isSecondFactorNeeded, twoPassModeEnabled = sessionInfo.isTwoPassModeNeeded, + passphrase = null, password = password, fido2AuthenticationOptionsJson = sessionInfo.getFido2AuthOptions()?.serialize() ) diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSessionFromFork.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSessionFromFork.kt new file mode 100644 index 000000000..b01558169 --- /dev/null +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSessionFromFork.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Proton AG + * This file is part of Proton 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.auth.domain.usecase + +import me.proton.core.account.domain.entity.Account +import me.proton.core.account.domain.entity.AccountDetails +import me.proton.core.account.domain.entity.AccountState +import me.proton.core.account.domain.entity.AccountType +import me.proton.core.account.domain.entity.SessionDetails +import me.proton.core.account.domain.entity.SessionState +import me.proton.core.account.domain.repository.AccountRepository +import me.proton.core.accountmanager.domain.AccountWorkflowHandler +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.network.domain.session.Session +import me.proton.core.user.domain.UserManager +import javax.inject.Inject + +class CreateLoginSessionFromFork @Inject constructor( + private val accountRepository: AccountRepository, + private val accountWorkflowHandler: AccountWorkflowHandler, + private val userManager: UserManager, +) { + suspend operator fun invoke( + accountType: AccountType, + passphrase: EncryptedByteArray?, + session: Session.Authenticated + ) { + val account = Account( + userId = session.userId, + username = null, + email = null, + state = AccountState.NotReady, + sessionId = session.sessionId, + sessionState = SessionState.Authenticated, + details = AccountDetails( + session = SessionDetails( + initialEventId = null, + requiredAccountType = accountType, + secondFactorEnabled = false, + twoPassModeEnabled = false, + passphrase = passphrase, + password = null, + fido2AuthenticationOptionsJson = null, + ) + ) + ) + accountWorkflowHandler.handleSession(account, session) + + val user = userManager.getUser(session.userId) + accountRepository.createOrUpdateAccountSession( + account = account.copy(username = user.name, email = user.email), + session = session + ) + } +} diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSsoSession.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSsoSession.kt index b64f49ea0..4372e8943 100644 --- a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSsoSession.kt +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/CreateLoginSsoSession.kt @@ -62,6 +62,7 @@ class CreateLoginSsoSession @Inject constructor( requiredAccountType = requiredAccountType, secondFactorEnabled = sessionInfo.isSecondFactorNeeded, twoPassModeEnabled = sessionInfo.isTwoPassModeNeeded, + passphrase = null, password = null, fido2AuthenticationOptionsJson = sessionInfo.getFido2AuthOptions()?.serialize() ) diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/PostLoginAccountSetup.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/PostLoginAccountSetup.kt index f95a123e5..2b08ce21f 100644 --- a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/PostLoginAccountSetup.kt +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/PostLoginAccountSetup.kt @@ -23,6 +23,7 @@ import me.proton.core.accountmanager.domain.AccountWorkflowHandler import me.proton.core.accountmanager.domain.SessionManager import me.proton.core.auth.domain.LogTag import me.proton.core.auth.domain.entity.BillingDetails +import me.proton.core.auth.domain.entity.EncryptedAuthSecret import me.proton.core.crypto.common.keystore.EncryptedString import me.proton.core.domain.entity.UserId import me.proton.core.payment.domain.MAX_PLAN_QUANTITY @@ -95,6 +96,30 @@ class PostLoginAccountSetup @Inject constructor( onSetupSuccess: (suspend () -> Unit)? = null, billingDetails: BillingDetails? = null, internalAddressDomain: String? = null + ): Result { + return invoke( + userId = userId, + encryptedAuthSecret = EncryptedAuthSecret.Password(encryptedPassword), + requiredAccountType = requiredAccountType, + isSecondFactorNeeded = isSecondFactorNeeded, + isTwoPassModeNeeded = isTwoPassModeNeeded, + temporaryPassword = temporaryPassword, + onSetupSuccess = onSetupSuccess, + billingDetails = billingDetails, + internalAddressDomain = internalAddressDomain, + ) + } + + suspend operator fun invoke( + userId: UserId, + encryptedAuthSecret: EncryptedAuthSecret, + requiredAccountType: AccountType, + isSecondFactorNeeded: Boolean, + isTwoPassModeNeeded: Boolean, + temporaryPassword: Boolean, + onSetupSuccess: (suspend () -> Unit)? = null, + billingDetails: BillingDetails? = null, + internalAddressDomain: String? = null ): Result { // Flows not using PurchaseStateHandler pass billingDetails. subscribeAnyPendingBilling(billingDetails, userId) @@ -120,22 +145,27 @@ class PostLoginAccountSetup @Inject constructor( Result.Need.ChooseUsername(userId) } is SetupAccountCheck.Result.SetupPrimaryKeysNeeded -> { - setupPrimaryKeys.invoke( - userId, - encryptedPassword, - requiredAccountType, - internalAddressDomain - ) - unlockUserPrimaryKey( - userId, - encryptedPassword, - onSetupSuccess - ) + val encryptedPassword = encryptedAuthSecret as? EncryptedAuthSecret.Password + if (encryptedPassword != null) { + setupPrimaryKeys.invoke( + userId, + encryptedPassword.password, + requiredAccountType, + internalAddressDomain + ) + unlockUserPrimaryKey( + userId, + encryptedAuthSecret, + onSetupSuccess + ) + } else { + Result.Error.UnlockPrimaryKeyError(UserManager.UnlockResult.Error.PrimaryKeyInvalidPassphrase) + } } is SetupAccountCheck.Result.SetupExternalAddressKeysNeeded -> { unlockUserPrimaryKey( userId, - encryptedPassword, + encryptedAuthSecret, onSetupSuccess ) { setupExternalAddressKeys.invoke(userId) @@ -144,7 +174,7 @@ class PostLoginAccountSetup @Inject constructor( is SetupAccountCheck.Result.SetupInternalAddressNeeded -> { unlockUserPrimaryKey( userId, - encryptedPassword, + encryptedAuthSecret, onSetupSuccess ) { setupInternalAddress.invoke(userId, internalAddressDomain) @@ -153,7 +183,7 @@ class PostLoginAccountSetup @Inject constructor( is SetupAccountCheck.Result.NoSetupNeeded -> { unlockUserPrimaryKey( userId, - encryptedPassword, + encryptedAuthSecret, onSetupSuccess ) } @@ -207,11 +237,11 @@ class PostLoginAccountSetup @Inject constructor( private suspend fun unlockUserPrimaryKey( userId: UserId, - password: EncryptedString, + secret: EncryptedAuthSecret, onSetupSuccess: (suspend () -> Unit)?, onUnlockSuccess: (suspend () -> Unit)? = null, ): Result { - return when (val result = unlockUserPrimaryKey.invoke(userId, password)) { + return when (val result = unlockUserPrimaryKey.invoke(userId, secret)) { is UserManager.UnlockResult.Success -> { // Invoke unlock success action. onUnlockSuccess?.invoke() diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/UnlockUserPrimaryKey.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/UnlockUserPrimaryKey.kt index fd178948a..bc8f47a12 100644 --- a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/UnlockUserPrimaryKey.kt +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/UnlockUserPrimaryKey.kt @@ -18,13 +18,15 @@ package me.proton.core.auth.domain.usecase -import me.proton.core.crypto.common.keystore.EncryptedString +import me.proton.core.account.domain.repository.AccountRepository +import me.proton.core.auth.domain.entity.EncryptedAuthSecret import me.proton.core.crypto.common.keystore.KeyStoreCrypto import me.proton.core.crypto.common.keystore.decrypt import me.proton.core.crypto.common.keystore.use import me.proton.core.domain.entity.Product import me.proton.core.domain.entity.UserId import me.proton.core.user.domain.UserManager +import me.proton.core.user.domain.entity.Type import me.proton.core.user.domain.entity.UserKey import me.proton.core.user.domain.extension.hasKeys import me.proton.core.util.kotlin.coroutine.result @@ -38,6 +40,7 @@ import javax.inject.Inject * - For VPN: this function always return UnlockResult.Success. */ class UnlockUserPrimaryKey @Inject constructor( + private val accountRepository: AccountRepository, private val userManager: UserManager, private val keyStoreCrypto: KeyStoreCrypto, private val product: Product @@ -54,16 +57,26 @@ class UnlockUserPrimaryKey @Inject constructor( ) suspend operator fun invoke( userId: UserId, - password: EncryptedString + secret: EncryptedAuthSecret ): UserManager.UnlockResult { return when { - product == Product.Vpn -> UserManager.UnlockResult.Success + shouldSkipForVpn(userId) -> UserManager.UnlockResult.Success !userManager.getUser(userId).hasKeys() -> UserManager.UnlockResult.Success - else -> password.decrypt(keyStoreCrypto).toByteArray().use { - userManager.unlockWithPassword(userId, it) + else -> when (secret) { + is EncryptedAuthSecret.Absent -> UserManager.UnlockResult.Error.PrimaryKeyInvalidPassphrase + is EncryptedAuthSecret.Passphrase -> userManager.unlockWithPassphrase(userId, secret.passphrase) + is EncryptedAuthSecret.Password -> secret.password.decrypt(keyStoreCrypto).toByteArray().use { + userManager.unlockWithPassword(userId, it) + } } }.let { unlockResult -> result("unlockUserPrimaryKey") { unlockResult } } } + + private suspend fun shouldSkipForVpn(userId: UserId): Boolean { + if (product != Product.Vpn) return false + val account = accountRepository.getAccountOrNull(userId) + return account?.details?.session?.twoPassModeEnabled == true || !userManager.getUser(userId).hasKeys() + } } diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/fork/DecryptPassphrasePayload.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/fork/DecryptPassphrasePayload.kt index 5f1ff5bbd..1856e82d1 100644 --- a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/fork/DecryptPassphrasePayload.kt +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/fork/DecryptPassphrasePayload.kt @@ -31,6 +31,7 @@ import me.proton.core.crypto.common.keystore.decrypt import me.proton.core.crypto.common.keystore.encrypt import me.proton.core.util.kotlin.DispatcherProvider import me.proton.core.util.kotlin.deserialize +import me.proton.core.util.kotlin.takeIfNotBlank import javax.inject.Inject class DecryptPassphrasePayload @Inject constructor( @@ -46,7 +47,7 @@ class DecryptPassphrasePayload @Inject constructor( encryptionKey: EncryptedByteArray, aesCipherGCMTagBits: Int, aesCipherIvBytes: Int - ): EncryptedByteArray = withContext(dispatcherProvider.Comp) { + ): EncryptedByteArray? = withContext(dispatcherProvider.Comp) { val aeadCrypto = cryptoContext.aeadCryptoFactory.create( keyAlgorithm = DEFAULT_AES_KEY_ALGORITHM, transformation = DEFAULT_AES_GCM_CIPHER_TRANSFORMATION, @@ -57,7 +58,7 @@ class DecryptPassphrasePayload @Inject constructor( payload.decrypt(aeadCrypto, key = it.array) } val payloadWithKey = decryptedPayload.deserialize() - val key = PlainByteArray(payloadWithKey.keyPassword.toByteArray()) - key.encrypt(cryptoContext.keyStoreCrypto) + val key = payloadWithKey.keyPassword?.takeIfNotBlank()?.let { PlainByteArray(it.toByteArray()) } + key?.encrypt(cryptoContext.keyStoreCrypto) } } diff --git a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/fork/GetEncryptedPassphrasePayload.kt b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/fork/GetEncryptedPassphrasePayload.kt index 91afd5a2d..ee1dc70e4 100644 --- a/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/fork/GetEncryptedPassphrasePayload.kt +++ b/auth/domain/src/main/kotlin/me/proton/core/auth/domain/usecase/fork/GetEncryptedPassphrasePayload.kt @@ -54,15 +54,15 @@ class GetEncryptedPassphrasePayload @Inject constructor( authTagBits = aesCipherGCMTagBits, ivBytes = aesCipherIvBytes ) - requireNotNull(passphraseRepository.getPassphrase(userId)) - .decrypt(cryptoContext.keyStoreCrypto) - .use { decryptedPassphrase -> - val serializedPayload = SessionForkPayloadWithKey( - keyPassword = String(decryptedPassphrase.array) - ).serialize() - encryptionKey.decrypt(cryptoContext.keyStoreCrypto).use { key -> - serializedPayload.encrypt(aeadCrypto, key = key.array) - } + + // Note: passphrase may be `null` on VPN. + passphraseRepository.getPassphrase(userId)?.decrypt(cryptoContext.keyStoreCrypto).use { decryptedPassphrase -> + val serializedPayload = SessionForkPayloadWithKey( + keyPassword = decryptedPassphrase?.let { String(it.array) } + ).serialize() + encryptionKey.decrypt(cryptoContext.keyStoreCrypto).use { key -> + serializedPayload.encrypt(aeadCrypto, key = key.array) } + } } } diff --git a/auth/domain/src/test/kotlin/me/proton/core/auth/domain/usecase/PostLoginSsoAccountSetupTest.kt b/auth/domain/src/test/kotlin/me/proton/core/auth/domain/usecase/PostLoginSsoAccountSetupTest.kt index 9e6ef7542..6f9a55f1b 100644 --- a/auth/domain/src/test/kotlin/me/proton/core/auth/domain/usecase/PostLoginSsoAccountSetupTest.kt +++ b/auth/domain/src/test/kotlin/me/proton/core/auth/domain/usecase/PostLoginSsoAccountSetupTest.kt @@ -26,6 +26,7 @@ import io.mockk.mockk import kotlinx.coroutines.test.runTest import me.proton.core.accountmanager.domain.AccountWorkflowHandler import me.proton.core.accountmanager.domain.SessionManager +import me.proton.core.auth.domain.entity.EncryptedAuthSecret import me.proton.core.auth.domain.entity.SessionInfo import me.proton.core.auth.domain.entity.UnprivatizationInfo import me.proton.core.auth.domain.repository.AuthDeviceRepository @@ -92,7 +93,7 @@ class PostLoginSsoAccountSetupTest { coEvery { this@mockk.invoke(any()) } returns PostLoginAccountSetup.UserCheckResult.Success } unlockUserPrimaryKey = mockk { - coEvery { this@mockk.invoke(any(), any()) } returns UserManager.UnlockResult.Success + coEvery { this@mockk.invoke(any(), any()) } returns UserManager.UnlockResult.Success } userManager = mockk { coEvery { this@mockk.getUser(any(), any()) } returns user diff --git a/auth/domain/src/test/kotlin/me/proton/core/auth/domain/usecase/UnlockUserPrimaryKeyTest.kt b/auth/domain/src/test/kotlin/me/proton/core/auth/domain/usecase/UnlockUserPrimaryKeyTest.kt index 3c0d241c3..3343bac0e 100644 --- a/auth/domain/src/test/kotlin/me/proton/core/auth/domain/usecase/UnlockUserPrimaryKeyTest.kt +++ b/auth/domain/src/test/kotlin/me/proton/core/auth/domain/usecase/UnlockUserPrimaryKeyTest.kt @@ -20,9 +20,13 @@ package me.proton.core.auth.domain.usecase import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk +import me.proton.core.account.domain.repository.AccountRepository +import me.proton.core.auth.domain.entity.EncryptedAuthSecret +import me.proton.core.crypto.common.keystore.EncryptedByteArray import me.proton.core.crypto.common.keystore.EncryptedString import me.proton.core.crypto.common.keystore.KeyStoreCrypto import me.proton.core.domain.entity.Product @@ -34,6 +38,9 @@ import kotlin.test.Test import kotlin.test.assertEquals internal class UnlockUserPrimaryKeyTest { + @MockK + private lateinit var accountRepository: AccountRepository + @MockK private lateinit var keyStoreCrypto: KeyStoreCrypto @@ -46,13 +53,72 @@ internal class UnlockUserPrimaryKeyTest { } @Test - fun vpnSuccess_result() = runTestWithResultContext { + fun vpnSuccess_result_2pass() = runTestWithResultContext { + // GIVEN + coEvery { accountRepository.getAccountOrNull(any()) } returns mockk { + every { details } returns mockk { + every { session } returns mockk { + every { twoPassModeEnabled } returns true + } + } + } // WHEN runTested(Product.Vpn) // THEN val result = assertSingleResult("unlockUserPrimaryKey") assertEquals(UserManager.UnlockResult.Success, result.getOrThrow()) + coVerify(exactly = 0) { userManager.unlockWithPassphrase(any(), any()) } + coVerify(exactly = 0) { userManager.unlockWithPassword(any(), any()) } + } + + @Test + fun vpnSuccess_result_noKeys() = runTestWithResultContext { + // GIVEN + coEvery { accountRepository.getAccountOrNull(any()) } returns mockk { + every { details } returns mockk { + every { session } returns mockk { + every { twoPassModeEnabled } returns false + } + } + } + coEvery { userManager.getUser(any(), any()) } returns mockk { + every { keys } returns emptyList() + } + + // WHEN + runTested(Product.Vpn) + + // THEN + val result = assertSingleResult("unlockUserPrimaryKey") + assertEquals(UserManager.UnlockResult.Success, result.getOrThrow()) + coVerify(exactly = 0) { userManager.unlockWithPassphrase(any(), any()) } + coVerify(exactly = 0) { userManager.unlockWithPassword(any(), any()) } + } + + @Test + fun vpnSuccess_result_unlocks() = runTestWithResultContext { + // GIVEN + coEvery { accountRepository.getAccountOrNull(any()) } returns mockk { + every { details } returns mockk { + every { session } returns mockk { + every { twoPassModeEnabled } returns false + } + } + } + coEvery { userManager.getUser(any(), any()) } returns mockk { + every { keys } returns listOf(mockk()) + } + coEvery { userManager.unlockWithPassphrase(any(), any()) } returns + UserManager.UnlockResult.Success + + // WHEN + runTested(Product.Vpn, authSecret = EncryptedAuthSecret.Passphrase(EncryptedByteArray(byteArrayOf(1, 2, 3)))) + + // THEN + val result = assertSingleResult("unlockUserPrimaryKey") + assertEquals(UserManager.UnlockResult.Success, result.getOrThrow()) + coVerify { userManager.unlockWithPassphrase(any(), any()) } } @Test @@ -88,8 +154,11 @@ internal class UnlockUserPrimaryKeyTest { assertEquals(UserManager.UnlockResult.Error.NoPrimaryKey, result.getOrThrow()) } - private suspend fun runTested(product: Product = Product.Mail) { - val tested = UnlockUserPrimaryKey(userManager, keyStoreCrypto, product) - tested(UserId("test_user_id"), "test_password") + private suspend fun runTested( + product: Product = Product.Mail, + authSecret: EncryptedAuthSecret = EncryptedAuthSecret.Password("test_password") + ) { + val tested = UnlockUserPrimaryKey(accountRepository, userManager, keyStoreCrypto, product) + tested(UserId("test_user_id"), authSecret) } } diff --git a/auth/presentation/build.gradle.kts b/auth/presentation/build.gradle.kts index 529c8a9b5..89239210b 100644 --- a/auth/presentation/build.gradle.kts +++ b/auth/presentation/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { project(Module.challengeDomain), project(Module.challengePresentation), project(Module.countryDomain), + project(Module.cryptoAndroid), project(Module.cryptoCommon), project(Module.deviceMigrationPresentation), project(Module.domain), diff --git a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/AuthOrchestrator.kt b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/AuthOrchestrator.kt index 2bbe157ca..85ac66302 100644 --- a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/AuthOrchestrator.kt +++ b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/AuthOrchestrator.kt @@ -25,10 +25,12 @@ import androidx.activity.result.ActivityResultLauncher import dagger.hilt.android.qualifiers.ApplicationContext import me.proton.core.account.domain.entity.Account import me.proton.core.account.domain.entity.AccountType +import me.proton.core.account.domain.entity.SessionDetails import me.proton.core.auth.domain.feature.IsLoginTwoStepEnabled import me.proton.core.auth.presentation.alert.confirmpass.StartConfirmPassword import me.proton.core.auth.presentation.entity.AddAccountInput import me.proton.core.auth.presentation.entity.AddAccountResult +import me.proton.core.auth.presentation.entity.ChooseAddressAuthSecret import me.proton.core.auth.presentation.entity.ChooseAddressInput import me.proton.core.auth.presentation.entity.ChooseAddressResult import me.proton.core.auth.presentation.entity.DeviceSecretResult @@ -53,6 +55,7 @@ import me.proton.core.auth.presentation.ui.StartLoginTwoStep import me.proton.core.auth.presentation.ui.StartSecondFactor import me.proton.core.auth.presentation.ui.StartSignup import me.proton.core.auth.presentation.ui.StartTwoPassMode +import me.proton.core.crypto.common.keystore.EncryptedByteArray import me.proton.core.crypto.common.keystore.EncryptedString import me.proton.core.domain.entity.UserId import me.proton.core.network.domain.scopes.MissingScopeState @@ -228,14 +231,14 @@ class AuthOrchestrator @Inject constructor( private fun startChooseAddressWorkflow( userId: UserId, - password: EncryptedString, + authSecret: ChooseAddressAuthSecret, externalEmail: String, isTwoPassModeNeeded: Boolean ) { checkRegistered(chooseAddressLauncher).launch( ChooseAddressInput( userId.id, - password = password, + authSecret = authSecret, recoveryEmail = externalEmail, isTwoPassModeNeeded = isTwoPassModeNeeded ) @@ -382,20 +385,28 @@ class AuthOrchestrator @Inject constructor( val email = checkNotNull(account.email) { "Email is null for startChooseAddressWorkflow." } - val password = checkNotNull(account.details.session?.password) { - "Password is null for startChooseAddressWorkflow." - } + val authSecret = getChoosePasswordAuthSecret(account.details.session) val twoPassModeEnabled = checkNotNull(account.details.session?.twoPassModeEnabled) { "TwoPassModeEnabled is null for startChooseAddressWorkflow." } startChooseAddressWorkflow( userId = account.userId, - password = password, + authSecret = authSecret, externalEmail = email, isTwoPassModeNeeded = twoPassModeEnabled ) } + private fun getChoosePasswordAuthSecret(sessionDetails: SessionDetails?): ChooseAddressAuthSecret { + val passphrase = sessionDetails?.passphrase + val password = sessionDetails?.password + return when { + passphrase != null && password == null -> ChooseAddressAuthSecret.Passphrase(passphrase) + passphrase == null && password != null -> ChooseAddressAuthSecret.Password(password) + else -> error("Either passphrase or password must be set.") + } + } + /** * Start the Device Secret workflow. * diff --git a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/entity/AuthHelpResult.kt b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/entity/AuthHelpResult.kt index ad07b1fbc..a5324c136 100644 --- a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/entity/AuthHelpResult.kt +++ b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/entity/AuthHelpResult.kt @@ -24,6 +24,8 @@ import kotlinx.parcelize.Parcelize @Parcelize sealed interface AuthHelpResult : Parcelable { /** User has signed in using Easy Device Migration (QR code). */ - @Parcelize data class SignedInWithEdm(val userId: String) : AuthHelpResult + + /** User has signed in using Easy Device Migration (QR code) and needs to change password. */ + data object PasswordChangeNeededAfterEdm : AuthHelpResult } diff --git a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/entity/ChooseAddressInput.kt b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/entity/ChooseAddressInput.kt index 03d2f08f1..2495f973a 100644 --- a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/entity/ChooseAddressInput.kt +++ b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/entity/ChooseAddressInput.kt @@ -20,12 +20,22 @@ package me.proton.core.auth.presentation.entity import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler +import me.proton.core.crypto.android.keystore.EncryptedByteArrayParceler +import me.proton.core.crypto.common.keystore.EncryptedByteArray import me.proton.core.crypto.common.keystore.EncryptedString @Parcelize data class ChooseAddressInput constructor( val userId: String, - val password: EncryptedString, + val authSecret: ChooseAddressAuthSecret, val recoveryEmail: String, val isTwoPassModeNeeded: Boolean ) : Parcelable + +@Parcelize +sealed class ChooseAddressAuthSecret : Parcelable { + @TypeParceler + data class Passphrase(val passphrase: EncryptedByteArray) : ChooseAddressAuthSecret() + data class Password(val password: EncryptedString) : ChooseAddressAuthSecret() +} diff --git a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/AuthHelpActivity.kt b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/AuthHelpActivity.kt index 0887378e7..d75341dbc 100644 --- a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/AuthHelpActivity.kt +++ b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/AuthHelpActivity.kt @@ -53,17 +53,8 @@ class AuthHelpActivity : AuthActivity(ActivityAuthHelpB override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - targetDeviceMigrationLauncher = registerForActivityResult(StartMigrationFromTargetDevice()) { result -> - if (result is TargetDeviceMigrationResult.NavigateToSignIn) { - setResult(RESULT_CANCELED) - finish() - } else if (result is TargetDeviceMigrationResult.SignedIn) { - setResult(RESULT_OK, Intent().apply { - putExtra(ARG_RESULT, AuthHelpResult.SignedInWithEdm(result.userId)) - }) - finish() - } - } + targetDeviceMigrationLauncher = + registerForActivityResult(StartMigrationFromTargetDevice(), this::onSignedInResult) binding.apply { toolbar.setNavigationOnClickListener { @@ -94,6 +85,33 @@ class AuthHelpActivity : AuthActivity(ActivityAuthHelpB } } + private fun onSignedInResult(result: TargetDeviceMigrationResult?) { + when (result) { + is TargetDeviceMigrationResult.NavigateToSignIn -> { + setResult(RESULT_CANCELED) + finish() + } + + is TargetDeviceMigrationResult.SignedIn -> { + setOkResult(AuthHelpResult.SignedInWithEdm(result.userId)) + finish() + } + + is TargetDeviceMigrationResult.PasswordChangeNeeded -> { + setOkResult(AuthHelpResult.PasswordChangeNeededAfterEdm) + finish() + } + + null -> Unit + } + } + + private fun setOkResult(result: AuthHelpResult) { + setResult(RESULT_OK, Intent().apply { + putExtra(ARG_RESULT, result) + }) + } + companion object { const val ARG_INPUT = "input" const val ARG_RESULT = "result" diff --git a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/ChooseAddressActivity.kt b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/ChooseAddressActivity.kt index d25d1caf2..6b01cf801 100644 --- a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/ChooseAddressActivity.kt +++ b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/ChooseAddressActivity.kt @@ -31,13 +31,17 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import me.proton.core.auth.domain.entity.EncryptedAuthSecret import me.proton.core.auth.domain.usecase.PostLoginAccountSetup import me.proton.core.auth.presentation.R import me.proton.core.auth.presentation.databinding.ActivityChooseAddressBinding +import me.proton.core.auth.presentation.entity.ChooseAddressAuthSecret import me.proton.core.auth.presentation.entity.ChooseAddressInput import me.proton.core.auth.presentation.entity.ChooseAddressResult import me.proton.core.auth.presentation.viewmodel.ChooseAddressViewModel import me.proton.core.auth.presentation.viewmodel.ChooseAddressViewModel.State +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.EncryptedString import me.proton.core.domain.entity.UserId import me.proton.core.observability.domain.metrics.LoginScreenViewTotal import me.proton.core.network.presentation.util.getUserMessage @@ -70,6 +74,13 @@ class ChooseAddressActivity : AuthActivity( requireNotNull(intent?.extras?.getParcelable(ARG_INPUT)) } + private val authSecret: EncryptedAuthSecret by lazy { + when (val s = input.authSecret) { + is ChooseAddressAuthSecret.Passphrase -> EncryptedAuthSecret.Passphrase(s.passphrase) + is ChooseAddressAuthSecret.Password -> EncryptedAuthSecret.Password(s.password) + } + } + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { stopWorkflow() @@ -142,7 +153,7 @@ class ChooseAddressActivity : AuthActivity( viewModel.setUsername( UserId(input.userId), username = it, - password = input.password, + authSecret = authSecret, domain = domainInput.text.toString().replace("@", ""), isTwoPassModeNeeded = input.isTwoPassModeNeeded ) diff --git a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/LoginActivity.kt b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/LoginActivity.kt index fcf598238..cfe973696 100644 --- a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/LoginActivity.kt +++ b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/LoginActivity.kt @@ -181,8 +181,10 @@ class LoginActivity : AuthActivity(ActivityLoginBinding::i override fun onAuthHelpResult(authHelpResult: AuthHelpResult?) { super.onAuthHelpResult(authHelpResult) - if (authHelpResult is AuthHelpResult.SignedInWithEdm) { - onSuccess(UserId(authHelpResult.userId)) + when (authHelpResult) { + is AuthHelpResult.SignedInWithEdm -> onSuccess(UserId(authHelpResult.userId)) + is AuthHelpResult.PasswordChangeNeededAfterEdm -> onChangePassword() + null -> Unit } } diff --git a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/LoginTwoStepActivity.kt b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/LoginTwoStepActivity.kt index 7754460ee..4c5b6aa7a 100644 --- a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/LoginTwoStepActivity.kt +++ b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/ui/LoginTwoStepActivity.kt @@ -156,8 +156,10 @@ class LoginTwoStepActivity : WebPageListenerActivity(), ProductMetricsDelegateOw super.onCreate(savedInstanceState) authHelpLauncher = registerForActivityResult(StartAuthHelp()) { result -> - if (result is AuthHelpResult.SignedInWithEdm) { - onSuccess(UserId(result.userId)) + when (result) { + is AuthHelpResult.SignedInWithEdm -> onSuccess(UserId(result.userId)) + is AuthHelpResult.PasswordChangeNeededAfterEdm -> onChangePassword() + null -> Unit } } diff --git a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/viewmodel/ChooseAddressViewModel.kt b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/viewmodel/ChooseAddressViewModel.kt index 0988d2920..95482d09e 100644 --- a/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/viewmodel/ChooseAddressViewModel.kt +++ b/auth/presentation/src/main/kotlin/me/proton/core/auth/presentation/viewmodel/ChooseAddressViewModel.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.launch import me.proton.core.account.domain.entity.AccountType import me.proton.core.accountmanager.domain.AccountWorkflowHandler import me.proton.core.auth.domain.LogTag +import me.proton.core.auth.domain.entity.EncryptedAuthSecret import me.proton.core.auth.domain.usecase.AccountAvailability import me.proton.core.auth.domain.usecase.PostLoginAccountSetup import me.proton.core.auth.domain.usecase.PostLoginAccountSetup.Result @@ -110,7 +111,7 @@ class ChooseAddressViewModel @Inject constructor( fun setUsername( userId: UserId, username: String, - password: EncryptedString, + authSecret: EncryptedAuthSecret, domain: String, isTwoPassModeNeeded: Boolean ) = viewModelScope.launchWithResultContext { @@ -120,7 +121,7 @@ class ChooseAddressViewModel @Inject constructor( flow { emit(State.Processing) setupUsername(userId, username) - emit(postLoginSetup(userId, password, domain, isTwoPassModeNeeded)) + emit(postLoginSetup(userId, authSecret, domain, isTwoPassModeNeeded)) }.retryOnceWhen(Throwable::primaryKeyExists) { CoreLogger.e(LogTag.FLOW_ERROR_RETRY, it, "Retrying to upgrade an account") }.catch { error -> @@ -159,13 +160,13 @@ class ChooseAddressViewModel @Inject constructor( private suspend fun postLoginSetup( userId: UserId, - password: EncryptedString, + authSecret: EncryptedAuthSecret, domain: String, isTwoPassModeNeeded: Boolean ): State.AccountSetupResult { val result = postLoginAccountSetup( userId = userId, - encryptedPassword = password, + encryptedAuthSecret = authSecret, requiredAccountType = AccountType.Internal, isSecondFactorNeeded = false, isTwoPassModeNeeded = isTwoPassModeNeeded, diff --git a/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/AuthOrchestratorTest.kt b/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/AuthOrchestratorTest.kt index 62343d399..5149331bf 100644 --- a/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/AuthOrchestratorTest.kt +++ b/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/AuthOrchestratorTest.kt @@ -34,6 +34,7 @@ import me.proton.core.account.domain.entity.SessionDetails import me.proton.core.auth.domain.feature.IsLoginTwoStepEnabled import me.proton.core.auth.presentation.alert.confirmpass.StartConfirmPassword import me.proton.core.auth.presentation.entity.AddAccountInput +import me.proton.core.auth.presentation.entity.ChooseAddressAuthSecret import me.proton.core.auth.presentation.entity.ChooseAddressInput import me.proton.core.auth.presentation.entity.LoginInput import me.proton.core.auth.presentation.entity.LoginSsoInput @@ -285,11 +286,11 @@ class AuthOrchestratorTest { orchestrator.startChooseAddressWorkflow(account) }.message // Then - assertEquals("Password is null for startChooseAddressWorkflow.", message) + assertEquals("Either passphrase or password must be set.", message) } @Test - fun `startChooseAddressWorkflow password null`() = runTest { + fun `startChooseAddressWorkflow passphrase and password null`() = runTest { // Given orchestrator.register(caller) val account = mockk(relaxed = true) @@ -298,13 +299,14 @@ class AuthOrchestratorTest { every { account.email } returns "test-email" every { account.details } returns accountDetails every { accountDetails.session } returns session + every { session.passphrase } returns null every { session.password } returns null // When val message = assertFailsWith { orchestrator.startChooseAddressWorkflow(account) }.message // Then - assertEquals("Password is null for startChooseAddressWorkflow.", message) + assertEquals("Either passphrase or password must be set.", message) } @Test @@ -336,10 +338,16 @@ class AuthOrchestratorTest { every { account.userId } returns userId every { accountDetails.session } returns session every { session.requiredAccountType } returns AccountType.Internal + every { session.passphrase } returns null every { session.password } returns encryptedPassword every { session.twoPassModeEnabled } returns true // When - val input = ChooseAddressInput(userId.id, encryptedPassword, email, true) + val input = ChooseAddressInput( + userId = userId.id, + authSecret = ChooseAddressAuthSecret.Password(encryptedPassword), + recoveryEmail = email, + isTwoPassModeNeeded = true + ) orchestrator.startChooseAddressWorkflow(account) // Then verify(exactly = 1) { chooseAddressLauncher.launch(input) } diff --git a/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/ChooseAddressViewModelTest.kt b/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/ChooseAddressViewModelTest.kt index 60c33cf2f..1909bef5f 100644 --- a/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/ChooseAddressViewModelTest.kt +++ b/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/ChooseAddressViewModelTest.kt @@ -26,6 +26,7 @@ import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.yield import me.proton.core.accountmanager.domain.AccountWorkflowHandler +import me.proton.core.auth.domain.entity.EncryptedAuthSecret import me.proton.core.auth.domain.usecase.AccountAvailability import me.proton.core.auth.domain.usecase.PostLoginAccountSetup import me.proton.core.domain.entity.UserId @@ -245,7 +246,7 @@ class ChooseAddressViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Cor coEvery { postLoginAccountSetup.invoke( userId = any(), - encryptedPassword = any(), + encryptedAuthSecret = any(), requiredAccountType = any(), isSecondFactorNeeded = any(), isTwoPassModeNeeded = any(), @@ -274,7 +275,7 @@ class ChooseAddressViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Cor viewModel.setUsername( userId = userId, username = "new-username", - password = "password", + authSecret = EncryptedAuthSecret.Password("password"), domain = "new-domain", isTwoPassModeNeeded = false ) @@ -343,7 +344,7 @@ class ChooseAddressViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Cor viewModel.setUsername( userId = userId, username = "username", - password = "password", + authSecret = EncryptedAuthSecret.Password("password"), domain = "domain", isTwoPassModeNeeded = false ).join() diff --git a/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/SecondFactorViewModelTest.kt b/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/SecondFactorViewModelTest.kt index 77ed3fab1..a465957fa 100644 --- a/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/SecondFactorViewModelTest.kt +++ b/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/SecondFactorViewModelTest.kt @@ -34,6 +34,7 @@ import me.proton.core.auth.domain.usecase.PerformSecondFactor import me.proton.core.auth.domain.usecase.PostLoginAccountSetup import me.proton.core.auth.fido.domain.entity.SecondFactorProof import me.proton.core.auth.presentation.entity.SessionResult +import me.proton.core.crypto.common.keystore.EncryptedString import me.proton.core.domain.entity.UserId import me.proton.core.network.domain.session.SessionId import me.proton.core.network.domain.session.SessionProvider @@ -103,7 +104,7 @@ class SecondFactorViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Unco coEvery { performSecondFactor.invoke(testSessionId, testSecondFactorProof) } coAnswers { result("performSecondFactor") { testScopeInfo } } - coEvery { postLoginAccountSetup.invoke(any(), any(), any(), any(), any(), any()) } returns success + coEvery { postLoginAccountSetup.invoke(any(), any(), any(), any(), any(), any()) } returns success flowTest(viewModel.state) { // WHEN viewModel.startSecondFactorFlow( @@ -133,7 +134,7 @@ class SecondFactorViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Unco val requiredAccountType = AccountType.Internal every { testSessionResult.isTwoPassModeNeeded } returns true coEvery { performSecondFactor.invoke(testSessionId, testSecondFactorProof) } returns testScopeInfo - coEvery { postLoginAccountSetup.invoke(any(), any(), any(), any(), any(), any()) } returns twoPassNeeded + coEvery { postLoginAccountSetup.invoke(any(), any(), any(), any(), any(), any()) } returns twoPassNeeded // WHEN flowTest(viewModel.state) { viewModel.startSecondFactorFlow( diff --git a/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/TwoPassModeViewModelTest.kt b/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/TwoPassModeViewModelTest.kt index 62a10299a..ae274a8d1 100644 --- a/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/TwoPassModeViewModelTest.kt +++ b/auth/presentation/src/test/kotlin/me/proton/core/auth/presentation/viewmodel/TwoPassModeViewModelTest.kt @@ -27,6 +27,7 @@ import io.mockk.slot import me.proton.core.account.domain.entity.AccountType import me.proton.core.accountmanager.domain.AccountWorkflowHandler import me.proton.core.auth.domain.usecase.PostLoginAccountSetup +import me.proton.core.crypto.common.keystore.EncryptedString import me.proton.core.crypto.common.keystore.KeyStoreCrypto import me.proton.core.domain.entity.UserId import me.proton.core.test.android.ArchTest @@ -64,7 +65,7 @@ class TwoPassModeViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Uncon @Test fun `mailbox login happy path`() = coroutinesTest { // GIVEN - coEvery { postLoginAccountSetup.invoke(any(), any(), any(), any(), any(), any()) } returns success + coEvery { postLoginAccountSetup.invoke(any(), any(), any(), any(), any(), any()) } returns success viewModel.state.test { // WHEN viewModel.tryUnlockUser(testUserId, testPassword, accountType) @@ -84,7 +85,7 @@ class TwoPassModeViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Uncon coEvery { postLoginAccountSetup.invoke( any(), - any(), + any(), any(), any(), any(), diff --git a/coreexample/schemas/me.proton.android.core.coreexample.db.AppDatabase/61.json b/coreexample/schemas/me.proton.android.core.coreexample.db.AppDatabase/61.json new file mode 100644 index 000000000..abe158a49 --- /dev/null +++ b/coreexample/schemas/me.proton.android.core.coreexample.db.AppDatabase/61.json @@ -0,0 +1,3562 @@ +{ + "formatVersion": 1, + "database": { + "version": 61, + "identityHash": "98441bcc53a06467a4ae2cf750419112", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `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": false + }, + { + "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": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "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, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `passphrase` BLOB, `password` TEXT, `fido2AuthenticationOptionsJson` 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": false + }, + { + "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": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "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, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `flags` TEXT, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, 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": "createdAtUtc", + "columnName": "createdAtUtc", + "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": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "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 + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "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, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, 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 + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "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, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, 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 + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "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, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, 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": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "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": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "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 + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "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": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `autoDeleteSpamAndTrashDays` INTEGER, `almostAllMail` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, `mobileSettings_listToolbar_isCustom` INTEGER, `mobileSettings_listToolbar_actions` TEXT, `mobileSettings_messageToolbar_isCustom` INTEGER, `mobileSettings_messageToolbar_actions` TEXT, `mobileSettings_conversationToolbar_isCustom` INTEGER, `mobileSettings_conversationToolbar_actions` TEXT, 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": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "autoDeleteSpamAndTrashDays", + "columnName": "autoDeleteSpamAndTrashDays", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "almostAllMail", + "columnName": "almostAllMail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.isCustom", + "columnName": "mobileSettings_listToolbar_isCustom", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.actions", + "columnName": "mobileSettings_listToolbar_actions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.isCustom", + "columnName": "mobileSettings_messageToolbar_isCustom", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.actions", + "columnName": "mobileSettings_messageToolbar_actions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.isCustom", + "columnName": "mobileSettings_conversationToolbar_isCustom", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.actions", + "columnName": "mobileSettings_conversationToolbar_actions", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `easyDeviceMigrationOptOut` 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_registeredKeys` TEXT, 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": "density", + "columnName": "density", + "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": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "easyDeviceMigrationOptOut", + "columnName": "easyDeviceMigrationOptOut", + "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.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "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, `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": "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": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, `lastUsedTime` INTEGER NOT NULL, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsedTime", + "columnName": "lastUsedTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "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, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` 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": "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 + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "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": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "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": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "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": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "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": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "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": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER 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": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceSecretEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `secret` TEXT NOT NULL, `token` TEXT NOT NULL, 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": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_DeviceSecretEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceSecretEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AuthDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_AuthDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AuthDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MemberDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `memberId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_MemberDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MemberDeviceEntity_memberId", + "unique": false, + "columnNames": [ + "memberId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_memberId` ON `${TABLE_NAME}` (`memberId`)" + }, + { + "name": "index_MemberDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "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, '98441bcc53a06467a4ae2cf750419112')" + ] + } +} \ No newline at end of file diff --git a/coreexample/src/main/kotlin/me/proton/android/core/coreexample/db/AppDatabase.kt b/coreexample/src/main/kotlin/me/proton/android/core/coreexample/db/AppDatabase.kt index 9c37041aa..f08f6a5d3 100644 --- a/coreexample/src/main/kotlin/me/proton/android/core/coreexample/db/AppDatabase.kt +++ b/coreexample/src/main/kotlin/me/proton/android/core/coreexample/db/AppDatabase.kt @@ -202,7 +202,7 @@ abstract class AppDatabase : companion object { const val name = "db-account-manager" - const val version = 60 + const val version = 61 val migrations = listOf( AppDatabaseMigrations.MIGRATION_1_2, @@ -264,6 +264,7 @@ abstract class AppDatabase : AppDatabaseMigrations.MIGRATION_57_58, AppDatabaseMigrations.MIGRATION_58_59, AppDatabaseMigrations.MIGRATION_59_60, + AppDatabaseMigrations.MIGRATION_60_61, ) fun buildDatabase(context: Context): AppDatabase = diff --git a/coreexample/src/main/kotlin/me/proton/android/core/coreexample/db/AppDatabaseMigrations.kt b/coreexample/src/main/kotlin/me/proton/android/core/coreexample/db/AppDatabaseMigrations.kt index ee27eef3c..4fe466999 100644 --- a/coreexample/src/main/kotlin/me/proton/android/core/coreexample/db/AppDatabaseMigrations.kt +++ b/coreexample/src/main/kotlin/me/proton/android/core/coreexample/db/AppDatabaseMigrations.kt @@ -418,4 +418,10 @@ object AppDatabaseMigrations { UserSettingsDatabase.MIGRATION_8.migrate(db) } } + + val MIGRATION_60_61 = object : Migration(60, 61) { + override fun migrate(db: SupportSQLiteDatabase) { + AccountDatabase.MIGRATION_10.migrate(db) + } + } } diff --git a/crypto/android/build.gradle.kts b/crypto/android/build.gradle.kts index 65d19dd4b..8a07f6293 100644 --- a/crypto/android/build.gradle.kts +++ b/crypto/android/build.gradle.kts @@ -21,6 +21,7 @@ import studio.forface.easygradle.dsl.android.* plugins { protonAndroidLibrary + id("kotlin-parcelize") } protonBuild { diff --git a/crypto/android/src/main/kotlin/me/proton/core/crypto/android/keystore/CryptoConverters.kt b/crypto/android/src/main/kotlin/me/proton/core/crypto/android/keystore/CryptoConverters.kt index f4e9180c1..7ea6d41c9 100644 --- a/crypto/android/src/main/kotlin/me/proton/core/crypto/android/keystore/CryptoConverters.kt +++ b/crypto/android/src/main/kotlin/me/proton/core/crypto/android/keystore/CryptoConverters.kt @@ -18,8 +18,10 @@ package me.proton.core.crypto.android.keystore +import android.os.Parcel import androidx.room.RoomDatabase import androidx.room.TypeConverter +import kotlinx.parcelize.Parceler import me.proton.core.crypto.common.keystore.EncryptedByteArray /** @@ -35,3 +37,17 @@ class CryptoConverters { fun fromByteArrayToEncryptedByteArray(value: ByteArray?): EncryptedByteArray? = value?.let { EncryptedByteArray(it) } } + +object EncryptedByteArrayParceler : Parceler { + override fun EncryptedByteArray.write(parcel: Parcel, flags: Int) { + parcel.writeInt(array.size) + parcel.writeByteArray(array) + } + + override fun create(parcel: Parcel): EncryptedByteArray { + val size = parcel.readInt() + val buf = ByteArray(size) + parcel.readByteArray(buf) + return EncryptedByteArray(buf) + } +} diff --git a/device-migration/dagger/src/main/kotlin/me/proton/core/devicemigration/dagger/CoreDeviceMigrationModule.kt b/device-migration/dagger/src/main/kotlin/me/proton/core/devicemigration/dagger/CoreDeviceMigrationModule.kt index 70a69af18..254b29159 100644 --- a/device-migration/dagger/src/main/kotlin/me/proton/core/devicemigration/dagger/CoreDeviceMigrationModule.kt +++ b/device-migration/dagger/src/main/kotlin/me/proton/core/devicemigration/dagger/CoreDeviceMigrationModule.kt @@ -26,13 +26,20 @@ import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.components.SingletonComponent import me.proton.core.devicemigration.data.feature.IsEasyDeviceMigrationEnabledImpl +import me.proton.core.devicemigration.data.usecase.IsEasyDeviceMigrationAvailableImpl import me.proton.core.devicemigration.domain.feature.IsEasyDeviceMigrationEnabled import me.proton.core.devicemigration.domain.usecase.GenerateEdmCode +import me.proton.core.devicemigration.domain.usecase.IsEasyDeviceMigrationAvailable import me.proton.core.devicemigration.domain.usecase.ObserveEdmCode @Module @InstallIn(SingletonComponent::class) -public interface CoreDeviceMigrationModule +public interface CoreDeviceMigrationModule { + @Binds + public fun bindIsEasyDeviceMigrationAvailable( + impl: IsEasyDeviceMigrationAvailableImpl + ): IsEasyDeviceMigrationAvailable +} @Module @InstallIn(ViewModelComponent::class) diff --git a/device-migration/data/build.gradle.kts b/device-migration/data/build.gradle.kts index cab09cef1..04e4be0ea 100644 --- a/device-migration/data/build.gradle.kts +++ b/device-migration/data/build.gradle.kts @@ -35,11 +35,16 @@ android { dependencies { api( + project(Module.biometricData), + project(Module.biometricDomain), project(Module.deviceMigrationDomain), - project(Module.featureFlagDomain) + project(Module.domain), + project(Module.featureFlagDomain), + project(Module.userSettingsDomain), ) testImplementation( + `coroutines-test`, `kotlin-test`, mockk, ) diff --git a/device-migration/data/src/main/kotlin/me/proton/core/devicemigration/data/usecase/IsEasyDeviceMigrationAvailableImpl.kt b/device-migration/data/src/main/kotlin/me/proton/core/devicemigration/data/usecase/IsEasyDeviceMigrationAvailableImpl.kt new file mode 100644 index 000000000..22922e2bc --- /dev/null +++ b/device-migration/data/src/main/kotlin/me/proton/core/devicemigration/data/usecase/IsEasyDeviceMigrationAvailableImpl.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Proton AG + * This file is part of Proton 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.devicemigration.data.usecase + +import me.proton.core.biometric.data.StrongAuthenticatorsResolver +import me.proton.core.biometric.domain.CheckBiometricAuthAvailability +import me.proton.core.devicemigration.domain.feature.IsEasyDeviceMigrationEnabled +import me.proton.core.devicemigration.domain.usecase.IsEasyDeviceMigrationAvailable +import me.proton.core.domain.entity.Product +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.UserManager +import me.proton.core.user.domain.extension.hasKeys +import me.proton.core.user.domain.repository.PassphraseRepository +import me.proton.core.usersettings.domain.usecase.IsUserSettingEnabled +import javax.inject.Inject + +public class IsEasyDeviceMigrationAvailableImpl @Inject constructor( + private val checkBiometricAuthAvailability: CheckBiometricAuthAvailability, + private val isEasyDeviceMigrationEnabled: IsEasyDeviceMigrationEnabled, + private val isUserSettingsEnabled: IsUserSettingEnabled, + private val passphraseRepository: PassphraseRepository, + private val product: Product, + private val strongAuthenticatorsResolver: StrongAuthenticatorsResolver, + private val userManager: UserManager +) : IsEasyDeviceMigrationAvailable { + /** + * @param userId If it's not null, the check is performed for the given user as an origin device. + * Otherwise if it's null, the check is performed as a target device (the one trying to log in). + */ + override suspend operator fun invoke(userId: UserId?): Boolean = + isEasyDeviceMigrationEnabled(userId) && when { + userId != null -> isAllowedForUser(userId) + else -> true + } + + private suspend fun isAllowedForUser(userId: UserId): Boolean = when { + userId.hasOptedOut() -> false + !hasBiometrics() -> false + // If there's no passphrase, we only allow for VPN users without keys + // (the user may want to migrate from VPN to VPN, so the passphrase is not required then). + !userId.hasPassphrase() -> product == Product.Vpn && !userId.hasKeys() + else -> true + } + + private fun hasBiometrics(): Boolean = + checkBiometricAuthAvailability(authenticatorsResolver = strongAuthenticatorsResolver).canAttemptBiometricAuth() + + private suspend fun UserId.hasOptedOut(): Boolean = runCatching { + isUserSettingsEnabled(this) { easyDeviceMigrationOptOut } + }.getOrNull() ?: true + + private suspend fun UserId.hasPassphrase(): Boolean = + passphraseRepository.getPassphrase(this) != null + + private suspend fun UserId.hasKeys(): Boolean = + userManager.getUser(this).hasKeys() +} diff --git a/device-migration/domain/src/test/kotlin/me/proton/core/devicemigration/domain/usecase/IsEasyDeviceMigrationAvailableImplTest.kt b/device-migration/data/src/test/kotlin/me/proton/core/devicemigration/data/usecase/IsEasyDeviceMigrationAvailableImplTest.kt similarity index 51% rename from device-migration/domain/src/test/kotlin/me/proton/core/devicemigration/domain/usecase/IsEasyDeviceMigrationAvailableImplTest.kt rename to device-migration/data/src/test/kotlin/me/proton/core/devicemigration/data/usecase/IsEasyDeviceMigrationAvailableImplTest.kt index d9723908e..dbb218098 100644 --- a/device-migration/domain/src/test/kotlin/me/proton/core/devicemigration/domain/usecase/IsEasyDeviceMigrationAvailableImplTest.kt +++ b/device-migration/data/src/test/kotlin/me/proton/core/devicemigration/data/usecase/IsEasyDeviceMigrationAvailableImplTest.kt @@ -16,7 +16,7 @@ * along with ProtonCore. If not, see . */ -package me.proton.core.devicemigration.domain.usecase +package me.proton.core.devicemigration.data.usecase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -24,8 +24,13 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import kotlinx.coroutines.test.runTest +import me.proton.core.biometric.data.StrongAuthenticatorsResolver +import me.proton.core.biometric.domain.CheckBiometricAuthAvailability import me.proton.core.devicemigration.domain.feature.IsEasyDeviceMigrationEnabled +import me.proton.core.devicemigration.domain.usecase.IsEasyDeviceMigrationAvailable +import me.proton.core.domain.entity.Product import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.UserManager import me.proton.core.user.domain.repository.PassphraseRepository import me.proton.core.usersettings.domain.usecase.IsUserSettingEnabled import kotlin.test.BeforeTest @@ -34,6 +39,9 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class IsEasyDeviceMigrationAvailableImplTest { + @MockK + private lateinit var checkBiometricAuthAvailability: CheckBiometricAuthAvailability + @MockK private lateinit var isEasyDeviceMigrationEnabled: IsEasyDeviceMigrationEnabled @@ -43,21 +51,23 @@ class IsEasyDeviceMigrationAvailableImplTest { @MockK private lateinit var passphraseRepository: PassphraseRepository + @MockK + private lateinit var strongAuthenticatorsResolver: StrongAuthenticatorsResolver + + @MockK + private lateinit var userManager: UserManager + private lateinit var tested: IsEasyDeviceMigrationAvailable @BeforeTest fun setUp() { MockKAnnotations.init(this) - tested = IsEasyDeviceMigrationAvailable( - isEasyDeviceMigrationEnabled = isEasyDeviceMigrationEnabled, - isUserSettingsEnabled = isUserSettingEnabled, - passphraseRepository = passphraseRepository - ) } @Test fun `edm feature flag disabled`() = runTest { // Given + makeTested() every { isEasyDeviceMigrationEnabled(any()) } returns false // When @@ -70,6 +80,7 @@ class IsEasyDeviceMigrationAvailableImplTest { @Test fun `edm user setting disabled`() = runTest { // Given + makeTested() every { isEasyDeviceMigrationEnabled(any()) } returns true coEvery { isUserSettingEnabled(any(), any(), any()) } returns true // easyDeviceMigrationOptOut @@ -83,6 +94,8 @@ class IsEasyDeviceMigrationAvailableImplTest { @Test fun `edm user setting enabled`() = runTest { // Given + makeTested() + every { checkBiometricAuthAvailability(any(), any()) } returns CheckBiometricAuthAvailability.Result.Success every { isEasyDeviceMigrationEnabled(any()) } returns true coEvery { isUserSettingEnabled(any(), any(), any()) } returns false // easyDeviceMigrationOptOut coEvery { passphraseRepository.getPassphrase(any()) } returns mockk() @@ -97,6 +110,8 @@ class IsEasyDeviceMigrationAvailableImplTest { @Test fun `edm user setting disabled because no passphrase`() = runTest { // Given + makeTested() + every { checkBiometricAuthAvailability(any(), any()) } returns CheckBiometricAuthAvailability.Result.Success every { isEasyDeviceMigrationEnabled(any()) } returns true coEvery { isUserSettingEnabled(any(), any(), any()) } returns false // easyDeviceMigrationOptOut coEvery { passphraseRepository.getPassphrase(any()) } returns null @@ -108,9 +123,48 @@ class IsEasyDeviceMigrationAvailableImplTest { assertFalse(result) } + @Test + fun `edm user setting enabled on vpn with no passphrase and no user keys`() = runTest { + // Given + makeTested(Product.Vpn) + every { checkBiometricAuthAvailability(any(), any()) } returns CheckBiometricAuthAvailability.Result.Success + every { isEasyDeviceMigrationEnabled(any()) } returns true + coEvery { isUserSettingEnabled(any(), any(), any()) } returns false // easyDeviceMigrationOptOut + coEvery { passphraseRepository.getPassphrase(any()) } returns null + coEvery { userManager.getUser(any()) } returns mockk(relaxed = true) { + every { keys } returns emptyList() + } + + // When + val result = tested(userId = UserId("id-1")) + + // Then + assertTrue(result) + } + + @Test + fun `edm user setting disabled on vpn with no passphrase and user keys`() = runTest { + // Given + makeTested(Product.Vpn) + every { checkBiometricAuthAvailability(any(), any()) } returns CheckBiometricAuthAvailability.Result.Success + every { isEasyDeviceMigrationEnabled(any()) } returns true + coEvery { isUserSettingEnabled(any(), any(), any()) } returns false // easyDeviceMigrationOptOut + coEvery { passphraseRepository.getPassphrase(any()) } returns null + coEvery { userManager.getUser(any()) } returns mockk(relaxed = true) { + every { keys } returns listOf(mockk()) + } + + // When + val result = tested(userId = UserId("id-1")) + + // Then + assertFalse(result) + } + @Test fun `edm enabled for anonymous user`() = runTest { // Given + makeTested() every { isEasyDeviceMigrationEnabled(any()) } returns true // When @@ -119,4 +173,33 @@ class IsEasyDeviceMigrationAvailableImplTest { // Then assertTrue(result) } + + @Test + fun `edm user setting disabled because no biometrics`() = runTest { + // Given + makeTested() + every { + checkBiometricAuthAvailability(any(), any()) + } returns CheckBiometricAuthAvailability.Result.Failure.NotEnrolled + every { isEasyDeviceMigrationEnabled(any()) } returns true + coEvery { isUserSettingEnabled(any(), any(), any()) } returns false // easyDeviceMigrationOptOut + + // When + val result = tested(userId = UserId("id-1")) + + // Then + assertFalse(result) + } + + private fun makeTested(product: Product = Product.Mail) { + tested = IsEasyDeviceMigrationAvailableImpl( + checkBiometricAuthAvailability = checkBiometricAuthAvailability, + isEasyDeviceMigrationEnabled = isEasyDeviceMigrationEnabled, + isUserSettingsEnabled = isUserSettingEnabled, + passphraseRepository = passphraseRepository, + product = product, + strongAuthenticatorsResolver = strongAuthenticatorsResolver, + userManager = userManager + ) + } } diff --git a/device-migration/domain/build.gradle.kts b/device-migration/domain/build.gradle.kts index 69c8a6242..3cee5ff9f 100644 --- a/device-migration/domain/build.gradle.kts +++ b/device-migration/domain/build.gradle.kts @@ -26,8 +26,9 @@ publishOption.shouldBePublishedAsLib = true dependencies { api( + project(Module.authDomain), project(Module.domain), - project(Module.userSettingsDomain), + project(Module.networkDomain), ) implementation( diff --git a/device-migration/domain/src/main/kotlin/me/proton/core/devicemigration/domain/usecase/IsEasyDeviceMigrationAvailable.kt b/device-migration/domain/src/main/kotlin/me/proton/core/devicemigration/domain/usecase/IsEasyDeviceMigrationAvailable.kt index bb3b64254..d6c3c48c9 100644 --- a/device-migration/domain/src/main/kotlin/me/proton/core/devicemigration/domain/usecase/IsEasyDeviceMigrationAvailable.kt +++ b/device-migration/domain/src/main/kotlin/me/proton/core/devicemigration/domain/usecase/IsEasyDeviceMigrationAvailable.kt @@ -18,27 +18,12 @@ package me.proton.core.devicemigration.domain.usecase -import me.proton.core.devicemigration.domain.feature.IsEasyDeviceMigrationEnabled import me.proton.core.domain.entity.UserId -import me.proton.core.user.domain.repository.PassphraseRepository -import me.proton.core.usersettings.domain.usecase.IsUserSettingEnabled -import javax.inject.Inject -public class IsEasyDeviceMigrationAvailable @Inject constructor( - private val isEasyDeviceMigrationEnabled: IsEasyDeviceMigrationEnabled, - private val isUserSettingsEnabled: IsUserSettingEnabled, - private val passphraseRepository: PassphraseRepository -) { - public suspend operator fun invoke(userId: UserId?): Boolean = - isEasyDeviceMigrationEnabled(userId) && when { - userId != null -> !userId.hasOptedOut() && userId.hasPassphrase() - else -> true - } - - private suspend fun UserId.hasOptedOut(): Boolean = runCatching { - isUserSettingsEnabled(this) { easyDeviceMigrationOptOut } - }.getOrNull() ?: true - - private suspend fun UserId.hasPassphrase(): Boolean = - passphraseRepository.getPassphrase(this) != null +public interface IsEasyDeviceMigrationAvailable { + /** + * @param userId If it's not null, the check is performed for the given user as an origin device. + * Otherwise if it's null, the check is performed as a target device (the one trying to log in). + */ + public suspend operator fun invoke(userId: UserId?): Boolean } diff --git a/device-migration/domain/src/main/kotlin/me/proton/core/devicemigration/domain/usecase/PullEdmSessionFork.kt b/device-migration/domain/src/main/kotlin/me/proton/core/devicemigration/domain/usecase/PullEdmSessionFork.kt index 8719be1e9..2b38aa3fd 100644 --- a/device-migration/domain/src/main/kotlin/me/proton/core/devicemigration/domain/usecase/PullEdmSessionFork.kt +++ b/device-migration/domain/src/main/kotlin/me/proton/core/devicemigration/domain/usecase/PullEdmSessionFork.kt @@ -80,7 +80,7 @@ public class PullEdmSessionFork @Inject constructor( public data object Awaiting : Result public data object Loading : Result public data class Success( - val passphrase: EncryptedByteArray, + val passphrase: EncryptedByteArray?, val session: Session.Authenticated ) : Result diff --git a/device-migration/presentation/build.gradle.kts b/device-migration/presentation/build.gradle.kts index f04de0a7d..60e4146f6 100644 --- a/device-migration/presentation/build.gradle.kts +++ b/device-migration/presentation/build.gradle.kts @@ -31,6 +31,7 @@ android { namespace = "me.proton.core.devicemigration.presentation" buildFeatures { + buildConfig = true resValues = true viewBinding = true } diff --git a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/ActivityResultContracts.kt b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/ActivityResultContracts.kt index a3f5d8f9a..f6e1ea48c 100644 --- a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/ActivityResultContracts.kt +++ b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/ActivityResultContracts.kt @@ -65,9 +65,12 @@ public class StartMigrationFromTargetDevice : ActivityResultContract - setResult(RESULT_OK, Intent().apply { - putExtra(ARG_RESULT, TargetDeviceMigrationResult.SignedIn(userId.id)) - }) - finish() - } + onSuccess(userId, shouldChangePassword = false) + }, + onSuccessAndPasswordChange = { userId: UserId -> + onSuccess(userId, shouldChangePassword = true) + }, ) } } + private fun onSuccess(userId: UserId, shouldChangePassword: Boolean) { + val result = when { + shouldChangePassword -> TargetDeviceMigrationResult.PasswordChangeNeeded + else -> TargetDeviceMigrationResult.SignedIn(userId.id) + } + setResult(RESULT_OK, Intent().apply { + putExtra(ARG_RESULT, result) + }) + finish() + } + internal companion object { const val ARG_RESULT = "result" } diff --git a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/TargetDeviceMigrationRoutes.kt b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/TargetDeviceMigrationRoutes.kt index b167ca1fb..a372582b1 100644 --- a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/TargetDeviceMigrationRoutes.kt +++ b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/TargetDeviceMigrationRoutes.kt @@ -35,6 +35,7 @@ internal object TargetDeviceMigrationRoutes { onBackToSignIn: () -> Unit = {}, onNavigateBack: () -> Unit = {}, onSuccess: (userId: UserId) -> Unit, + onSuccessAndPasswordChange: (userId: UserId) -> Unit, ) { composable( route = Route.SignIn.Deeplink @@ -42,7 +43,8 @@ internal object TargetDeviceMigrationRoutes { SignInScreen( onBackToSignIn = onBackToSignIn, onNavigateBack = onNavigateBack, - onSuccess = onSuccess + onSuccess = onSuccess, + onSuccessAndPasswordChange = onSuccessAndPasswordChange, ) } } diff --git a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/intro/SignInIntroViewModel.kt b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/intro/SignInIntroViewModel.kt index 9e4b24fba..7de56fd76 100644 --- a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/intro/SignInIntroViewModel.kt +++ b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/intro/SignInIntroViewModel.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.flow import me.proton.core.biometric.data.StrongAuthenticatorsResolver import me.proton.core.biometric.domain.BiometricAuthErrorCode import me.proton.core.biometric.domain.BiometricAuthResult -import me.proton.core.biometric.domain.CheckBiometricAuthAvailability import me.proton.core.compose.effect.Effect import me.proton.core.compose.viewmodel.BaseViewModel import me.proton.core.devicemigration.domain.usecase.DecodeEdmCode @@ -43,7 +42,6 @@ import javax.inject.Inject @HiltViewModel internal class SignInIntroViewModel @Inject constructor( - private val checkBiometricAuthAvailability: CheckBiometricAuthAvailability, @ApplicationContext private val context: Context, private val decodeEdmCode: DecodeEdmCode, private val pushEdmSessionFork: PushEdmSessionFork, @@ -71,13 +69,7 @@ internal class SignInIntroViewModel @Inject constructor( } private fun onStart() = flow { - when { - shouldStartBiometricsCheck() -> - emit(idleWithEffect(SignInIntroEvent.LaunchBiometricsCheck(strongAuthenticatorsResolver))) - - else -> - emit(idleWithEffect(SignInIntroEvent.LaunchQrScanner)) - } + emit(idleWithEffect(SignInIntroEvent.LaunchBiometricsCheck(strongAuthenticatorsResolver))) } private fun onBiometricAuthResult(result: BiometricAuthResult) = flow { @@ -122,9 +114,6 @@ internal class SignInIntroViewModel @Inject constructor( private fun stateWithEffect(state: SignInIntroState, event: SignInIntroEvent): SignInIntroStateHolder = SignInIntroStateHolder(Effect.of(event), state) - - private fun shouldStartBiometricsCheck() = - checkBiometricAuthAvailability(authenticatorsResolver = strongAuthenticatorsResolver).canAttemptBiometricAuth() } private val BiometricAuthErrorCode.shouldDisplayErrorMessage: Boolean diff --git a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInEvent.kt b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInEvent.kt index 8131bd38d..e8962a392 100644 --- a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInEvent.kt +++ b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInEvent.kt @@ -22,4 +22,5 @@ import me.proton.core.domain.entity.UserId internal sealed interface SignInEvent { data class SignedIn(val userId: UserId) : SignInEvent + data class SignedInAndPasswordChange(val userId: UserId) : SignInEvent } diff --git a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInOperation.kt b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInOperation.kt index b00e05e4c..ae75b5014 100644 --- a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInOperation.kt +++ b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInOperation.kt @@ -25,5 +25,5 @@ internal sealed interface SignInOperation internal sealed interface SignInAction : SignInOperation { data class Load(val unused: Long = System.currentTimeMillis()) : SignInAction - data class SessionForkPulled(val passphrase: EncryptedByteArray, val session: Session.Authenticated) : SignInAction + data class SessionForkPulled(val passphrase: EncryptedByteArray?, val session: Session.Authenticated) : SignInAction } diff --git a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInScreen.kt b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInScreen.kt index 483c079c7..0e67a6fcb 100644 --- a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInScreen.kt +++ b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times +import androidx.core.graphics.createBitmap import androidx.lifecycle.compose.collectAsStateWithLifecycle import me.proton.core.compose.component.ProtonBackButton import me.proton.core.compose.component.ProtonSolidButton @@ -74,32 +75,33 @@ internal fun SignInScreen( onBackToSignIn: () -> Unit, onNavigateBack: () -> Unit, onSuccess: (userId: UserId) -> Unit, + onSuccessAndPasswordChange: (userId: UserId) -> Unit, modifier: Modifier = Modifier, viewModel: SignInViewModel? = hiltViewModelOrNull() ) { val state by viewModel?.state?.collectAsStateWithLifecycle() - ?: remember { derivedStateOf { SignInStateHolder(state = SignInState.Loading) } } + ?: remember { derivedStateOf { SignInState.Loading } } SignInScreen( - state = state.state, - effect = state.effect, + state = state, modifier = modifier, onBackToSignIn = onBackToSignIn, onNavigateBack = onNavigateBack, - onSuccess = onSuccess + onSuccess = onSuccess, + onSuccessAndPasswordChange = onSuccessAndPasswordChange, ) } @Composable internal fun SignInScreen( state: SignInState, - effect: Effect?, modifier: Modifier = Modifier, onBackToSignIn: () -> Unit = {}, onNavigateBack: () -> Unit = {}, onSuccess: (userId: UserId) -> Unit = {}, + onSuccessAndPasswordChange: (userId: UserId) -> Unit = {}, ) { val title = when (state) { - is SignInState.UnrecoverableError -> "" + is SignInState.Failure -> "" else -> stringResource(R.string.target_sign_in_title) } Scaffold( @@ -107,7 +109,7 @@ internal fun SignInScreen( topBar = { SignInTopBar(onBackClicked = onNavigateBack, title = title) } ) { padding -> Box(modifier = modifier.padding(padding)) { - if (state is SignInState.UnrecoverableError) { + if (state is SignInState.Failure) { SignInErrorContent(state, onBackToSignIn = onBackToSignIn) } else { SignInContent(state) @@ -115,7 +117,11 @@ internal fun SignInScreen( } } - SignInEffects(effect, onSuccess) + SignInEffects( + effect = state.effect, + onSuccess = onSuccess, + onSuccessAndPasswordChange = onSuccessAndPasswordChange, + ) } @Composable @@ -173,14 +179,14 @@ private fun SignInContent( .size(qrBitmapSize) ) - is SignInState.UnrecoverableError -> Image( + is SignInState.Failure -> Image( painter = painterResource(R.drawable.ic_proton_cross_big), contentDescription = null, modifier = Modifier.align(Alignment.Center), colorFilter = ColorFilter.tint(Color.Black) ) - SignInState.SuccessfullySignedIn -> Image( + is SignInState.SuccessfullySignedIn -> Image( painter = painterResource(R.drawable.ic_proton_checkmark), contentDescription = null, modifier = Modifier.align(Alignment.Center), @@ -204,7 +210,7 @@ private fun SignInContent( @Composable private fun SignInErrorContent( - state: SignInState.UnrecoverableError, + state: SignInState.Failure, onBackToSignIn: () -> Unit, modifier: Modifier = Modifier ) { @@ -228,7 +234,7 @@ private fun SignInErrorContent( ) Text( - text = stringResource(R.string.target_sign_in_error_description), + text = state.message, style = LocalTypography.current.body2Regular, textAlign = TextAlign.Center, modifier = Modifier @@ -238,12 +244,14 @@ private fun SignInErrorContent( Spacer(modifier = Modifier.weight(1.0f)) - ProtonSolidButton( - onClick = state.onRetry, - contained = false, - modifier = Modifier.heightIn(min = ProtonDimens.DefaultButtonMinHeight) - ) { - Text(text = stringResource(R.string.target_sign_in_error_new_qr)) + if (state.onRetry != null) { + ProtonSolidButton( + onClick = state.onRetry, + contained = false, + modifier = Modifier.heightIn(min = ProtonDimens.DefaultButtonMinHeight) + ) { + Text(text = stringResource(R.string.target_sign_in_error_new_qr)) + } } ProtonTextButton( @@ -262,11 +270,13 @@ private fun SignInErrorContent( private fun SignInEffects( effect: Effect?, onSuccess: (userId: UserId) -> Unit, + onSuccessAndPasswordChange: (userId: UserId) -> Unit = {}, ) { LaunchedEffect(effect) { effect?.consume { event -> when (event) { is SignInEvent.SignedIn -> onSuccess(event.userId) + is SignInEvent.SignedInAndPasswordChange -> onSuccessAndPasswordChange(event.userId) } } } @@ -278,8 +288,15 @@ private fun SignInEffects( private fun SignInScreenPreview() { ProtonTheme { SignInScreen( - state = SignInState.Loading, - effect = null + state = SignInState.Idle( + "qr-code", + generateBitmap = { _, size -> + createBitmap( + size.value.toInt(), + size.value.toInt(), + Bitmap.Config.ARGB_8888 + ) + }) ) } } @@ -290,8 +307,7 @@ private fun SignInScreenPreview() { private fun SignInScreenErrorPreview() { ProtonTheme { SignInScreen( - state = SignInState.UnrecoverableError(onRetry = {}), - effect = null + state = SignInState.Failure(message = "Error", onRetry = {}) ) } } diff --git a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInState.kt b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInState.kt index 10c2e9002..c07121abc 100644 --- a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInState.kt +++ b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInState.kt @@ -22,14 +22,9 @@ import android.graphics.Bitmap import androidx.compose.ui.unit.Dp import me.proton.core.compose.effect.Effect -internal data class SignInStateHolder( - val effect: Effect? = null, - val state: SignInState -) - -internal sealed interface SignInState { - data object Loading : SignInState - data class Idle(val qrCode: String, val generateBitmap: suspend (String, Dp) -> Bitmap) : SignInState - data class UnrecoverableError(val onRetry: () -> Unit) : SignInState - data object SuccessfullySignedIn : SignInState +internal sealed class SignInState(open val effect: Effect? = null) { + data object Loading : SignInState() + data class Idle(val qrCode: String, val generateBitmap: suspend (String, Dp) -> Bitmap) : SignInState() + data class Failure(val message: String, val onRetry: (() -> Unit)?) : SignInState() + data class SuccessfullySignedIn(override val effect: Effect) : SignInState(effect) } diff --git a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInViewModel.kt b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInViewModel.kt index 8885f1fc7..549b9c73e 100644 --- a/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInViewModel.kt +++ b/device-migration/presentation/src/main/kotlin/me/proton/core/devicemigration/presentation/signin/SignInViewModel.kt @@ -18,76 +18,122 @@ package me.proton.core.devicemigration.presentation.signin +import android.content.Context import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import me.proton.core.account.domain.entity.AccountType +import me.proton.core.auth.domain.entity.EncryptedAuthSecret +import me.proton.core.auth.domain.usecase.CreateLoginSessionFromFork +import me.proton.core.auth.domain.usecase.PostLoginAccountSetup import me.proton.core.compose.effect.Effect import me.proton.core.compose.viewmodel.BaseViewModel import me.proton.core.devicemigration.domain.usecase.ObserveEdmCode import me.proton.core.devicemigration.domain.usecase.PullEdmSessionFork +import me.proton.core.devicemigration.presentation.BuildConfig +import me.proton.core.devicemigration.presentation.R import me.proton.core.devicemigration.presentation.qr.QrBitmapGenerator +import me.proton.core.util.kotlin.CoreLogger import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds @HiltViewModel internal class SignInViewModel @Inject constructor( + private val accountType: AccountType, + @ApplicationContext private val context: Context, + private val createLoginSessionFromFork: CreateLoginSessionFromFork, private val observeEdmCode: ObserveEdmCode, + private val postLoginAccountSetup: PostLoginAccountSetup, private val pullEdmSessionFork: PullEdmSessionFork, private val qrBitmapGenerator: QrBitmapGenerator, -) : BaseViewModel( +) : BaseViewModel( initialAction = SignInAction.Load(), - initialState = SignInStateHolder(state = SignInState.Loading), + initialState = SignInState.Loading ) { - override fun onAction(action: SignInAction): Flow = when (action) { + override fun onAction(action: SignInAction): Flow = when (action) { is SignInAction.Load -> onLoad() is SignInAction.SessionForkPulled -> onSessionForkPulled(action) } - override suspend fun FlowCollector.onError(throwable: Throwable) { - emit(stateWithUnrecoverableError()) + override suspend fun FlowCollector.onError(throwable: Throwable) { + emit(stateWithUnrecoverableError(onRetry = { perform(SignInAction.Load()) })) } - private fun onLoad(): Flow = observeEdmCode(sessionId = null).flatMapLatest { edmCodeResult -> + private fun onLoad(): Flow = observeEdmCode(sessionId = null).flatMapLatest { edmCodeResult -> pullEdmSessionFork(edmCodeResult.edmParams.encryptionKey, edmCodeResult.selector).map { pullResult -> Pair(pullResult, edmCodeResult.qrCodeContent) } }.map { (pullResult, qrCodeContent) -> + if (BuildConfig.DEBUG) { + CoreLogger.d("GenerateEdmCode", "QR code: $qrCodeContent") + } when (pullResult) { is PullEdmSessionFork.Result.Awaiting, - is PullEdmSessionFork.Result.Loading -> SignInStateHolder( - state = SignInState.Idle( - qrCode = qrCodeContent, - generateBitmap = qrBitmapGenerator::invoke - ) + is PullEdmSessionFork.Result.Loading -> SignInState.Idle( + qrCode = qrCodeContent, + generateBitmap = qrBitmapGenerator::invoke ) is PullEdmSessionFork.Result.Success -> { perform(SignInAction.SessionForkPulled(pullResult.passphrase, pullResult.session)) - SignInStateHolder(state = SignInState.Loading) + SignInState.Loading } - is PullEdmSessionFork.Result.UnrecoverableError -> stateWithUnrecoverableError() + is PullEdmSessionFork.Result.UnrecoverableError -> + stateWithUnrecoverableError(onRetry = { perform(SignInAction.Load()) }) } - }.onStart { emit(SignInStateHolder(state = SignInState.Loading)) } + }.onStart { emit(SignInState.Loading) } private fun onSessionForkPulled(action: SignInAction.SessionForkPulled) = flow { - emit(SignInStateHolder(state = SignInState.Loading)) - delay(3.seconds) // TODO remove - TODO("perform post-login actions") - SignInStateHolder( - effect = Effect.of(SignInEvent.SignedIn(action.session.userId)), - state = SignInState.SuccessfullySignedIn + emit(SignInState.Loading) + + createLoginSessionFromFork(accountType, action.passphrase, action.session) + + val authSecret = action.passphrase?.let { EncryptedAuthSecret.Passphrase(it) } ?: EncryptedAuthSecret.Absent + val result = postLoginAccountSetup( + userId = action.session.userId, + encryptedAuthSecret = authSecret, + requiredAccountType = accountType, + isSecondFactorNeeded = false, + isTwoPassModeNeeded = false, + temporaryPassword = false, ) + + val state = when (result) { + // Most `Result.Need.*` are handled separately by `AccountManagerObserver`. + is PostLoginAccountSetup.Result.Need.ChooseUsername, + is PostLoginAccountSetup.Result.Need.DeviceSecret, + is PostLoginAccountSetup.Result.Need.SecondFactor, + is PostLoginAccountSetup.Result.Need.TwoPassMode, + is PostLoginAccountSetup.Result.AccountReady -> + SignInState.SuccessfullySignedIn(Effect.of(SignInEvent.SignedIn(action.session.userId))) + + is PostLoginAccountSetup.Result.Need.ChangePassword -> + SignInState.SuccessfullySignedIn(Effect.of(SignInEvent.SignedInAndPasswordChange(action.session.userId))) + + is PostLoginAccountSetup.Result.Error.UnlockPrimaryKeyError -> stateWithUnrecoverableError( + message = context.getString(R.string.target_sign_in_passphrase_error), + onRetry = null + ) + + is PostLoginAccountSetup.Result.Error.UserCheckError -> stateWithUnrecoverableError( + message = result.error.localizedMessage, + onRetry = null + ) + } + + emit(state) } - private fun stateWithUnrecoverableError() = SignInStateHolder( - state = SignInState.UnrecoverableError( - onRetry = { perform(SignInAction.Load()) } - ) + private fun stateWithUnrecoverableError( + message: String = context.getString(R.string.target_sign_in_retryable_error), + onRetry: (() -> Unit)? + ) = SignInState.Failure( + message = message, + onRetry = onRetry ) } diff --git a/device-migration/presentation/src/main/res/values/strings.xml b/device-migration/presentation/src/main/res/values/strings.xml index f65d7d392..122bde8c4 100644 --- a/device-migration/presentation/src/main/res/values/strings.xml +++ b/device-migration/presentation/src/main/res/values/strings.xml @@ -44,7 +44,8 @@ Scan this code with your phone camera to sign in instantly. 1. Open the Proton app on your phone\n2. Tap into Settings, then tap Sign in to another device\n3. Tap Scan QR code Something went wrong - We couldn\'t sign you in.\nPlease scan a new QR code to try again. + We couldn\'t sign you in.\nPlease scan a new QR code to try again. + We couldn\'t sign you in.\nPlease sign in with your password. New QR code Back to sign-in diff --git a/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/intro/SignInIntroViewModelTest.kt b/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/intro/SignInIntroViewModelTest.kt index 40861570f..c580ef5a2 100644 --- a/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/intro/SignInIntroViewModelTest.kt +++ b/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/intro/SignInIntroViewModelTest.kt @@ -13,7 +13,6 @@ import me.proton.core.auth.domain.entity.SessionForkUserCode import me.proton.core.biometric.data.StrongAuthenticatorsResolver import me.proton.core.biometric.domain.BiometricAuthErrorCode import me.proton.core.biometric.domain.BiometricAuthResult -import me.proton.core.biometric.domain.CheckBiometricAuthAvailability import me.proton.core.crypto.common.keystore.EncryptedByteArray import me.proton.core.devicemigration.domain.entity.ChildClientId import me.proton.core.devicemigration.domain.entity.EdmParams @@ -27,13 +26,9 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertNull import kotlin.test.assertSame class SignInIntroViewModelTest : CoroutinesTest by CoroutinesTest() { - @MockK - private lateinit var checkBiometricAuthAvailability: CheckBiometricAuthAvailability - @MockK private lateinit var context: Context @@ -55,7 +50,6 @@ class SignInIntroViewModelTest : CoroutinesTest by CoroutinesTest() { fun setUp() { MockKAnnotations.init(this) tested = SignInIntroViewModel( - checkBiometricAuthAvailability = checkBiometricAuthAvailability, context = context, decodeEdmCode = decodeEdmCode, pushEdmSessionFork = pushEdmSessionFork, @@ -64,30 +58,8 @@ class SignInIntroViewModelTest : CoroutinesTest by CoroutinesTest() { ) } - @Test - fun `starting the flow with no biometrics available`() = coroutinesTest { - // GIVEN - every { checkBiometricAuthAvailability(any(), any()) } returns - CheckBiometricAuthAvailability.Result.Failure.Unsupported - - tested.state.test { - assertInitialState() - - // WHEN - tested.perform(SignInIntroAction.Start) - - // THEN - val state = awaitItem() - assertIs(state.effect?.peek()) - assertEquals(SignInIntroState.Idle, state.state) - } - } - @Test fun `starting the flow with biometrics available`() = coroutinesTest { - // GIVEN - every { checkBiometricAuthAvailability(any(), any()) } returns CheckBiometricAuthAvailability.Result.Success - tested.state.test { assertInitialState() diff --git a/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/signin/SignInScreenTest.kt b/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/signin/SignInScreenTest.kt index 60b172dcb..dc710a972 100644 --- a/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/signin/SignInScreenTest.kt +++ b/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/signin/SignInScreenTest.kt @@ -35,12 +35,22 @@ class SignInScreenTest(deviceConfig: DeviceConfig) { @Test fun `loading state`() { + paparazzi.snapshot { + ProtonTheme { + SignInScreen(state = SignInState.Loading) + } + } + } + + @Test + fun `idle state`() { paparazzi.snapshot { ProtonTheme { SignInScreen( - state = SignInState.Loading, - effect = null - ) + state = SignInState.Idle( + qrCode = "qr-code", + generateBitmap = { _, _ -> throw NotImplementedError() } + )) } } } @@ -49,10 +59,7 @@ class SignInScreenTest(deviceConfig: DeviceConfig) { fun `unrecoverable error`() { paparazzi.snapshot { ProtonTheme { - SignInScreen( - state = SignInState.UnrecoverableError(onRetry = {}), - effect = null - ) + SignInScreen(state = SignInState.Failure(message = "Error", onRetry = {})) } } } diff --git a/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/signin/SignInViewModelTest.kt b/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/signin/SignInViewModelTest.kt index a4c70e63b..d20fe6d57 100644 --- a/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/signin/SignInViewModelTest.kt +++ b/device-migration/presentation/src/test/kotlin/me/proton/core/devicemigration/presentation/signin/SignInViewModelTest.kt @@ -19,17 +19,20 @@ package me.proton.core.devicemigration.presentation.signin import android.content.Context -import android.content.res.Resources import app.cash.turbine.test import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coJustRun import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import me.proton.core.auth.domain.entity.EncryptedAuthSecret import me.proton.core.auth.domain.entity.SessionForkSelector import me.proton.core.auth.domain.entity.SessionForkUserCode +import me.proton.core.auth.domain.usecase.CreateLoginSessionFromFork +import me.proton.core.auth.domain.usecase.PostLoginAccountSetup import me.proton.core.crypto.common.keystore.EncryptedByteArray import me.proton.core.devicemigration.domain.entity.ChildClientId import me.proton.core.devicemigration.domain.entity.EdmCodeResult @@ -38,24 +41,27 @@ import me.proton.core.devicemigration.domain.entity.EncryptionKey import me.proton.core.devicemigration.domain.usecase.ObserveEdmCode import me.proton.core.devicemigration.domain.usecase.PullEdmSessionFork import me.proton.core.devicemigration.presentation.qr.QrBitmapGenerator +import me.proton.core.domain.entity.UserId import me.proton.core.network.domain.session.Session import me.proton.core.test.kotlin.CoroutinesTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertNull class SignInViewModelTest : CoroutinesTest by CoroutinesTest() { @MockK private lateinit var context: Context @MockK - private lateinit var resources: Resources + private lateinit var createLoginSessionFromFork: CreateLoginSessionFromFork @MockK private lateinit var observeEdmCode: ObserveEdmCode + @MockK + private lateinit var postLoginAccountSetup: PostLoginAccountSetup + @MockK private lateinit var pullEdmSessionFork: PullEdmSessionFork @@ -67,17 +73,32 @@ class SignInViewModelTest : CoroutinesTest by CoroutinesTest() { @BeforeTest fun setUp() { MockKAnnotations.init(this) - every { resources.getString(any()) } returns "error-from-resources" - every { context.resources } returns resources + every { context.getString(any()) } returns "string-resource" coEvery { qrBitmapGenerator.invoke(any(), any(), any(), any()) } returns mockk() - tested = SignInViewModel(observeEdmCode, pullEdmSessionFork, qrBitmapGenerator) + tested = SignInViewModel( + accountType = mockk(), + context = context, + createLoginSessionFromFork = createLoginSessionFromFork, + observeEdmCode = observeEdmCode, + postLoginAccountSetup = postLoginAccountSetup, + pullEdmSessionFork = pullEdmSessionFork, + qrBitmapGenerator = qrBitmapGenerator + ) } @Test fun `happy path`() = coroutinesTest { // GIVEN val passphrase = EncryptedByteArray(byteArrayOf(1, 2, 3)) - val session = mockk() + val testUserId = UserId("user-id") + val session = mockk { + every { userId } returns testUserId + } + + coJustRun { createLoginSessionFromFork(any(), any(), any()) } + coEvery { + postLoginAccountSetup(any(), any(), any(), any(), any(), any()) + } returns PostLoginAccountSetup.Result.AccountReady(testUserId) every { pullEdmSessionFork(any(), any(), any()) } returns flowOf( PullEdmSessionFork.Result.Awaiting, PullEdmSessionFork.Result.Success(passphrase, session) @@ -97,23 +118,18 @@ class SignInViewModelTest : CoroutinesTest by CoroutinesTest() { // WHEN tested.state.test { // THEN - assertEquals(SignInStateHolder(state = SignInState.Loading), awaitItem()) + assertEquals(SignInState.Loading, awaitItem()) assertEquals( - SignInStateHolder( - state = SignInState.Idle( - qrCode = "qr-code", - generateBitmap = qrBitmapGenerator::invoke - ) - ), awaitItem() + SignInState.Idle(qrCode = "qr-code", generateBitmap = qrBitmapGenerator::invoke), + awaitItem() ) // loading while performing post-login actions: - assertEquals(SignInStateHolder(state = SignInState.Loading), awaitItem()) + assertEquals(SignInState.Loading, awaitItem()) - // TODO verify logged in -// val (effect, state) = awaitItem() -// val signedInEvent = assertIs(effect?.peek()) -// assertEquals(session.userId, signedInEvent.userId) -// assertEquals(SignInState.SuccessfullySignedIn, state) + val state = awaitItem() + assertIs(state) + val signedInEvent = assertIs(state.effect.peek()) + assertEquals(session.userId, signedInEvent.userId) } } @@ -127,10 +143,8 @@ class SignInViewModelTest : CoroutinesTest by CoroutinesTest() { // WHEN tested.state.test { // THEN - assertEquals(SignInStateHolder(state = SignInState.Loading), awaitItem()) - val (effect, state) = awaitItem() - assertNull(effect) - assertIs(state) + assertEquals(SignInState.Loading, awaitItem()) + assertIs(awaitItem()) } } @@ -147,36 +161,48 @@ class SignInViewModelTest : CoroutinesTest by CoroutinesTest() { // WHEN tested.state.test { // THEN - assertEquals(SignInStateHolder(state = SignInState.Loading), awaitItem()) - val (effect, state) = awaitItem() - assertNull(effect) - assertIs(state) + assertEquals(SignInState.Loading, awaitItem()) + assertIs(awaitItem()) } } @Test fun `awaiting the fork`() = coroutinesTest { // GIVEN + val testUserId = UserId("user-id") + val session = mockk { + every { userId } returns testUserId + } + + coJustRun { createLoginSessionFromFork(any(), any(), any()) } every { observeEdmCode(any()) } returns flowOf( EdmCodeResult(mockk(relaxed = true), "qr-code", SessionForkSelector("selector")) ) + coEvery { + postLoginAccountSetup(any(), any(), any(), any(), any(), any()) + } returns PostLoginAccountSetup.Result.AccountReady(testUserId) every { pullEdmSessionFork(any(), any(), any()) } returns flowOf( PullEdmSessionFork.Result.Loading, PullEdmSessionFork.Result.Awaiting, PullEdmSessionFork.Result.Loading, - PullEdmSessionFork.Result.Success(mockk(), mockk()), + PullEdmSessionFork.Result.Success(mockk(), session), ) // WHEN tested.state.test { // THEN - assertIs(awaitItem().state) + assertIs(awaitItem()) // stays Idle when pullEdmSessionFork returns Loading or Awaiting: - assertIs(awaitItem().state) + assertIs(awaitItem()) // back to loading when pullEdmSessionFork returns Success: - assertIs(awaitItem().state) + assertIs(awaitItem()) + + val state = awaitItem() + assertIs(state) + val signedInEvent = assertIs(state.effect.peek()) + assertEquals(session.userId, signedInEvent.userId) } } @@ -204,17 +230,16 @@ class SignInViewModelTest : CoroutinesTest by CoroutinesTest() { // WHEN tested.state.test { // THEN - assertIs(awaitItem().state) + assertIs(awaitItem()) - val (effect1, state1) = awaitItem() - assertNull(effect1) - assertIs(state1) + val state1 = awaitItem() + assertIs(state1) // WHEN - state1.onRetry() + state1.onRetry?.invoke() // THEN - assertIs(awaitItem().state) + assertIs(awaitItem()) } } -} \ No newline at end of file +} diff --git a/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_idle state[0].png b/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_idle state[0].png new file mode 100644 index 000000000..74900f43a --- /dev/null +++ b/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_idle state[0].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a17ff5455af5d620a25d194305d41a707d5a465a0ae0c2982ef62f7f4d4545b +size 26808 diff --git a/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_idle state[1].png b/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_idle state[1].png new file mode 100644 index 000000000..84c68ece5 --- /dev/null +++ b/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_idle state[1].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9c30f93c35f9cd6ac01509d176795516c1b4a9aa41ba18d40a54972713c2d7c +size 30780 diff --git a/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_unrecoverable error[0].png b/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_unrecoverable error[0].png index 4de1b09bb..3cb0f3599 100644 --- a/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_unrecoverable error[0].png +++ b/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_unrecoverable error[0].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58fb2bef92ee6a8f999420967b5c128d3b130a6c4dbfe98531c726fd7f1ecb21 -size 26515 +oid sha256:e779ca17bc669ee752e7b2d27cf8f6ce170c2b655a6ae847abbdb1b235b54da1 +size 19736 diff --git a/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_unrecoverable error[1].png b/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_unrecoverable error[1].png index e284fe7be..d8f432c98 100644 --- a/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_unrecoverable error[1].png +++ b/device-migration/presentation/src/test/snapshots/images/me.proton.core.devicemigration.presentation.signin_SignInScreenTest_unrecoverable error[1].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82d4a8475fa84448bcd5d58390d32bd7a205ebdc385b78dbfcf4bfa053a687a0 -size 28092 +oid sha256:7c8f5cc859ea4a14037344a004c2f90ec4d48b27c0582aed7bb208134010c8ae +size 20558