diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b3083e27..82b05fd8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,10 +12,10 @@ variables: workflow: rules: + - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "candidate" - - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "candidate" before_script: # We must keep these variables here. We can't do it inside the entrypoint, as idk how but @@ -262,10 +262,12 @@ drive-sorting-presentation-firebase-tests: allow_failure: true rules: - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:all/' - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" when: manual + test:firebase:e2e:smoke: extends: .app-firebase-tests when: manual @@ -277,67 +279,128 @@ test:firebase:e2e:smoke: test:firebase:e2e:account: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:account/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.account" + +test:firebase:e2e:computers: + extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:computers/' + - !reference [.app-firebase-tests, rules] + variables: + TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.computers" + test:firebase:e2e:creatingFolder: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:creatingFolder/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.creatingFolder" +test:firebase:e2e:deeplink: + extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:deeplink/' + - !reference [.app-firebase-tests, rules] + variables: + TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.deeplink" + test:firebase:e2e:details: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:details/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.details" test:firebase:e2e:move: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:move/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.move" DEVICE_CONFIG: "quickTest-2" test:firebase:e2e:offline: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:offline/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.offline" +test:firebase:e2e:photos: + extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:photos/' + - !reference [.app-firebase-tests, rules] + variables: + TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.photos" + +test:firebase:e2e:preview: + extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:preview/' + - !reference [.app-firebase-tests, rules] + variables: + TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.preview" + test:firebase:e2e:rename: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:rename/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.rename" test:firebase:e2e:settings: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:settings/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.settings" DEVICE_CONFIG: "quickTest-3" test:firebase:e2e:share: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:share/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.share" DEVICE_CONFIG: "quickTest-2" test:firebase:e2e:subscription: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:subscription/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.subscription" test:firebase:e2e:trash: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:trash/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.trash" test:firebase:e2e:upload: extends: .app-firebase-tests + rules: + - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:upload/' + - !reference [.app-firebase-tests, rules] variables: TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.upload" -test:firebase:e2e:photos: - extends: .app-firebase-tests - variables: - TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.photos" - coverage report: stage: report tags: @@ -404,10 +467,32 @@ publish to firebase app distribution: --release-notes-file "app/src/main/play/release-notes/en-US/default.txt" --groups "qa-team, dev-team, management-team" rules: - - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" - when: manual + - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH allow_failure: true +distribute:debug: + stage: publish + image: $CI_REGISTRY/tpe/test-scripts + needs: + - job: "build:dev:debug" + artifacts: true + rules: + - if: '$CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH' + allow_failure: true + script: + - upload_to_nexus.py + --path app/build/outputs/apk/dev/debug/ProtonDrive-*-dev-debug.apk + --repository "TestData/builds/Drive/Android/LatestDevDebug/" + --filename "latest-drive-dev-debug.apk" + - upload_to_nexus.py + --path app/build/outputs/apk/androidTest/dev/debug/ProtonDrive-*-dev-debug-androidTest.apk + --repository "TestData/builds/Drive/Android/LatestTestDevDebug" + --filename "latest-drive-test-dev-debug.apk" + # Get latest commit information, save it to commit_info.json file and upload to Nexus. + - upload_mr_description_to_nexus.py + --token $PRIVATE_TOKEN_GITLAB_API_PROTON_CI + --repository TestData/builds/Drive/Android/LatestDevDebug + startReview: needs: - job: "prepare-build" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4afec5e..706ab8f7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,15 @@ android { } testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" + managedDevices { + localDevices { + create("pixel2api30") { + device = "Pixel 2" + apiLevel = 30 + systemImageSource = "aosp" + } + } + } } val gitHash = "git rev-parse --short HEAD".runCommand(workingDir = rootDir) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2cf670c7..8b0845d1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,7 @@ android:name=".ui.MainActivity" android:exported="true" android:launchMode="singleTop" + android:taskAffinity="" android:theme="@style/ProtonTheme.Splash.Drive.StatusBarFix"> @@ -66,6 +67,8 @@ + Unit) = takeIf { currentDestination?.route == route }?.let { block() } + +@Composable +fun NavHostController.isCurrentDestination(route: String) = currentBackStack.map { entries -> + entries.lastOrNull()?.destination?.route == route +}.collectAsState(initial = false) diff --git a/app/src/main/kotlin/me/proton/android/drive/extension/ShouldUpgradeStorage.kt b/app/src/main/kotlin/me/proton/android/drive/extension/ShouldUpgradeStorage.kt new file mode 100644 index 00000000..3b05ba90 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/extension/ShouldUpgradeStorage.kt @@ -0,0 +1,5 @@ +package me.proton.android.drive.extension + +import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage + +internal val ShouldUpgradeStorage.Result.asBoolean: Boolean get() = this != ShouldUpgradeStorage.Result.NoUpgrade diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/EventManagerInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/EventManagerInitializer.kt index 943e93ef..bf051863 100644 --- a/app/src/main/kotlin/me/proton/android/drive/initializer/EventManagerInitializer.kt +++ b/app/src/main/kotlin/me/proton/android/drive/initializer/EventManagerInitializer.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -24,7 +24,7 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent -import me.proton.core.drive.eventmanager.DriveEventManager +import me.proton.core.drive.eventmanager.presentation.DriveEventManager @Suppress("unused") class EventManagerInitializer : Initializer { diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt index 63a0e40c..6eaeb290 100644 --- a/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt +++ b/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt @@ -52,6 +52,7 @@ class MainInitializer : Initializer { BackupInitializer::class.java, TelemetryInitializer::class.java, SelectionInitializer::class.java, + PingActiveUserInitializer::class.java, ) companion object { diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/NotificationChannelInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/NotificationChannelInitializer.kt index eefd1b5d..1b8bd292 100644 --- a/app/src/main/kotlin/me/proton/android/drive/initializer/NotificationChannelInitializer.kt +++ b/app/src/main/kotlin/me/proton/android/drive/initializer/NotificationChannelInitializer.kt @@ -28,6 +28,7 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.launch import me.proton.android.drive.usecase.CancelAllForegroundServices +import me.proton.core.account.domain.entity.Account import me.proton.core.accountmanager.domain.AccountManager import me.proton.core.accountmanager.presentation.observe import me.proton.core.accountmanager.presentation.onAccountReady @@ -56,7 +57,7 @@ class NotificationChannelInitializer : Initializer { } accountManager.observe(appLifecycleProvider.lifecycle, Lifecycle.State.STARTED) .onAccountReady { account -> - createNotificationChannels(account.userId, account.username) + createNotificationChannels(account.userId, account.name) } .onAccountRemoved { account -> cancelAllForegroundServices(account.userId) @@ -79,4 +80,7 @@ class NotificationChannelInitializer : Initializer { val removeNotificationChannels: RemoveNotificationChannels val cancelAllForegroundServices: CancelAllForegroundServices } + + // We should not have an Account without username yet we'll have a fallback + private val Account.name get() = username ?: userId.id.take(6) } diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/PingActiveUserInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/PingActiveUserInitializer.kt new file mode 100644 index 00000000..8bfe996e --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/initializer/PingActiveUserInitializer.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.initializer + +import android.content.Context +import androidx.lifecycle.coroutineScope +import androidx.startup.Initializer +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.proton.android.drive.extension.log +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.drive.base.domain.log.LogTag +import me.proton.core.drive.data.domain.usecase.PingActiveUser +import me.proton.core.presentation.app.AppLifecycleProvider + +class PingActiveUserInitializer : Initializer { + override fun create(context: Context) { + with ( + EntryPointAccessors.fromApplication( + context.applicationContext, + PingActiveUserInitializerEntryPoint::class.java + ) + ) { + combine( + appLifecycleProvider.state, + accountManager.getPrimaryUserId(), + ) { state, primaryUserId -> + takeIf { state == AppLifecycleProvider.State.Foreground } + ?.let { + primaryUserId + } + } + .distinctUntilChanged() + .filterNotNull() + .onEach { primaryUserId -> + pingActiveUser(primaryUserId) + .onFailure { error -> + error.log(LogTag.TELEMETRY, "Ping active user failed") + } + } + .launchIn(appLifecycleProvider.lifecycle.coroutineScope) + } + } + + override fun dependencies(): List>> = listOf( + LoggerInitializer::class.java, + ) + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface PingActiveUserInitializerEntryPoint { + val accountManager: AccountManager + val appLifecycleProvider: AppLifecycleProvider + val pingActiveUser: PingActiveUser + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/settings/DebugSettings.kt b/app/src/main/kotlin/me/proton/android/drive/settings/DebugSettings.kt index b4c55a47..d784842f 100644 --- a/app/src/main/kotlin/me/proton/android/drive/settings/DebugSettings.kt +++ b/app/src/main/kotlin/me/proton/android/drive/settings/DebugSettings.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -21,6 +21,7 @@ package me.proton.android.drive.settings import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import dagger.hilt.android.qualifiers.ApplicationContext @@ -48,6 +49,7 @@ class DebugSettings( private val prefsAllowBackupDeletedFilesEnabled = booleanPreferencesKey(ALLOW_BACKUP_DELETED_FILES_ENABLED) private val prefsFeatureFlagFreshDuration = longPreferencesKey(FEATURE_FLAG_FRESH_DURATION) private val prefsUseVerifier = booleanPreferencesKey(USE_VERIFIER) + private val prefsPhotosUpsellPhotoCount = intPreferencesKey(PHOTOS_UPSELL_PHOTO_COUNT) val baseUrlFlow: Flow = prefsKeyBaseUrl.asFlow( dataStore = context.dataStore, default = buildConfig.baseUrl @@ -130,6 +132,11 @@ class DebugSettings( key = prefsUseVerifier, default = buildConfig.useVerifier, ) + override var photosUpsellPhotoCount by Delegate( + dataStore = context.dataStore, + key = prefsPhotosUpsellPhotoCount, + default = buildConfig.photosUpsellPhotoCount, + ) fun reset(coroutineScope: CoroutineScope) { coroutineScope.launch { @@ -149,5 +156,6 @@ class DebugSettings( const val ALLOW_BACKUP_DELETED_FILES_ENABLED = "allow_backup_deleted_files_enabled" const val FEATURE_FLAG_FRESH_DURATION = "feature_flag_fresh_duration" const val USE_VERIFIER = "use_verifier" + const val PHOTOS_UPSELL_PHOTO_COUNT = "photos_upsell_photo_count" } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt b/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt index 5ba7ad86..a889b6b8 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -73,6 +73,7 @@ import me.proton.android.drive.lock.data.provider.BiometricPromptProvider import me.proton.android.drive.lock.domain.manager.AppLockManager import me.proton.android.drive.log.DriveLogTag import me.proton.android.drive.ui.navigation.AppNavGraph +import me.proton.android.drive.ui.navigation.Screen import me.proton.android.drive.ui.provider.LocalSnackbarPadding import me.proton.android.drive.ui.provider.ProvideLocalSnackbarPadding import me.proton.android.drive.ui.viewmodel.AccountViewModel @@ -156,6 +157,11 @@ class MainActivity : FragmentActivity() { deepLinkBaseUrl = this@MainActivity.deepLinkBaseUrl, clearBackstackTrigger = clearBackstackTrigger, deepLinkIntent = deepLinkIntent, + defaultStartDestination = if (configurationProvider.photosFeatureFlag) { + Screen.Photos.route + } else { + Screen.Files.route + }, locked = appLockManager.locked, primaryAccount = accountViewModel.primaryAccount, exitApp = { finish() }, @@ -304,7 +310,7 @@ class MainActivity : FragmentActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) intent?.let { - processIntent(intent, deepLinkIntent, accountViewModel) + processIntent(intent, deepLinkIntent, accountViewModel, true) } } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/dialog/ComputerOptions.kt b/app/src/main/kotlin/me/proton/android/drive/ui/dialog/ComputerOptions.kt new file mode 100644 index 00000000..55d377bb --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/dialog/ComputerOptions.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.dialog + +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.hilt.navigation.compose.hiltViewModel +import me.proton.android.drive.ui.viewmodel.ComputerOptionsViewModel +import me.proton.core.compose.flow.rememberFlowWithLifecycle +import me.proton.core.drive.base.presentation.component.RunAction +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.drivelink.device.presentation.component.DeviceOptions +import me.proton.core.drive.drivelink.device.presentation.options.DeviceOptionEntry +import me.proton.core.drive.link.domain.entity.FolderId + +@Composable +fun ComputerOptions( + runAction: RunAction, + navigateToRenameComputer: (deviceId: DeviceId, folderId: FolderId) -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel = hiltViewModel() + val viewModelDevice by rememberFlowWithLifecycle(viewModel.device).collectAsState(initial = null) + viewModelDevice?.let { device -> + val entries = viewModel.entries( + runAction, + navigateToRenameComputer, + ) + ComputerOptions( + device = device, + entries = entries, + modifier = modifier + .navigationBarsPadding() + .testTag(ComputerOptionsTestTag.computerOptions), + ) + } +} + +@Composable +fun ComputerOptions( + device: Device, + entries: List, + modifier: Modifier = Modifier, +) { + DeviceOptions( + device = device, + entries = entries, + modifier = modifier, + ) +} + +object ComputerOptionsTestTag { + const val computerOptions = "computer options context menu" +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/effect/PhotosEffect.kt b/app/src/main/kotlin/me/proton/android/drive/ui/effect/PhotosEffect.kt new file mode 100644 index 00000000..decf9eb8 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/effect/PhotosEffect.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.effect + +sealed interface PhotosEffect { + data object ShowUpsell : PhotosEffect +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt index 7cf04c36..11bf2bff 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -52,6 +52,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import me.proton.android.drive.extension.get +import me.proton.android.drive.extension.isCurrentDestination +import me.proton.android.drive.extension.log import me.proton.android.drive.extension.require import me.proton.android.drive.extension.requireArguments import me.proton.android.drive.extension.requireSerializable @@ -60,6 +62,7 @@ import me.proton.android.drive.lock.presentation.component.AppLock import me.proton.android.drive.log.DriveLogTag import me.proton.android.drive.photos.presentation.component.PhotosPermissionRationale import me.proton.android.drive.ui.dialog.AutoLockDurations +import me.proton.android.drive.ui.dialog.ComputerOptions import me.proton.android.drive.ui.dialog.ConfirmDeletionDialog import me.proton.android.drive.ui.dialog.ConfirmEmptyTrashDialog import me.proton.android.drive.ui.dialog.ConfirmSkipIssuesDialog @@ -82,11 +85,13 @@ import me.proton.android.drive.ui.options.OptionsFilter import me.proton.android.drive.ui.screen.AppAccessScreen import me.proton.android.drive.ui.screen.BackupIssuesScreen import me.proton.android.drive.ui.screen.FileInfoScreen +import me.proton.android.drive.ui.screen.GetMoreFreeStorageScreen import me.proton.android.drive.ui.screen.HomeScreen import me.proton.android.drive.ui.screen.LauncherScreen import me.proton.android.drive.ui.screen.MoveToFolder import me.proton.android.drive.ui.screen.OfflineScreen import me.proton.android.drive.ui.screen.PhotosBackupScreen +import me.proton.android.drive.ui.screen.PhotosUpsellScreen import me.proton.android.drive.ui.screen.PreviewScreen import me.proton.android.drive.ui.screen.SettingsScreen import me.proton.android.drive.ui.screen.SigningOutScreen @@ -97,6 +102,8 @@ import me.proton.core.account.domain.entity.Account import me.proton.core.compose.component.bottomsheet.ModalBottomSheetViewState import me.proton.core.crypto.common.keystore.KeyStoreCrypto import me.proton.core.domain.entity.UserId +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.drivelink.device.presentation.component.RenameDevice import me.proton.core.drive.drivelink.rename.presentation.Rename import me.proton.core.drive.drivelink.shared.presentation.component.DiscardChangesDialog import me.proton.core.drive.drivelink.shared.presentation.component.ShareViaLink @@ -117,6 +124,7 @@ fun AppNavGraph( deepLinkBaseUrl: String, clearBackstackTrigger: SharedFlow, deepLinkIntent: SharedFlow, + defaultStartDestination: String, locked: Flow, primaryAccount: Flow, exitApp: () -> Unit, @@ -160,8 +168,12 @@ fun AppNavGraph( deepLinkIntent .collectLatest { intent -> CoreLogger.d(DriveLogTag.UI, "Deep link intent received") - navController.handleDeepLink(intent) - homeNavController = createNavController(localContext) + if (!navController.handleDeepLink(intent)) { + // clear query params with user information before logging + val uri = intent.data?.buildUpon()?.apply { clearQuery() }?.build() + IllegalStateException("Invalid deep link: $uri").log(DriveLogTag.UI) + homeNavController = createNavController(localContext) + } } } AppLock(locked = locked, primaryAccount = primaryAccount) { @@ -169,6 +181,7 @@ fun AppNavGraph( navController = navController, homeNavController = homeNavController, deepLinkBaseUrl = deepLinkBaseUrl, + defaultStartDestination = defaultStartDestination, exitApp = exitApp, navigateToBugReport = navigateToBugReport, navigateToSubscription = navigateToSubscription, @@ -184,6 +197,7 @@ fun AppNavGraph( navController: NavHostController, homeNavController: NavHostController, deepLinkBaseUrl: String, + defaultStartDestination: String, exitApp: () -> Unit, navigateToBugReport: () -> Unit, navigateToSubscription: () -> Unit, @@ -194,13 +208,17 @@ fun AppNavGraph( startDestination = Screen.Launcher.route, modifier = Modifier.fillMaxSize() ) { - addLauncher(navController) + addLauncher( + navController = navController, + deepLinkBaseUrl = deepLinkBaseUrl, + ) addSignOutConfirmationDialog(navController) addSigningOut() addHome( navController = navController, homeNavController = homeNavController, deepLinkBaseUrl = deepLinkBaseUrl, + defaultStartDestination = defaultStartDestination, navigateToBugReport = navigateToBugReport, navigateToSubscription = navigateToSubscription, onDrawerStateChanged = onDrawerStateChanged, @@ -238,6 +256,7 @@ fun AppNavGraph( onDrawerStateChanged = onDrawerStateChanged, ) addPhotosIssues(navController) + addPhotosUpsell(navigateToSubscription) addConfirmSkipIssues(navController) addConfirmStopSyncFolderDialog(navController) addPhotosPermissionRationale(navController) @@ -265,18 +284,35 @@ fun AppNavGraph( addSystemAccessDialog(navController) addAutoLockDurations(navController) addPhotosBackup(navController) + addComputerOptions(navController) + addRenameComputerDialog(navController) + addGetMoreFreeStorage(navController) } } @ExperimentalCoroutinesApi @ExperimentalAnimationApi -fun NavGraphBuilder.addLauncher(navController: NavHostController) = composable( +fun NavGraphBuilder.addLauncher( + navController: NavHostController, + deepLinkBaseUrl: String, +) = composable( route = Screen.Launcher.route, -) { + arguments = listOf( + navArgument(Screen.Launcher.REDIRECTION) { + type = NavType.StringType + nullable = true + }, + ), + deepLinks = listOf( + navDeepLink { uriPattern = Screen.Launcher.deepLink(deepLinkBaseUrl) } + ) +) { navBackStackEntry -> + val redirection = navBackStackEntry.get(Screen.Launcher.REDIRECTION) LauncherScreen( + foregroundState = navController.isCurrentDestination(route = Screen.Launcher.route), navigateToHomeScreen = { userId -> navController.runFromRoute(route = Screen.Launcher.route) { - navController.navigate(Screen.Home(userId)) { + navController.navigate(Screen.Home(userId, redirection)) { popUpTo(Screen.Launcher.route) { inclusive = true } } } @@ -544,7 +580,7 @@ internal fun NavGraphBuilder.addHome( homeNavController: NavHostController, deepLinkBaseUrl: String, route: String, - startDestination: String, + defaultStartDestination: String, navigateToBugReport: () -> Unit, navigateToSubscription: () -> Unit, onDrawerStateChanged: (Boolean) -> Unit, @@ -558,6 +594,13 @@ internal fun NavGraphBuilder.addHome( arguments = arguments, ) { navBackStackEntry -> val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID)) + val startDestination = when (navBackStackEntry.get(Screen.Home.TAB)) { + Screen.Home.TAB_FILES -> Screen.Files.route + Screen.Home.TAB_PHOTOS -> Screen.Photos.route + Screen.Home.TAB_COMPUTERS -> Screen.Computers.route + Screen.Home.TAB_SHARED -> Screen.Shared.route + else -> defaultStartDestination + } val shareId = navBackStackEntry.get(Screen.Files.SHARE_ID) val currentFolderId = navBackStackEntry.get(Screen.Files.FOLDER_ID)?.let { folderId -> shareId?.let { @@ -613,6 +656,11 @@ internal fun NavGraphBuilder.addHome( Screen.BackupIssues.invoke(folderId) ) }, + navigateToPhotosUpsell = { + navController.navigate( + Screen.Photos.Upsell(userId) + ) + }, navigateToBackupSettings = { navController.navigate( Screen.Settings.PhotosBackup(userId) @@ -623,6 +671,16 @@ internal fun NavGraphBuilder.addHome( Screen.PhotosPermissionRationale(userId) ) }, + navigateToComputerOptions = { deviceId -> + navController.navigate( + Screen.ComputerOptions.invoke(userId, deviceId) + ) + }, + navigateToGetMoreFreeStorage = { + navController.navigate( + Screen.GetMoreFreeStorage(userId) + ) + }, modifier = Modifier.fillMaxSize(), ) } @@ -633,6 +691,7 @@ fun NavGraphBuilder.addHome( navController: NavHostController, homeNavController: NavHostController, deepLinkBaseUrl: String, + defaultStartDestination: String, navigateToBugReport: () -> Unit, navigateToSubscription: () -> Unit, onDrawerStateChanged: (Boolean) -> Unit, @@ -641,10 +700,19 @@ fun NavGraphBuilder.addHome( homeNavController = homeNavController, deepLinkBaseUrl = deepLinkBaseUrl, route = Screen.Home.route, - startDestination = Screen.Files.route, + defaultStartDestination = defaultStartDestination, navigateToBugReport = navigateToBugReport, navigateToSubscription = navigateToSubscription, - onDrawerStateChanged= onDrawerStateChanged + onDrawerStateChanged= onDrawerStateChanged, + arguments = listOf( + navArgument(Screen.Home.USER_ID) { + type = NavType.StringType + }, + navArgument(Screen.Home.TAB) { + type = NavType.StringType + nullable = true + }, + ), ) @ExperimentalAnimationApi @@ -661,7 +729,7 @@ fun NavGraphBuilder.addHomeFiles( homeNavController = homeNavController, deepLinkBaseUrl = deepLinkBaseUrl, route = Screen.Files.route, - startDestination = Screen.Files.route, + defaultStartDestination = Screen.Files.route, navigateToBugReport = navigateToBugReport, navigateToSubscription = navigateToSubscription, onDrawerStateChanged= onDrawerStateChanged, @@ -694,7 +762,7 @@ fun NavGraphBuilder.addHomeShared( homeNavController = homeNavController, deepLinkBaseUrl = deepLinkBaseUrl, route = Screen.Shared.route, - startDestination = Screen.Shared.route, + defaultStartDestination = Screen.Shared.route, navigateToBugReport = navigateToBugReport, navigateToSubscription = navigateToSubscription, onDrawerStateChanged = onDrawerStateChanged @@ -714,7 +782,7 @@ fun NavGraphBuilder.addHomePhotos( homeNavController = homeNavController, deepLinkBaseUrl = deepLinkBaseUrl, route = Screen.Photos.route, - startDestination = Screen.Photos.route, + defaultStartDestination = Screen.Photos.route, navigateToBugReport = navigateToBugReport, navigateToSubscription = navigateToSubscription, onDrawerStateChanged = onDrawerStateChanged @@ -734,7 +802,7 @@ fun NavGraphBuilder.addHomeComputers( homeNavController = homeNavController, deepLinkBaseUrl = deepLinkBaseUrl, route = Screen.Computers.route, - startDestination = Screen.Computers.route, + defaultStartDestination = Screen.Computers.route, navigateToBugReport = navigateToBugReport, navigateToSubscription = navigateToSubscription, onDrawerStateChanged = onDrawerStateChanged @@ -1334,3 +1402,79 @@ fun NavGraphBuilder.addPhotosBackup(navController: NavHostController) = composab }, ) } + +@ExperimentalAnimationApi +fun NavGraphBuilder.addPhotosUpsell( + navigateToSubscription: () -> Unit, +) = modalBottomSheet( + route = Screen.Photos.Upsell.route, + arguments = listOf( + navArgument(Screen.Settings.USER_ID) { type = NavType.StringType }, + ), +) { _, runAction -> + PhotosUpsellScreen( + runAction = runAction, + navigateToSubscription = navigateToSubscription, + ) +} + +fun NavGraphBuilder.addComputerOptions( + navController: NavHostController, +) = modalBottomSheet( + route = Screen.ComputerOptions.route, + viewState = ModalBottomSheetViewState(dismissOnAction = false), + arguments = listOf( + navArgument(Screen.Files.USER_ID) { type = NavType.StringType }, + navArgument(Screen.ComputerOptions.DEVICE_ID) { type = NavType.StringType }, + ), +) { navBackStackEntry, runAction -> + val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID)) + ComputerOptions( + runAction = runAction, + navigateToRenameComputer = { deviceId: DeviceId, folderId: FolderId -> + navController.navigate(Screen.Dialogs.RenameComputer.invoke(userId, deviceId, folderId)) { + popUpTo(route = Screen.ComputerOptions.route) { inclusive = true } + } + }, + ) +} + +@ExperimentalCoroutinesApi +fun NavGraphBuilder.addRenameComputerDialog(navController: NavHostController) = dialog( + route = Screen.Dialogs.RenameComputer.route, + arguments = listOf( + navArgument(Screen.USER_ID) { type = NavType.StringType }, + navArgument(Screen.Dialogs.RenameComputer.FOLDER_ID) { type = NavType.StringType }, + navArgument(Screen.Dialogs.RenameComputer.DEVICE_ID) { type = NavType.StringType }, + ), +) { + RenameDevice( + onDismiss = { + navController.popBackStack( + route = Screen.Dialogs.RenameComputer.route, + inclusive = true, + ) + } + ) +} + +@ExperimentalAnimationApi +fun NavGraphBuilder.addGetMoreFreeStorage(navController: NavHostController) = composable( + route = Screen.GetMoreFreeStorage.route, + enterTransition = defaultEnterSlideTransition(towards = AnimatedContentTransitionScope.SlideDirection.Up) { true }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = defaultPopExitSlideTransition(towards = AnimatedContentTransitionScope.SlideDirection.Down) { true }, + arguments = listOf( + navArgument(Screen.Settings.USER_ID) { type = NavType.StringType }, + ), +) { + GetMoreFreeStorageScreen( + navigateBack = { + navController.popBackStack( + route = Screen.GetMoreFreeStorage.route, + inclusive = true, + ) + } + ) +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/HomeNavGraph.kt b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/HomeNavGraph.kt index 7e6b6fa8..d2f9b6eb 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/HomeNavGraph.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/HomeNavGraph.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -30,7 +30,6 @@ import androidx.navigation.navArgument import androidx.navigation.navDeepLink import kotlinx.coroutines.ExperimentalCoroutinesApi import me.proton.android.drive.extension.get -import me.proton.android.drive.extension.require import me.proton.android.drive.ui.navigation.animation.defaultEnterSlideTransition import me.proton.android.drive.ui.navigation.animation.defaultExitSlideTransition import me.proton.android.drive.ui.navigation.animation.defaultPopEnterSlideTransition @@ -44,6 +43,7 @@ import me.proton.android.drive.ui.screen.SharedScreen import me.proton.android.drive.ui.screen.SyncedFoldersScreen import me.proton.android.drive.ui.viewstate.HomeScaffoldState import me.proton.core.domain.entity.UserId +import me.proton.core.drive.device.domain.entity.DeviceId import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.LinkId @@ -68,7 +68,9 @@ fun HomeNavGraph( navigateToPhotosPermissionRationale: () -> Unit, navigateToSubscription: () -> Unit, navigateToPhotosIssues: (FolderId) -> Unit, + navigateToPhotosUpsell: () -> Unit, navigateToBackupSettings: () -> Unit, + navigateToComputerOptions: (deviceId: DeviceId) -> Unit, ) = DriveNavHost( navController = homeNavController, startDestination = startDestination @@ -85,13 +87,18 @@ fun HomeNavGraph( navigateToParentFolderOptions, ) addShared( - homeScaffoldState, homeNavController, + deepLinkBaseUrl, + arguments, + homeScaffoldState, { fileId -> navigateToPreview(fileId, PagerType.SINGLE, OptionsFilter.FILES) }, navigateToSorting, { linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) }, ) addPhotos( + homeNavController, + deepLinkBaseUrl, + arguments, homeScaffoldState, navigateToPhotosPermissionRationale, navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO, OptionsFilter.PHOTOS) }, @@ -101,16 +108,20 @@ fun HomeNavGraph( }, navigateToSubscription = navigateToSubscription, navigateToPhotosIssues = navigateToPhotosIssues, + navigateToPhotosUpsell = navigateToPhotosUpsell, navigateToBackupSettings = navigateToBackupSettings, ) addComputers( - homeScaffoldState, homeNavController, + deepLinkBaseUrl, + arguments, + homeScaffoldState, { fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) }, navigateToSorting, { linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) }, { selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES) }, navigateToParentFolderOptions, + navigateToComputerOptions, ) } @@ -186,13 +197,14 @@ fun NavGraphBuilder.addFiles( } } } - } @ExperimentalAnimationApi fun NavGraphBuilder.addShared( - homeScaffoldState: HomeScaffoldState, navController: NavHostController, + deepLinkBaseUrl: String, + arguments: Bundle, + homeScaffoldState: HomeScaffoldState, navigateToPreview: (linkId: FileId) -> Unit, navigateToSorting: (sorting: Sorting) -> Unit, navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit, @@ -205,23 +217,40 @@ fun NavGraphBuilder.addShared( nullable = true defaultValue = null }, + ), + deepLinks = listOf( + navDeepLink { uriPattern = Screen.Shared.deepLink(deepLinkBaseUrl) } ) ) { navBackStackEntry -> - val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID)) - SharedScreen( - homeScaffoldState = homeScaffoldState, - navigateToFiles = { folderId, folderName -> - navController.navigate(Screen.Files(userId, folderId, folderName)) - }, - navigateToPreview = navigateToPreview, - navigateToSortingDialog = navigateToSorting, - navigateToFileOrFolderOptions = navigateToFileOrFolderOptions, - ) + navBackStackEntry.get(Screen.Shared.USER_ID)?.let { userId -> + SharedScreen( + homeScaffoldState = homeScaffoldState, + navigateToFiles = { folderId, folderName -> + navController.navigate(Screen.Files(UserId(userId), folderId, folderName)) + }, + navigateToPreview = navigateToPreview, + navigateToSortingDialog = navigateToSorting, + navigateToFileOrFolderOptions = navigateToFileOrFolderOptions, + ) + } ?: let { + val userId = UserId(requireNotNull(arguments.getString(Screen.Shared.USER_ID))) + val shareId = arguments.getString(Screen.Shared.SHARE_ID)?.let { shareId -> + ShareId(userId, shareId) + } + navController.navigate(Screen.Shared(userId, shareId)) { + popUpTo(navController.graph.findStartDestination().id) { + inclusive = true + } + } + } } @ExperimentalAnimationApi fun NavGraphBuilder.addPhotos( + navController: NavHostController, + deepLinkBaseUrl: String, + arguments: Bundle, homeScaffoldState: HomeScaffoldState, navigateToPhotosPermissionRationale: () -> Unit, navigateToPhotosPreview: (fileId: FileId) -> Unit, @@ -229,6 +258,7 @@ fun NavGraphBuilder.addPhotos( navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit, navigateToSubscription: () -> Unit, navigateToPhotosIssues: (FolderId) -> Unit, + navigateToPhotosUpsell: () -> Unit, navigateToBackupSettings: () -> Unit, ) = composable( route = Screen.Photos.route, @@ -239,30 +269,49 @@ fun NavGraphBuilder.addPhotos( nullable = true defaultValue = null }, + ), + deepLinks = listOf( + navDeepLink { uriPattern = Screen.Photos.deepLink(deepLinkBaseUrl) } ) -) { - PhotosScreen( - homeScaffoldState = homeScaffoldState, - navigateToPhotosPermissionRationale = navigateToPhotosPermissionRationale, - navigateToPhotosPreview = navigateToPhotosPreview, - navigateToPhotosOptions = navigateToPhotosOptions, - navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions, - navigateToSubscription = navigateToSubscription, - navigateToPhotosIssues = navigateToPhotosIssues, - navigateToBackupSettings = navigateToBackupSettings, - ) +) { navBackStackEntry -> + navBackStackEntry.get(Screen.Photos.USER_ID)?.let { _ -> + PhotosScreen( + homeScaffoldState = homeScaffoldState, + navigateToPhotosPermissionRationale = navigateToPhotosPermissionRationale, + navigateToPhotosPreview = navigateToPhotosPreview, + navigateToPhotosOptions = navigateToPhotosOptions, + navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions, + navigateToSubscription = navigateToSubscription, + navigateToPhotosIssues = navigateToPhotosIssues, + navigateToPhotosUpsell = navigateToPhotosUpsell, + navigateToBackupSettings = navigateToBackupSettings, + ) + } ?: let { + val userId = UserId(requireNotNull(arguments.getString(Screen.Photos.USER_ID))) + val shareId = arguments.getString(Screen.Photos.SHARE_ID)?.let { shareId -> + ShareId(userId, shareId) + } + navController.navigate(Screen.Photos(userId, shareId)) { + popUpTo(navController.graph.findStartDestination().id) { + inclusive = true + } + } + } } @ExperimentalCoroutinesApi @ExperimentalAnimationApi fun NavGraphBuilder.addComputers( - homeScaffoldState: HomeScaffoldState, navController: NavHostController, + deepLinkBaseUrl: String, + arguments: Bundle, + homeScaffoldState: HomeScaffoldState, navigateToPreview: (linkId: FileId) -> Unit, navigateToSorting: (sorting: Sorting) -> Unit, navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit, navigateToMultipleFileOrFolderOptions: (SelectionId) -> Unit, navigateToParentFolderOptions: (folderId: FolderId) -> Unit, + navigateToComputerOptions: (deviceId: DeviceId) -> Unit, ) = composable( route = Screen.Computers.route, enterTransition = defaultEnterSlideTransition { @@ -301,47 +350,87 @@ fun NavGraphBuilder.addComputers( nullable = false defaultValue = false } - ) + ), + deepLinks = listOf( + navDeepLink { uriPattern = Screen.Computers.deepLink(deepLinkBaseUrl) } + ), ) { navBackStackEntry -> - val userId = UserId(navBackStackEntry.require(Screen.Computers.USER_ID)) - val argShareId = navBackStackEntry.get(Screen.Files.SHARE_ID) - val argFolderId = navBackStackEntry.get(Screen.Files.FOLDER_ID) - val argSyncedFolders = navBackStackEntry.arguments?.getBoolean(Screen.Computers.SYNCED_FOLDERS, false) ?: false - if (argShareId != null && argFolderId != null) { - if (argSyncedFolders) { - SyncedFoldersScreen( + navBackStackEntry.get(Screen.Computers.USER_ID)?.let { userId -> + val argShareId = navBackStackEntry.get(Screen.Files.SHARE_ID) + val argFolderId = navBackStackEntry.get(Screen.Files.FOLDER_ID) + val argSyncedFolders = + navBackStackEntry.arguments?.getBoolean(Screen.Computers.SYNCED_FOLDERS, false) ?: false + if (argShareId != null && argFolderId != null) { + if (argSyncedFolders) { + SyncedFoldersScreen( + homeScaffoldState = homeScaffoldState, + navigateToFiles = { folderId, folderName -> + navController.navigate( + Screen.Computers( + userId = UserId(userId), + folderId = folderId, + folderName = folderName, + syncedFolders = false + ) + ) + }, + navigateToSortingDialog = navigateToSorting, + navigateBack = { + navController.popBackStack( + route = Screen.Computers.route, + inclusive = true, + ) + }, + ) + } else { + FilesScreen( + homeScaffoldState = homeScaffoldState, + navigateToFiles = { folderId, folderName -> + navController.navigate( + Screen.Computers( + UserId(userId), + folderId, + folderName, + false + ) + ) + }, + navigateToPreview = navigateToPreview, + navigateToSortingDialog = navigateToSorting, + navigateToFileOrFolderOptions = navigateToFileOrFolderOptions, + navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions, + navigateToParentFolderOptions = navigateToParentFolderOptions, + navigateBack = { navController.popBackStack() }, + ) + } + } else { + ComputersScreen( homeScaffoldState = homeScaffoldState, - navigateToFiles = { folderId, folderName -> - navController.navigate(Screen.Computers(userId, folderId, folderName, false)) - }, - navigateToSortingDialog = navigateToSorting, - navigateBack = { - navController.popBackStack( - route = Screen.Computers.route, - inclusive = true, + navigateToSyncedFolders = { folderId, folderName -> + navController.navigate( + Screen.Computers( + UserId(userId), + folderId, + folderName, + true + ) ) }, - ) - } else { - FilesScreen( - homeScaffoldState = homeScaffoldState, - navigateToFiles = { folderId, folderName -> - navController.navigate(Screen.Computers(userId, folderId, folderName, false)) - }, - navigateToPreview = navigateToPreview, - navigateToSortingDialog = navigateToSorting, - navigateToFileOrFolderOptions = navigateToFileOrFolderOptions, - navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions, - navigateToParentFolderOptions = navigateToParentFolderOptions, - navigateBack = { navController.popBackStack() }, + navigateToComputerOptions = navigateToComputerOptions, ) } - } else { - ComputersScreen( - homeScaffoldState = homeScaffoldState, - navigateToSyncedFolders = { folderId, folderName -> - navController.navigate(Screen.Computers(userId, folderId, folderName, true)) - }, - ) + } ?: let { + val userId = UserId(requireNotNull(arguments.getString(Screen.Files.USER_ID))) + val folderId = arguments.getString(Screen.Files.SHARE_ID)?.let { shareId -> + arguments.getString(Screen.Files.FOLDER_ID)?.let { folderId -> + FolderId(ShareId(userId, shareId), folderId) + } + } + val folderName = arguments.getString(Screen.Files.FOLDER_NAME) + navController.navigate(Screen.Computers(userId, folderId, folderName, false)) { + popUpTo(navController.graph.findStartDestination().id) { + inclusive = true + } + } } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt index f64e05fd..1e202dd2 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -27,6 +27,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import me.proton.android.drive.ui.options.OptionsFilter +import me.proton.android.drive.ui.viewmodel.ComputerOptionsViewModel import me.proton.android.drive.ui.viewmodel.FileOrFolderOptionsViewModel import me.proton.android.drive.ui.viewmodel.MoveToFolderViewModel import me.proton.android.drive.ui.viewmodel.MultipleFileOrFolderOptionsViewModel @@ -34,7 +35,9 @@ import me.proton.android.drive.ui.viewmodel.ParentFolderOptionsViewModel import me.proton.android.drive.ui.viewmodel.UploadToViewModel import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.presentation.viewmodel.UserViewModel -import me.proton.core.drive.drivelink.rename.presentation.RenameViewModel +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.drivelink.device.presentation.viewmodel.RenameDeviceViewModel +import me.proton.core.drive.drivelink.rename.presentation.viewmodel.RenameViewModel import me.proton.core.drive.drivelink.shared.presentation.viewmodel.SharedDriveLinkViewModel import me.proton.core.drive.folder.create.presentation.CreateFolderViewModel import me.proton.core.drive.link.domain.entity.FileId @@ -48,7 +51,9 @@ import me.proton.core.drive.sorting.domain.entity.Direction sealed class Screen(val route: String) { open fun deepLink(baseUrl: String): String? = "$baseUrl/$route" - data object Launcher : Screen("launcher") + data object Launcher : Screen("launcher?redirection={redirection}") { + const val REDIRECTION = "redirection" + } data object SigningOut : Screen("signingOut/{userId}") { operator fun invoke(userId: UserId) = "signingOut/${userId.id}" @@ -56,10 +61,20 @@ sealed class Screen(val route: String) { const val USER_ID = Screen.USER_ID } - data object Home : Screen("home/{userId}") { - operator fun invoke(userId: UserId) = "home/${userId.id}" + data object Home : Screen("home/{userId}?tab={tab}") { + operator fun invoke(userId: UserId, tab: String? = null) = buildString { + append("home/${userId.id}") + if (tab != null) { + append("?tab=$tab") + } + } const val USER_ID = Screen.USER_ID + const val TAB = "tab" + const val TAB_FILES = "files" + const val TAB_PHOTOS = "photos" + const val TAB_COMPUTERS = "computers" + const val TAB_SHARED = "shared" } data object Sorting : @@ -123,6 +138,18 @@ sealed class Screen(val route: String) { const val SHARE_ID = ParentFolderOptionsViewModel.KEY_SHARE_ID } + data object ComputerOptions : Screen( + "options/computer/{userId}/devices/{deviceId}" + ) { + + operator fun invoke( + userId: UserId, + deviceId: DeviceId, + ) = "options/computer/${userId.id}/devices/${deviceId.id}" + + const val DEVICE_ID = ComputerOptionsViewModel.KEY_DEVICE_ID + } + data object Info : Screen("info/{userId}/shares/{shareId}/files?linkId={linkId}") { operator fun invoke( userId: UserId, @@ -252,6 +279,10 @@ sealed class Screen(val route: String) { operator fun invoke(userId: UserId, shareId: ShareId?) = "home/${userId.id}/photos/${shareId?.id}" + object Upsell : Screen("home/{userId}/photos/upsell"){ + operator fun invoke(userId: UserId) = "home/${userId.id}/photos/upsell" + } + const val USER_ID = Screen.USER_ID const val SHARE_ID = "shareId" } @@ -291,8 +322,9 @@ sealed class Screen(val route: String) { override fun invoke(userId: UserId) = filesBrowsableBuildRoute("computers", userId, null, null) - operator fun invoke(userId: UserId, folderId: FolderId, folderName: String?, syncedFolders: Boolean) = - filesBrowsableBuildRoute("computers", userId, folderId, folderName) + "&syncedFolders=${syncedFolders}" + operator fun invoke(userId: UserId, folderId: FolderId?, folderName: String?, syncedFolders: Boolean) = + filesBrowsableBuildRoute("computers", userId, folderId, folderName) + + folderId?.let{"&syncedFolders=${syncedFolders}"}.orEmpty() const val USER_ID = Screen.USER_ID const val SYNCED_FOLDERS = "syncedFolders" @@ -421,6 +453,28 @@ sealed class Screen(val route: String) { const val USER_ID = Screen.USER_ID } + + data object RenameComputer : Screen( + "rename/{userId}/shares/{shareId}/files?fileId={fileId}&folderId={folderId}&deviceId={deviceId}" + ) { + + operator fun invoke( + userId: UserId, + deviceId: DeviceId, + folderId: FolderId, + ) = "rename/${userId.id}/shares/${folderId.shareId.id}/files?folderId=${folderId.id}&deviceId=${deviceId.id}" + + const val FOLDER_ID = RenameViewModel.KEY_FOLDER_ID + const val SHARE_ID = RenameViewModel.KEY_SHARE_ID + const val DEVICE_ID = RenameDeviceViewModel.KEY_DEVICE_ID + } + } + + data object GetMoreFreeStorage : Screen("storage/{userId}/getMoreFree") { + + operator fun invoke(userId: UserId) = "storage/${userId.id}/getMoreFree" + + const val USER_ID = Screen.USER_ID } companion object { diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/ComputersScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/ComputersScreen.kt index 608b31b6..46046768 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/ComputersScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/ComputersScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.hilt.navigation.compose.hiltViewModel import me.proton.android.drive.ui.effect.HandleHomeEffect @@ -40,6 +41,7 @@ import me.proton.core.compose.flow.rememberFlowWithLifecycle import me.proton.core.drive.base.presentation.component.ProtonPullToRefresh import me.proton.core.drive.base.presentation.extension.conditional import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId import me.proton.core.drive.drivelink.device.presentation.component.DevicesContent import me.proton.core.drive.drivelink.device.presentation.component.DevicesEmpty import me.proton.core.drive.drivelink.device.presentation.component.DevicesError @@ -56,13 +58,17 @@ import me.proton.core.drive.base.presentation.component.TopAppBar as BaseTopAppB fun ComputersScreen( homeScaffoldState: HomeScaffoldState, navigateToSyncedFolders: (FolderId, String?) -> Unit, + navigateToComputerOptions: (deviceId: DeviceId) -> Unit, modifier: Modifier = Modifier, ) { val viewModel = hiltViewModel() val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState) .collectAsState(initial = viewModel.initialViewState) val viewEvent = remember { - viewModel.viewEvent(navigateToSyncedFolders) + viewModel.viewEvent( + navigateToSyncedFolders, + navigateToComputerOptions, + ) } val devices by rememberFlowWithLifecycle(viewModel.devices).collectAsState(initial = null) viewModel.HandleHomeEffect(homeScaffoldState) @@ -101,9 +107,13 @@ fun Computers( devices = devices, modifier = modifier.fillMaxSize(), onError = {}, - ) { device -> - viewEvent.onDevice(device) - } + onDevice = { device -> + viewEvent.onDevice(device) + }, + onMoreOptions = { device -> + viewEvent.onMoreOptions(device) + }, + ) } } @@ -114,6 +124,7 @@ fun Computers( modifier: Modifier = Modifier, onError: () -> Unit, onDevice: (Device) -> Unit, + onMoreOptions: (Device) -> Unit, ) { val scrollState = rememberScrollState() Box( @@ -146,6 +157,9 @@ fun Computers( DevicesContent( devices = devices, onClick = onDevice, + onMoreOptions = onMoreOptions, + modifier = Modifier + .testTag(ComputersTestTag.content) ) } } @@ -160,7 +174,12 @@ private fun TopAppBar( navigationIcon = if (viewState.navigationIconResId != 0) { painterResource(id = viewState.navigationIconResId) } else null, + notificationDotVisible = viewState.notificationDotVisible, onNavigationIcon = viewEvent.onTopAppBarNavigation, title = viewState.title, ) } + +object ComputersTestTag { + const val content = "computers content" +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/GetMoreFreeStorageScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/GetMoreFreeStorageScreen.kt new file mode 100644 index 00000000..ce952fcc --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/GetMoreFreeStorageScreen.kt @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import me.proton.android.drive.ui.viewmodel.GetMoreFreeStorageViewModel +import me.proton.android.drive.ui.viewstate.GetMoreFreeStorageViewState +import me.proton.core.compose.flow.rememberFlowWithLifecycle +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing +import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing +import me.proton.core.compose.theme.ProtonDimens.MediumSpacing +import me.proton.core.compose.theme.ProtonDimens.SmallSpacing +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultHighlightNorm +import me.proton.core.compose.theme.defaultSmallNorm +import me.proton.core.compose.theme.defaultWeak +import me.proton.core.compose.theme.headlineNorm +import me.proton.core.drive.base.domain.extension.GiB +import me.proton.core.drive.base.presentation.component.TopAppBar +import me.proton.core.drive.base.presentation.extension.asHumanReadableString +import me.proton.core.drive.base.presentation.extension.isLandscape +import me.proton.core.drive.base.presentation.R as BasePresentation +import me.proton.core.presentation.R as CorePresentation +import me.proton.core.drive.i18n.R as I18N + +@Composable +fun GetMoreFreeStorageScreen( + modifier: Modifier = Modifier, + navigateBack: () -> Unit, +) { + val viewModel = hiltViewModel() + val viewState by rememberFlowWithLifecycle(viewModel.viewState).collectAsState( + initial = viewModel.initialViewState + ) + GetMoreFreeStorage( + viewState = viewState, + navigateBack = navigateBack, + modifier = modifier.fillMaxSize() + ) +} + +@Composable +fun GetMoreFreeStorage( + viewState: GetMoreFreeStorageViewState, + modifier: Modifier = Modifier, + navigateBack: () -> Unit, +) { + Column( + modifier = modifier + .navigationBarsPadding() + .fillMaxSize() + .testTag(GetMoreFreeStorage.screen), + verticalArrangement = Arrangement.Top, + ) { + TopAppBar(navigateBack = navigateBack) + Content( + viewState = viewState, + modifier = Modifier + .fillMaxSize(), + ) + } +} + +@Composable +private fun TopAppBar( + modifier: Modifier = Modifier, + navigateBack: () -> Unit, +) { + TopAppBar( + navigationIcon = painterResource(id = CorePresentation.drawable.ic_proton_close), + onNavigationIcon = navigateBack, + title = {}, + modifier = modifier.statusBarsPadding(), + ) +} + +@Composable +private fun Content( + viewState: GetMoreFreeStorageViewState, + modifier: Modifier = Modifier, +) { + if (isLandscape) { + Row( + modifier = modifier + .verticalScroll(rememberScrollState()) + .fillMaxSize() + .padding(DefaultSpacing), + ) { + TitlePart( + viewState = viewState, + modifier = Modifier + .padding(horizontal = SmallSpacing) + .weight(1f), + ) + ActionsPart( + actions = viewState.actions, + modifier = Modifier + .padding(horizontal = SmallSpacing) + .weight(1f), + ) + } + } else { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .fillMaxSize() + .padding(DefaultSpacing), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + TitlePart( + viewState = viewState, + ) + ActionsPart( + actions = viewState.actions, + ) + } + } +} + +@Composable +private fun TitlePart( + viewState: GetMoreFreeStorageViewState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(painter = painterResource(id = viewState.imageResId), contentDescription = null) + Text( + text = viewState.title, + style = ProtonTheme.typography.headlineNorm, + modifier = Modifier.padding(top = MediumSpacing) + ) + Text( + text = stringResource(id = viewState.descriptionResId), + style = ProtonTheme.typography.defaultWeak, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = SmallSpacing, bottom = DefaultSpacing) + ) + } +} + +@Composable +private fun ActionsPart( + actions: List, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + actions.forEach { action -> + ListItem(action = action) + } + } +} + +@Composable +private fun ListItem( + action: GetMoreFreeStorageViewState.Action, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = SmallSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + ActionIcon( + iconResId = action.iconResId, + iconTintColor = if (action.isDone) ProtonTheme.colors.notificationSuccess else ProtonTheme.colors.iconAccent, + bgColor = if (action.isDone) ProtonTheme.colors.notificationSuccess.copy(alpha = 0.06F) else ProtonTheme.colors.backgroundSecondary + ) + ActionDetails( + titleResId = action.titleResId, + titleTextStyle = ProtonTheme.typography.defaultHighlightNorm.copy( + color = if (action.isDone) ProtonTheme.colors.textWeak else ProtonTheme.colors.textNorm + ), + subtitle = action.getDescription(), + subtitleTextStyle = ProtonTheme.typography.defaultSmallNorm, + onSubtitleClick = action.onSubtitleClick, + ) + } +} + +@Composable +private fun ActionIcon( + iconResId: Int, + iconTintColor: Color, + bgColor: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .sizeIn(minHeight = 40.dp, minWidth = 40.dp) + .background( + color = bgColor, + shape = RoundedCornerShape(ProtonDimens.ExtraLargeCornerRadius), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = iconResId), + tint = iconTintColor, + contentDescription = null, + ) + } +} + +@Composable +private fun ActionDetails( + titleResId: Int, + titleTextStyle: TextStyle, + subtitle: AnnotatedString, + subtitleTextStyle: TextStyle, + modifier: Modifier = Modifier, + onSubtitleClick: (Int) -> Unit, +) { + Column( + modifier = modifier + .sizeIn(minHeight = 64.dp) + .padding(start = DefaultSpacing), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + ) { + Text( + text = stringResource(id = titleResId), + style = titleTextStyle, + ) + ClickableText( + text = subtitle, + style = subtitleTextStyle, + onClick = onSubtitleClick, + ) + } +} + +@Preview +@Composable +fun PreviewGetMoreFreeStorage() { + ProtonTheme { + GetMoreFreeStorage( + viewState = GetMoreFreeStorageViewState( + imageResId = BasePresentation.drawable.img_free_storage, + title = stringResource( + id = I18N.string.get_more_free_storage_title, + 5.GiB.asHumanReadableString(LocalContext.current, numberOfDecimals = 0), + ), + descriptionResId = I18N.string.get_more_free_storage_description, + actions = listOf( + GetMoreFreeStorageViewState.Action( + iconResId = CorePresentation.drawable.ic_proton_arrow_up_line, + titleResId = I18N.string.get_more_free_storage_action_upload_title, + getDescription = { + AnnotatedString( + stringResource(id = I18N.string.get_more_free_storage_action_upload_subtitle) + ) + }, + isDone = false, + ), + GetMoreFreeStorageViewState.Action( + iconResId = CorePresentation.drawable.ic_proton_checkmark, + titleResId = I18N.string.get_more_free_storage_action_link_title, + getDescription = { + AnnotatedString( + "Select any file of folder. Open the options menu and press Get link." + ) + }, + isDone = true, + ), + GetMoreFreeStorageViewState.Action( + iconResId = CorePresentation.drawable.ic_proton_key, + titleResId = I18N.string.get_more_free_storage_action_recovery_title, + getDescription = { + AnnotatedString( + text = "Sign in at account.proton.me, then go to Settings -> Recovery." + ) + }, + isDone = false, + ), + ), + ), + navigateBack = {}, + ) + } +} + +object GetMoreFreeStorage { + const val screen = "GetMoreFreeStorage screen" +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/HomeScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/HomeScreen.kt index 17351af1..8c1eba0c 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/HomeScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/HomeScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -64,6 +64,7 @@ import me.proton.core.compose.theme.ProtonTheme import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.presentation.component.BottomNavigation import me.proton.core.drive.base.presentation.component.ModalBottomSheet +import me.proton.core.drive.device.domain.entity.DeviceId import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.LinkId @@ -93,8 +94,11 @@ fun HomeScreen( navigateToParentFolderOptions: (folderId: FolderId) -> Unit, navigateToSubscription: () -> Unit, navigateToPhotosIssues: (FolderId) -> Unit, + navigateToPhotosUpsell: () -> Unit, navigateToBackupSettings: () -> Unit, navigateToPhotosPermissionRationale: () -> Unit, + navigateToComputerOptions: (deviceId: DeviceId) -> Unit, + navigateToGetMoreFreeStorage: () -> Unit, modifier: Modifier = Modifier, ) { setLocalSnackbarPadding(BottomNavigationHeight) @@ -125,7 +129,9 @@ fun HomeScreen( navigateToPhotosPermissionRationale = navigateToPhotosPermissionRationale, navigateToSubscription = navigateToSubscription, navigateToPhotosIssues = navigateToPhotosIssues, + navigateToPhotosUpsell = navigateToPhotosUpsell, navigateToBackupSettings = navigateToBackupSettings, + navigateToComputerOptions = navigateToComputerOptions, arguments = arguments, viewState = currentViewState, viewEvent = homeViewModel.viewEvent( @@ -133,7 +139,7 @@ fun HomeScreen( navigateToTrash = navigateToTrash, navigateToTab = { route -> homeNavController.navigate(route) { - popUpTo(Screen.Files.route) { inclusive = route == Screen.Files(userId) } + popUpTo(Screen.Photos.route) { inclusive = route == Screen.Photos(userId) } launchSingleTop = true } }, @@ -141,6 +147,7 @@ fun HomeScreen( navigateToSettings = navigateToSettings, navigateToBugReport = navigateToBugReport, navigateToSubscription = navigateToSubscription, + navigateToGetMoreFreeStorage = navigateToGetMoreFreeStorage, ), modifier = modifier .navigationBarsPadding() @@ -170,7 +177,9 @@ internal fun Home( navigateToPhotosPermissionRationale: () -> Unit, navigateToSubscription: () -> Unit, navigateToPhotosIssues: (FolderId) -> Unit, + navigateToPhotosUpsell: () -> Unit, navigateToBackupSettings: () -> Unit, + navigateToComputerOptions: (deviceId: DeviceId) -> Unit, ) { val homeScaffoldState = rememberHomeScaffoldState() val isDrawerOpen = with(homeScaffoldState.scaffoldState.drawerState) { @@ -262,7 +271,9 @@ internal fun Home( navigateToPhotosPermissionRationale, navigateToSubscription, navigateToPhotosIssues, + navigateToPhotosUpsell, navigateToBackupSettings, + navigateToComputerOptions, ) } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/LauncherScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/LauncherScreen.kt index 668c8140..e47a2706 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/LauncherScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/LauncherScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -22,8 +22,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,38 +39,52 @@ import me.proton.core.domain.entity.UserId @Composable @ExperimentalCoroutinesApi fun LauncherScreen( + foregroundState: State, navigateToHomeScreen: (userId: UserId) -> Unit, modifier: Modifier = Modifier, ) { val launcherViewModel = hiltViewModel() Box( - modifier = modifier.fillMaxSize() + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, ) { - Launcher(launcherViewModel, navigateToHomeScreen) + Launcher( + foregroundState = foregroundState, + viewModel = launcherViewModel, + navigateToHomeScreen = navigateToHomeScreen, + ) } } @Composable @ExperimentalCoroutinesApi internal fun Launcher( + foregroundState: State, viewModel: LauncherViewModel, navigateToHomeScreen: (userId: UserId) -> Unit, modifier: Modifier = Modifier, ) { val viewState by rememberFlowWithLifecycle(viewModel.viewState) .collectAsState(initial = LauncherViewState.initialValue) - Launcher(viewState, navigateToHomeScreen, modifier) + val foreground by foregroundState + Launcher( + foreground = foreground, + viewState = viewState, + navigateToHomeScreen = navigateToHomeScreen, + modifier = modifier, + ) } @Composable internal fun Launcher( + foreground: Boolean, viewState: LauncherViewState, navigateToHomeScreen: (userId: UserId) -> Unit, modifier: Modifier = Modifier, ) { val state = viewState.primaryAccountState - LaunchedEffect(state) { - if (state is PrimaryAccountState.SignedIn) { + LaunchedEffect(state, foreground) { + if (state is PrimaryAccountState.SignedIn && foreground) { navigateToHomeScreen(state.userId) } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt index e50e19fb..240458db 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt @@ -146,7 +146,9 @@ fun MoveToFolder( } Button( - modifier = Modifier.padding(start = SmallSpacing), + modifier = Modifier + .padding(start = SmallSpacing) + .testTag(MoveToFolderScreenTestTag.moveButton), enabled = viewState.isMoveButtonEnabled, onClick = viewEvent.move, ) { @@ -193,4 +195,5 @@ fun Title( object MoveToFolderScreenTestTag { const val screen = "move to folder screen" const val plusFolderButton = "move to folder plus folder button" + const val moveButton = "move button" } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosScreen.kt index 1ed7f37d..f3139954 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -31,10 +31,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.PagingData +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import me.proton.android.drive.photos.presentation.component.BackupPermissions import me.proton.android.drive.photos.presentation.component.Photos import me.proton.android.drive.photos.presentation.component.PhotosStatesIndicator @@ -42,6 +47,7 @@ import me.proton.android.drive.photos.presentation.state.PhotosItem import me.proton.android.drive.photos.presentation.viewevent.PhotosViewEvent import me.proton.android.drive.photos.presentation.viewstate.PhotosViewState import me.proton.android.drive.ui.effect.HandleHomeEffect +import me.proton.android.drive.ui.effect.PhotosEffect import me.proton.android.drive.ui.viewmodel.PhotosViewModel import me.proton.android.drive.ui.viewstate.HomeScaffoldState import me.proton.core.compose.flow.rememberFlowWithLifecycle @@ -64,6 +70,7 @@ fun PhotosScreen( navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit, navigateToSubscription: () -> Unit, navigateToPhotosIssues: (FolderId) -> Unit, + navigateToPhotosUpsell: () -> Unit, navigateToBackupSettings: () -> Unit, ) { val viewModel = hiltViewModel() @@ -76,12 +83,23 @@ fun PhotosScreen( navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions, navigateToSubscription = navigateToSubscription, navigateToPhotosIssues = navigateToPhotosIssues, + navigateToPhotosUpsell = navigateToPhotosUpsell, navigateToBackupSettings = navigateToBackupSettings, ) } val photos = rememberFlowWithLifecycle(flow = viewModel.driveLinks) val listEffect = rememberFlowWithLifecycle(flow = viewModel.listEffect) + LaunchedEffect(viewModel, LocalContext.current) { + viewModel.photosEffect.onEach { effect -> + when (effect) { + PhotosEffect.ShowUpsell -> launch(Dispatchers.Main) { + viewEvent.onShowUpsell() + } + } + }.launchIn(this) + } + viewModel.HandleHomeEffect(homeScaffoldState) PhotosScreen( @@ -161,6 +179,7 @@ fun TopAppBar( navigationIcon = if (viewState.navigationIconResId != 0) { painterResource(id = viewState.navigationIconResId) } else null, + notificationDotVisible = viewState.notificationDotVisible, onNavigationIcon = viewEvent.onTopAppBarNavigation, title = viewState.title, actions = actions diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosUpsellScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosUpsellScreen.kt new file mode 100644 index 00000000..d450c933 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosUpsellScreen.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import me.proton.android.drive.photos.presentation.viewevent.PhotosUpsellViewEvent +import me.proton.android.drive.ui.viewmodel.PhotosUpsellViewModel +import me.proton.core.compose.component.ProtonSolidButton +import me.proton.core.compose.component.ProtonTextButton +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.drive.base.presentation.common.getThemeDrawableId +import me.proton.core.drive.base.presentation.component.IllustratedMessage +import me.proton.core.drive.base.presentation.component.RunAction +import me.proton.core.drive.base.presentation.extension.conditional +import me.proton.core.drive.base.presentation.extension.isLandscape +import me.proton.core.drive.base.presentation.extension.isPortrait +import me.proton.core.drive.base.presentation.R as BasePresentation +import me.proton.core.drive.i18n.R as I18N + +@Composable +fun PhotosUpsellScreen( + modifier: Modifier = Modifier, + runAction: RunAction, + navigateToSubscription: () -> Unit, +) { + val viewModel = hiltViewModel() + val viewEvent = remember { + viewModel.viewEvent( + runAction = runAction, + navigateToSubscription = navigateToSubscription, + ) + } + DisposableEffect(Unit){ + onDispose { + viewEvent.onDismiss() + } + } + PhotosUpsell( + viewEvent = viewEvent, + modifier = modifier + .systemBarsPadding(), + ) +} + +@Composable +fun PhotosUpsell( + viewEvent: PhotosUpsellViewEvent, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = ProtonDimens.DefaultSpacing), + verticalArrangement = Arrangement.spacedBy(ProtonDimens.DefaultSpacing), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IllustratedMessage( + imageResId = getThemeDrawableId( + light = BasePresentation.drawable.img_upsell_drive_light, + dark = BasePresentation.drawable.img_upsell_drive_dark, + dayNight = BasePresentation.drawable.img_upsell_drive_daynight, + ), + titleResId = I18N.string.photos_upsell_title, + descriptionResId = I18N.string.photos_upsell_description, + ) + val buttonModifier = Modifier + .conditional(isPortrait) { + fillMaxWidth() + } + .conditional(isLandscape) { + widthIn(min = ButtonMinWidth) + } + .heightIn(min = ProtonDimens.ListItemHeight) + ProtonSolidButton( + onClick = { viewEvent.onMoreStorage() }, + modifier = buttonModifier, + ) { + Text( + text = stringResource(id = I18N.string.photos_upsell_get_storage_action), + modifier = Modifier.padding(horizontal = ProtonDimens.DefaultSpacing) + ) + } + ProtonTextButton( + onClick = { viewEvent.onCancel() }, + modifier = buttonModifier + ) { + Text( + text = stringResource(id = I18N.string.photos_upsell_dismiss_action), + modifier = Modifier.padding(horizontal = ProtonDimens.DefaultSpacing) + ) + } + } +} + +private val ButtonMinWidth = 300.dp + +@Preview +@Preview(widthDp = 600, heightDp = 360) +@Composable +private fun PhotosUpsellPreview() { + ProtonTheme { + PhotosUpsell( + viewEvent = object : PhotosUpsellViewEvent {}, + modifier = Modifier.fillMaxSize(), + ) + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/SyncedFoldersScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/SyncedFoldersScreen.kt index fb9bfff4..166d32ff 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/SyncedFoldersScreen.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/SyncedFoldersScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.hilt.navigation.compose.hiltViewModel import me.proton.android.drive.ui.effect.HandleHomeEffect @@ -62,7 +63,9 @@ fun SyncedFoldersScreen( driveLinks = PagingList(viewModel.driveLinks, viewModel.listEffect), viewState = viewState, viewEvent = viewEvent, - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .testTag(SyncedFoldersTestTag.screen), onRefresh = viewModel::refresh, ) } @@ -114,3 +117,7 @@ private fun TopAppBar( isTitleEncrypted = viewState.isTitleEncrypted, ) } + +object SyncedFoldersTestTag { + const val screen = "computer synced folders screen" +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/ComputersViewEvent.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/ComputersViewEvent.kt index 28f928ee..c270ce3e 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/ComputersViewEvent.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/ComputersViewEvent.kt @@ -24,4 +24,5 @@ interface ComputersViewEvent { val onTopAppBarNavigation: () -> Unit get() = {} val onDevice: (Device) -> Unit get() = { _ -> } val onRefresh: () -> Unit get() = {} + val onMoreOptions: (Device) -> Unit get() = { _ -> } } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputerOptionsViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputerOptionsViewModel.kt new file mode 100644 index 00000000..7f60d044 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputerOptionsViewModel.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import me.proton.core.domain.arch.mapSuccessValueOrNull +import me.proton.core.drive.base.presentation.extension.require +import me.proton.core.drive.base.presentation.viewmodel.UserViewModel +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.drivelink.device.domain.usecase.GetDecryptedDevice +import me.proton.core.drive.drivelink.device.presentation.options.DeviceOptionEntry +import me.proton.core.drive.drivelink.device.presentation.options.RenameDeviceOption +import me.proton.core.drive.link.domain.entity.FolderId +import javax.inject.Inject + +@HiltViewModel +class ComputerOptionsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + getDecryptedDevice: GetDecryptedDevice, +) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) { + private val deviceId = DeviceId(savedStateHandle.require(KEY_DEVICE_ID)) + val device: Flow = getDecryptedDevice( + userId = userId, + deviceId = deviceId, + ) + .mapSuccessValueOrNull() + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun entries( + runAction: (suspend () -> Unit) -> Unit, + navigateToRenameComputer: (DeviceId, FolderId) -> Unit, + ): List = listOf( + RenameDeviceOption { device -> + runAction { navigateToRenameComputer(device.id, device.rootLinkId) } + } + ) + + companion object { + const val KEY_DEVICE_ID = "deviceId" + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModel.kt index e2c35c96..e9ec41de 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModel.kt @@ -45,12 +45,15 @@ import me.proton.core.drive.base.domain.usecase.BroadcastMessages import me.proton.core.drive.base.presentation.common.getThemeDrawableId import me.proton.core.drive.base.presentation.viewmodel.UserViewModel import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.extension.name import me.proton.core.drive.device.domain.usecase.RefreshDevices import me.proton.core.drive.drivelink.device.domain.usecase.GetDecryptedDevicesSortedByName import me.proton.core.drive.files.presentation.state.ListContentState import me.proton.core.drive.i18n.R import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage +import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage import javax.inject.Inject import me.proton.core.drive.drivelink.device.presentation.R as DriveLinkDevicePresentation import me.proton.core.drive.i18n.R as I18N @@ -63,7 +66,11 @@ class ComputersViewModel @Inject constructor( private val refreshDevices: RefreshDevices, private val broadcastMessages: BroadcastMessages, private val configurationProvider: ConfigurationProvider, -) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle), HomeTabViewModel { + shouldUpgradeStorage: ShouldUpgradeStorage, +) : ViewModel(), + UserViewModel by UserViewModel(savedStateHandle), + HomeTabViewModel, + NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) { private val _homeEffect = MutableSharedFlow() override val homeEffect: Flow @@ -85,6 +92,7 @@ class ComputersViewModel @Inject constructor( val initialViewState = ComputersViewState( title = appContext.getString(R.string.computers_title), navigationIconResId = me.proton.core.presentation.R.drawable.ic_proton_hamburger, + notificationDotVisible = false, listContentState = listContentState.value, isRefreshEnabled = listContentState.value != ListContentState.Loading ) @@ -92,8 +100,10 @@ class ComputersViewModel @Inject constructor( val viewState: Flow = combine( listContentState, isRefreshing, - ) { state, refreshing -> + notificationDotRequested + ) { state, refreshing, notificationDotRequested -> initialViewState.copy( + notificationDotVisible = notificationDotRequested, listContentState = when (state) { is ListContentState.Content -> state.copy(isRefreshing = refreshing) is ListContentState.Empty -> state.copy(isRefreshing = refreshing) @@ -134,6 +144,7 @@ class ComputersViewModel @Inject constructor( fun viewEvent( navigateToSyncedFolders: (FolderId, String?) -> Unit, + navigateToComputerOptions: (deviceId: DeviceId) -> Unit, ): ComputersViewEvent = object : ComputersViewEvent { override val onTopAppBarNavigation = { @@ -146,6 +157,10 @@ class ComputersViewModel @Inject constructor( navigateToSyncedFolders(device.rootLinkId, name) } + override val onMoreOptions = { device: Device -> + navigateToComputerOptions(device.id) + } + override val onRefresh = { viewModelScope.launch { isRefreshing.value = true diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt index 40f8f235..d96077f8 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt @@ -84,6 +84,7 @@ import me.proton.core.drive.sorting.domain.entity.Sorting import me.proton.core.drive.sorting.domain.usecase.GetSorting import me.proton.core.drive.upload.domain.usecase.CancelUploadFile import me.proton.core.drive.upload.domain.usecase.GetUploadProgress +import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage import me.proton.core.util.kotlin.CoreLogger import me.proton.drive.android.settings.domain.entity.LayoutType import me.proton.drive.android.settings.domain.usecase.GetLayoutType @@ -115,7 +116,10 @@ class FilesViewModel @Inject constructor( savedStateHandle: SavedStateHandle, getSorting: GetSorting, private val configurationProvider: ConfigurationProvider, -) : SelectionViewModel(savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks), HomeTabViewModel { + shouldUpgradeStorage: ShouldUpgradeStorage, +) : SelectionViewModel(savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks), + HomeTabViewModel, + NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) { private val shareId = savedStateHandle.get(Screen.Files.SHARE_ID) private val folderId = savedStateHandle.get(Screen.Files.FOLDER_ID)?.let { folderId -> @@ -196,7 +200,8 @@ class FilesViewModel @Inject constructor( listContentAppendingState, layoutType, selected, - ) { driveLink, sorting, contentState, appendingState, layoutType, selected -> + notificationDotRequested + ) { driveLink, sorting, contentState, appendingState, layoutType, selected, notificationDotRequested -> val listContentState = when (contentState) { is ListContentState.Empty -> contentState.copy( imageResId = emptyStateImageResId, @@ -208,6 +213,7 @@ class FilesViewModel @Inject constructor( } else { topBarActions.value = setOf(selectAllAction, selectedOptionsAction) } + val showHamburgerMenuIcon = isRootFolder && selected.isEmpty() initialViewState.copy( title = if (selected.isNotEmpty()) { appContext.quantityString( @@ -220,7 +226,7 @@ class FilesViewModel @Inject constructor( } }, isTitleEncrypted = selected.isEmpty() && isRootFolder.not() && driveLink.isNameEncrypted, - navigationIconResId = if (isRootFolder && selected.isEmpty()) { + navigationIconResId = if (showHamburgerMenuIcon) { CorePresentation.drawable.ic_proton_hamburger } else if (selected.isNotEmpty()) { CorePresentation.drawable.ic_proton_cross @@ -233,6 +239,7 @@ class FilesViewModel @Inject constructor( isGrid = layoutType == LayoutType.GRID, isRefreshEnabled = selected.isEmpty(), drawerGesturesEnabled = isRootFolder && selected.isEmpty(), + notificationDotVisible = showHamburgerMenuIcon && notificationDotRequested ) } val listEffect: Flow diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/GetMoreFreeStorageViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/GetMoreFreeStorageViewModel.kt new file mode 100644 index 00000000..2b9abd0c --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/GetMoreFreeStorageViewModel.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewmodel + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.transform +import me.proton.android.drive.ui.viewstate.GetMoreFreeStorageViewState +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.domain.arch.DataResult +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.usecase.BroadcastMessages +import me.proton.core.drive.base.presentation.extension.asHumanReadableString +import me.proton.core.drive.base.presentation.viewmodel.UserViewModel +import me.proton.core.drive.folder.domain.usecase.HasAnyCachedFolderChildren +import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage +import me.proton.core.drive.share.domain.entity.Share +import me.proton.core.drive.share.domain.usecase.GetShares +import javax.inject.Inject +import me.proton.core.drive.base.presentation.R as BasePresentation +import me.proton.core.drive.i18n.R as I18N +import me.proton.core.presentation.R as CorePresentation + +@HiltViewModel +class GetMoreFreeStorageViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + configurationProvider: ConfigurationProvider, + getShares: GetShares, + private val hasAnyCachedFolderChildren: HasAnyCachedFolderChildren, + private val broadcastMessages: BroadcastMessages, + savedStateHandle: SavedStateHandle, +) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) { + private val uploadAFile = GetMoreFreeStorageViewState.Action( + iconResId = uploadAFileIconResId(false), + titleResId = I18N.string.get_more_free_storage_action_upload_title, + getDescription = { AnnotatedString(appContext.getString(I18N.string.get_more_free_storage_action_upload_subtitle)) }, + isDone = false, + ) + + private val createAShareLink = GetMoreFreeStorageViewState.Action( + iconResId = createAShareLinkIconResId(false), + titleResId = I18N.string.get_more_free_storage_action_link_title, + getDescription = { createAShareLinkDescription }, + isDone = false, + ) + + private val setARecoveryMethod = GetMoreFreeStorageViewState.Action( + iconResId = setARecoveryMethodIconResId(false), + titleResId = I18N.string.get_more_free_storage_action_recovery_title, + getDescription = { setARecoveryMethodDescription(ProtonTheme.colors.textAccent) }, + isDone = false, + onSubtitleClick = ::onSetRecoverMethodSubtitleClick + ) + + val initialViewState = GetMoreFreeStorageViewState( + imageResId = BasePresentation.drawable.img_free_storage, + title = appContext.getString( + I18N.string.get_more_free_storage_title, + configurationProvider.maxFreeSpace.asHumanReadableString(appContext, numberOfDecimals = 0), + ), + descriptionResId = I18N.string.get_more_free_storage_description, + actions = listOf(uploadAFile, createAShareLink, setARecoveryMethod), + ) + + val viewState: Flow = getShares(userId, Share.Type.STANDARD, flowOf(false)) + .distinctUntilChanged() + .transform { result -> + when (result) { + is DataResult.Success -> emit( + initialViewState.copy( + actions = listOf( + hasAnyCachedFolderChildren(userId, filesOnly = true).let { + uploadAFile.copy( + iconResId = uploadAFileIconResId(false), + isDone = false, + ) + }, + result.value.isNotEmpty().let { + createAShareLink.copy( + iconResId = createAShareLinkIconResId(false), + isDone = false, + ) + }, + setARecoveryMethod, + ) + ) + ) + else -> Unit + } + + } + + private val isDoneIconResId = CorePresentation.drawable.ic_proton_checkmark + + @Suppress("SameParameterValue") + private fun uploadAFileIconResId(isDone: Boolean): Int = + if (isDone) isDoneIconResId else CorePresentation.drawable.ic_proton_arrow_up_line + + @Suppress("SameParameterValue") + private fun createAShareLinkIconResId(isDone: Boolean): Int = + if (isDone) isDoneIconResId else CorePresentation.drawable.ic_proton_link + + @Suppress("SameParameterValue") + private fun setARecoveryMethodIconResId(isDone: Boolean): Int = + if (isDone) isDoneIconResId else CorePresentation.drawable.ic_proton_key + + private val createAShareLinkDescription: AnnotatedString get() { + val getLink = appContext.getString(I18N.string.common_get_link_action) + val description = appContext.getString(I18N.string.get_more_free_storage_action_link_subtitle).format(getLink) + val start = description.indexOf(getLink) + val spanStyles = listOf( + AnnotatedString.Range( + SpanStyle(fontWeight = FontWeight.Bold), + start = start, + end = start + getLink.length + ) + ) + return AnnotatedString(text = description, spanStyles = spanStyles) + } + + private fun setARecoveryMethodDescription(linkColor: Color): AnnotatedString { + val urlString = appContext.getString(I18N.string.get_more_free_storage_action_recovery_subtitle_url_string) + val description = appContext + .getString(I18N.string.get_more_free_storage_action_recovery_subtitle) + .format(urlString) + val start = description.indexOf(urlString) + val spanStyles = listOf( + AnnotatedString.Range( + SpanStyle(color = linkColor), + start = start, + end = start + urlString.length + ) + ) + return AnnotatedString(text = description, spanStyles = spanStyles) + } + + private fun onSetRecoverMethodSubtitleClick(offset: Int) { + val urlString = appContext.getString(I18N.string.get_more_free_storage_action_recovery_subtitle_url_string) + val description = appContext + .getString(I18N.string.get_more_free_storage_action_recovery_subtitle) + .format(urlString) + val start = description.indexOf(urlString) + val linkRange = IntRange( + start = start, + endInclusive = start + urlString.length, + ) + if (offset in linkRange) { + try { + val url = appContext + .getString(I18N.string.get_more_free_storage_action_recovery_subtitle_url) + .format(urlString) + appContext.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (ignored: ActivityNotFoundException) { + broadcastMessages( + userId = userId, + message = appContext.getString(I18N.string.common_error_no_browser_available), + type = BroadcastMessage.Type.ERROR, + ) + } + } + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/HomeViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/HomeViewModel.kt index 5f6cca26..a90e00a9 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/HomeViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/HomeViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -29,12 +29,14 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.shareIn import me.proton.android.drive.BuildConfig import me.proton.android.drive.ui.navigation.HomeTab import me.proton.android.drive.ui.navigation.Screen import me.proton.android.drive.ui.viewevent.HomeViewEvent import me.proton.android.drive.ui.viewstate.HomeViewState +import me.proton.android.drive.usecase.CanGetMoreFreeStorage import me.proton.core.domain.entity.SessionUserId import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.presentation.component.NavigationTab @@ -53,6 +55,7 @@ class HomeViewModel @Inject constructor( userManager: UserManager, savedStateHandle: SavedStateHandle, configurationProvider: ConfigurationProvider, + private val canGetMoreFreeStorage: CanGetMoreFreeStorage, ) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) { private val tabs: StateFlow> = MutableStateFlow( @@ -78,7 +81,7 @@ class HomeViewModel @Inject constructor( ).associateBy({ tab -> tab.first }, { tab -> tab.second }) ) - private val currentDestination = MutableStateFlow(Screen.Files.route) + private val currentDestination = MutableStateFlow(null) fun setCurrentDestination(route: String) { currentDestination.value = route } @@ -86,7 +89,7 @@ class HomeViewModel @Inject constructor( val viewState: Flow = combine( userManager.observeUser(SessionUserId(userId.id)), - currentDestination, + currentDestination.filterNotNull(), tabs.filter { tabs -> tabs.isNotEmpty() }, ) { user, selectedScreen, tabs -> getViewState(user, selectedScreen, tabs) @@ -100,6 +103,7 @@ class HomeViewModel @Inject constructor( navigateToSettings: () -> Unit, navigateToBugReport: () -> Unit, navigateToSubscription: () -> Unit, + navigateToGetMoreFreeStorage: () -> Unit, ): HomeViewEvent = object : HomeViewEvent { override val onTab = { tab: NavigationTab -> navigateToTab(tab.screen(userId)) } override val navigationDrawerViewEvent: NavigationDrawerViewEvent = @@ -111,13 +115,14 @@ class HomeViewModel @Inject constructor( override val onSignOut = navigateToSigningOut override val onBugReport = navigateToBugReport override val onSubscription = navigateToSubscription + override val onGetFreeStorage = navigateToGetMoreFreeStorage } } private val NavigationTab.screen: HomeTab get() = tabs.value.firstNotNullOf { (screen, value) -> screen.takeIf { value == this } } - private fun getViewState( + private suspend fun getViewState( user: User?, startDestination: String, tabs: Map, @@ -130,7 +135,8 @@ class HomeViewModel @Inject constructor( navigationDrawerViewState = NavigationDrawerViewState( I18N.string.app_name, BuildConfig.VERSION_NAME, - currentUser = user + currentUser = user, + showGetFreeStorage = user?.let { canGetMoreFreeStorage(user) } ?: false, ) ) } diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/MoveToFolderViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/MoveToFolderViewModel.kt index 8d26baad..c2d2c2ac 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/MoveToFolderViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/MoveToFolderViewModel.kt @@ -29,6 +29,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest @@ -50,7 +52,10 @@ import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.selection.domain.entity.SelectionId import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks +import me.proton.core.drive.share.domain.entity.Share import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.share.domain.extension.isDevice +import me.proton.core.drive.share.domain.usecase.GetShare import me.proton.core.drive.sorting.domain.entity.Sorting import me.proton.core.util.kotlin.CoreLogger import javax.inject.Inject @@ -64,6 +69,7 @@ class MoveToFolderViewModel @Inject constructor( getDriveLink: GetDecryptedDriveLink, getPagedDriveLinks: GetPagedDriveLinksList, getSelectedDriveLinks: GetSelectedDriveLinks, + getShare: GetShare, private val moveFile: MoveFile, private val deselectLinks: DeselectLinks, private val decryptDriveLinks: DecryptDriveLinks, @@ -85,6 +91,12 @@ class MoveToFolderViewModel @Inject constructor( } } } + private val shareFlow: StateFlow = + (shareId?.let { + getShare(shareId, flowOf(false)) + .mapSuccessValueOrNull() + } ?: flowOf(null)) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val driveLinksToMove: StateFlow> = if (selectionId != null) { getSelectedDriveLinks(selectionId) @@ -107,16 +119,21 @@ class MoveToFolderViewModel @Inject constructor( driveLinksToMove, parentLink, listContentState, - listContentAppendingState - ) { driveLinksToMove, parentLink, contentState, appendingState -> + listContentAppendingState, + shareFlow.filterNotNull(), + ) { driveLinksToMove, parentLink, contentState, appendingState, share -> val isRoot = parentLink != null && parentLink.parentId == null initialViewState.copy( filesViewState = initialViewState.filesViewState.copy( listContentState = contentState, listContentAppendingState = appendingState ), - isMoveButtonEnabled = parentLink?.id != parentId, - title = if (isRoot) appContext.getString(I18N.string.title_my_files) else parentLink?.name.orEmpty(), + isMoveButtonEnabled = (parentLink?.id != parentId) && !(isRoot && share.isDevice), + title = if (isRoot && share.isMain) { + appContext.getString(I18N.string.title_my_files) + } else { + parentLink?.name.orEmpty() + }, isTitleEncrypted = parentLink?.isNameEncrypted ?: false, navigationIconResId = if (parentLink == null || isRoot) { CorePresentation.drawable.ic_proton_cross diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/NotificationDotViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/NotificationDotViewModel.kt new file mode 100644 index 00000000..2161d9fd --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/NotificationDotViewModel.kt @@ -0,0 +1,18 @@ +package me.proton.android.drive.ui.viewmodel + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import me.proton.android.drive.extension.asBoolean +import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage + +interface NotificationDotViewModel { + val notificationDotRequested: Flow + + companion object { + operator fun invoke(shouldUpgradeStorage: ShouldUpgradeStorage) = + object : NotificationDotViewModel { + override val notificationDotRequested: Flow = + shouldUpgradeStorage().map { it.asBoolean } + } + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosBackupViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosBackupViewModel.kt index d710f6f4..9bf340ac 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosBackupViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosBackupViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import me.proton.android.drive.extension.getDefaultMessage import me.proton.android.drive.photos.domain.entity.PhotoBackupState import me.proton.android.drive.photos.domain.usecase.GetPhotosConfiguration import me.proton.android.drive.photos.domain.usecase.GetPhotosDriveLink @@ -38,8 +39,8 @@ import me.proton.android.drive.ui.viewevent.PhotosBackupViewEvent import me.proton.android.drive.ui.viewstate.PhotosBackupOption import me.proton.android.drive.ui.viewstate.PhotosBackupViewState import me.proton.core.drive.backup.domain.entity.BackupNetworkType -import me.proton.core.drive.base.data.extension.getDefaultMessage import me.proton.core.drive.base.data.extension.log +import me.proton.core.drive.base.domain.exception.DriveException import me.proton.core.drive.base.domain.extension.firstSuccessOrError import me.proton.core.drive.base.domain.extension.toResult import me.proton.core.drive.base.domain.log.LogTag.BACKUP @@ -124,9 +125,14 @@ class PhotosBackupViewModel @Inject constructor( } }.onFailure { error -> error.log(BACKUP) + val userError = if (error.cause is DriveException) { + error.cause as DriveException + } else { + error + } broadcastMessages( userId = userId, - message = error.getDefaultMessage( + message = userError.getDefaultMessage( appContext, configurationProvider.useExceptionMessage ), diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosUpsellViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosUpsellViewModel.kt new file mode 100644 index 00000000..de7a8a31 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosUpsellViewModel.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import me.proton.android.drive.photos.presentation.viewevent.PhotosUpsellViewEvent +import me.proton.core.drive.base.data.extension.log +import me.proton.core.drive.base.domain.log.LogTag.PHOTO +import me.proton.core.drive.base.presentation.component.RunAction +import me.proton.core.drive.base.presentation.viewmodel.UserViewModel +import me.proton.core.drive.telemetry.domain.event.PhotosEvent.UpsellPhotosAccepted +import me.proton.core.drive.telemetry.domain.event.PhotosEvent.UpsellPhotosDeclined +import me.proton.core.drive.telemetry.domain.manager.DriveTelemetryManager +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.usecase.CancelUserMessage +import javax.inject.Inject + +@HiltViewModel +class PhotosUpsellViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val cancelUserMessage: CancelUserMessage, + private val driveTelemetryManager: DriveTelemetryManager, +) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) { + + fun viewEvent( + runAction: RunAction, + navigateToSubscription: () -> Unit, + ): PhotosUpsellViewEvent = object : PhotosUpsellViewEvent { + override val onMoreStorage = { + runAction { + navigateToSubscription() + driveTelemetryManager.enqueue(userId, UpsellPhotosAccepted()) + } + } + override val onCancel = { + runAction { + driveTelemetryManager.enqueue(userId, UpsellPhotosDeclined()) + } + } + override val onDismiss = { + viewModelScope.launch { + cancelUserMessage(userId, UserMessage.UPSELL_PHOTOS).onFailure { error -> + error.log(PHOTO, "Cannot cancel upsell photos message") + } + } + Unit + } + } +} diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosViewModel.kt index e9c7da05..1f66ff6b 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PhotosViewModel.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.transformLatest @@ -50,6 +51,7 @@ import kotlinx.coroutines.launch import me.proton.android.drive.photos.domain.entity.PhotoBackupState import me.proton.android.drive.photos.domain.usecase.EnablePhotosBackup import me.proton.android.drive.photos.domain.usecase.GetPhotosDriveLink +import me.proton.android.drive.photos.domain.usecase.ShowUpsell import me.proton.android.drive.photos.presentation.R import me.proton.android.drive.photos.presentation.state.PhotosItem import me.proton.android.drive.photos.presentation.viewevent.PhotosViewEvent @@ -60,12 +62,14 @@ import me.proton.android.drive.photos.presentation.viewstate.PhotosViewState import me.proton.android.drive.ui.common.onClick import me.proton.android.drive.ui.effect.HomeEffect import me.proton.android.drive.ui.effect.HomeTabViewModel +import me.proton.android.drive.ui.effect.PhotosEffect import me.proton.android.drive.usecase.OnFilesDriveLinkError import me.proton.core.domain.arch.onSuccess import me.proton.core.drive.backup.domain.entity.BackupPermissions import me.proton.core.drive.backup.domain.entity.BackupStatus import me.proton.core.drive.backup.domain.manager.BackupPermissionsManager import me.proton.core.drive.backup.domain.usecase.GetBackupState +import me.proton.core.drive.backup.domain.usecase.GetDisabledBackupState import me.proton.core.drive.backup.domain.usecase.RetryBackup import me.proton.core.drive.backup.domain.usecase.SyncFolders import me.proton.core.drive.base.data.extension.getDefaultMessage @@ -75,6 +79,7 @@ import me.proton.core.drive.base.domain.extension.filterSuccessOrError import me.proton.core.drive.base.domain.extension.mapWithPrevious import me.proton.core.drive.base.domain.extension.onFailure import me.proton.core.drive.base.domain.log.LogTag.BACKUP +import me.proton.core.drive.base.domain.log.LogTag.PHOTO import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL import me.proton.core.drive.base.domain.log.logId import me.proton.core.drive.base.domain.provider.ConfigurationProvider @@ -102,6 +107,9 @@ import me.proton.core.drive.linkupload.domain.entity.UploadFileLink.Companion.RE import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage import me.proton.core.drive.photo.domain.usecase.GetPhotoCount import me.proton.core.drive.share.domain.entity.Share +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.usecase.CancelUserMessage +import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage import me.proton.core.util.kotlin.CoreLogger import java.util.Calendar import javax.inject.Inject @@ -125,7 +133,9 @@ class PhotosViewModel @Inject constructor( private val configurationProvider: ConfigurationProvider, private val broadcastMessages: BroadcastMessages, getBackupState: GetBackupState, + getDisabledBackupState: GetDisabledBackupState, getPhotoCount: GetPhotoCount, + showUpsell: ShowUpsell, getSelectedDriveLinks: GetSelectedDriveLinks, selectAll: SelectAll, selectLinks: SelectLinks, @@ -134,9 +144,11 @@ class PhotosViewModel @Inject constructor( private val photoDriveLinks: PhotoDriveLinks, private val onFilesDriveLinkError: OnFilesDriveLinkError, private val syncFolders: SyncFolders, -) : SelectionViewModel( - savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks -), HomeTabViewModel { + private val cancelUserMessage: CancelUserMessage, + shouldUpgradeStorage: ShouldUpgradeStorage, +) : SelectionViewModel(savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks), + HomeTabViewModel, + NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) { private var viewEvent: PhotosViewEvent? = null private var fetchingJob: Job? = null @@ -150,6 +162,18 @@ class PhotosViewModel @Inject constructor( private val firstVisibleItemIndex = MutableStateFlow(null) private val forceStatusExpand = MutableStateFlow(false) + private val _photosEffect = MutableSharedFlow() + private val photosEffectShowUpsell = showUpsell(userId).transformLatest { show -> + if (show) { + CoreLogger.d(PHOTO, "photosEffectShowUpsell") + emit(PhotosEffect.ShowUpsell) + } + } + val photosEffect: Flow = merge( + _photosEffect.asSharedFlow(), + photosEffectShowUpsell, + ) + val initialViewState = PhotosViewState( title = appContext.getString(I18N.string.photos_title), navigationIconResId = CorePresentation.drawable.ic_proton_hamburger, @@ -263,8 +287,12 @@ class PhotosViewModel @Inject constructor( dayNight = R.drawable.empty_photos_daynight, ) - private val backupState = parentFolderId.filterNotNull().flatMapLatest { folderId -> - getBackupState(folderId = folderId) + private val backupState = parentFolderId.flatMapLatest { folderId -> + if (folderId == null) { + getDisabledBackupState() + } else { + getBackupState(folderId = folderId) + } } val viewState: Flow = combine( @@ -274,7 +302,8 @@ class PhotosViewModel @Inject constructor( getPhotoCount(userId = userId), firstVisibleItemIndex, forceStatusExpand, - ) { selected, contentState, backupState, count, firstVisibleItemIndex, forceStatusExpand -> + notificationDotRequested + ) { selected, contentState, backupState, count, firstVisibleItemIndex, forceStatusExpand, notificationDotRequested -> val listContentState = when (contentState) { is ListContentState.Empty -> contentState.copy( imageResId = emptyStateImageResId, @@ -292,6 +321,7 @@ class PhotosViewModel @Inject constructor( val showPhotosStateIndicator = selected.isEmpty() && ((firstVisibleItemIndex?.let { index -> index > 0 } ?: false) || !isDisableOrRunning) + val showHamburgerMenuIcon = selected.isEmpty() initialViewState.copy( title = if (selected.isNotEmpty()) { appContext.quantityString( @@ -301,11 +331,12 @@ class PhotosViewModel @Inject constructor( } else { appContext.getString(I18N.string.photos_title) }, - navigationIconResId = if (selected.isNotEmpty()) { - CorePresentation.drawable.ic_proton_cross - } else { + navigationIconResId = if (showHamburgerMenuIcon) { CorePresentation.drawable.ic_proton_hamburger + } else { + CorePresentation.drawable.ic_proton_cross }, + notificationDotVisible = showHamburgerMenuIcon && notificationDotRequested, listContentState = listContentState, showEmptyList = backupState.isBackupEnabled || backupState.hasDefaultFolder == false , showPhotosStateIndicator = showPhotosStateIndicator, @@ -324,6 +355,7 @@ class PhotosViewModel @Inject constructor( navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit, navigateToSubscription: () -> Unit, navigateToPhotosIssues: (FolderId) -> Unit, + navigateToPhotosUpsell: () -> Unit, navigateToBackupSettings: () -> Unit, ): PhotosViewEvent = object : PhotosViewEvent { @@ -378,11 +410,15 @@ class PhotosViewModel @Inject constructor( override val onIgnoreBackgroundRestrictions: (Context) -> Unit = {context -> context.launchIgnoreBatteryOptimizations() } + override val onDismissBackgroundRestrictions: () -> Unit = { + dismissBackgroundRestrictions() + } override val onResolve: () -> Unit = { parentFolderId.value?.let { folderId -> navigateToPhotosIssues(folderId) } } + override val onShowUpsell = navigateToPhotosUpsell }.also { viewEvent -> this.viewEvent = viewEvent } @@ -488,6 +524,14 @@ class PhotosViewModel @Inject constructor( } } + private fun dismissBackgroundRestrictions() { + viewModelScope.launch { + cancelUserMessage(userId, UserMessage.BACKUP_BATTERY_SETTINGS).onFailure { error -> + error.log(BACKUP, "Cannot dismiss battery settings warning") + } + } + } + private fun BackupStatus.isRunning(): Boolean = when (this) { is BackupStatus.Complete -> totalBackupPhotos > 0 else -> true diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PreviewViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PreviewViewModel.kt index 95b3a2b8..7695c982 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PreviewViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/PreviewViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -592,7 +592,7 @@ class PhotoContentProvider( hasThumbnail = false, activeRevisionId = "", xAttr = null, - shareUrlId = null, + sharingDetails = null, contentKeyPacket = "", contentKeyPacketSignature = null, photoCaptureTime = captureTime, diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SharedViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SharedViewModel.kt index dd11281a..d29f5607 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SharedViewModel.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/SharedViewModel.kt @@ -75,6 +75,7 @@ import me.proton.core.drive.share.domain.usecase.GetMainShare import me.proton.core.drive.share.domain.usecase.GetShare import me.proton.core.drive.sorting.domain.entity.Sorting import me.proton.core.drive.sorting.domain.usecase.GetSorting +import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage import me.proton.drive.android.settings.domain.entity.LayoutType import me.proton.drive.android.settings.domain.usecase.GetLayoutType import me.proton.drive.android.settings.domain.usecase.GetThemeStyle @@ -100,7 +101,12 @@ class SharedViewModel @Inject constructor( private val configurationProvider: ConfigurationProvider, private val getShare: GetShare, private val getThemeStyle: GetThemeStyle, -) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle), HomeTabViewModel { + shouldUpgradeStorage: ShouldUpgradeStorage, +) : ViewModel(), + UserViewModel by UserViewModel(savedStateHandle), + HomeTabViewModel, + NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) { + private val _effects = MutableSharedFlow() private val refreshTrigger = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } private val volumeId = refreshTrigger.transformLatest { @@ -149,7 +155,8 @@ class SharedViewModel @Inject constructor( driveLinks, layoutType, getThemeStyle(userId), - ) { sorting, driveLinks, layoutType, _ -> + notificationDotRequested, + ) { sorting, driveLinks, layoutType, _, notificationDotRequested -> val listContentState = when (val contentState = driveLinks.toListContentState()) { is ListContentState.Empty -> contentState.copy( imageResId = emptyStateImageResId, @@ -161,6 +168,7 @@ class SharedViewModel @Inject constructor( sorting = sorting, listContentState = listContentState, isGrid = layoutType == LayoutType.GRID, + notificationDotVisible = notificationDotRequested ) ) }.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/ComputersViewState.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/ComputersViewState.kt index fbc87638..166ca67b 100644 --- a/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/ComputersViewState.kt +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/ComputersViewState.kt @@ -24,6 +24,7 @@ import me.proton.core.drive.files.presentation.state.ListContentState data class ComputersViewState( val title: String, @DrawableRes val navigationIconResId: Int, + val notificationDotVisible: Boolean, val listContentState: ListContentState, val isRefreshEnabled: Boolean, ) diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/GetMoreFreeStorageViewState.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/GetMoreFreeStorageViewState.kt new file mode 100644 index 00000000..9c355369 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewstate/GetMoreFreeStorageViewState.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewstate + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.AnnotatedString + +@Immutable +data class GetMoreFreeStorageViewState( + @DrawableRes val imageResId: Int, + val title: String, + @StringRes val descriptionResId: Int, + val actions: List, +) { + data class Action( + @DrawableRes val iconResId: Int, + @StringRes val titleResId: Int, + val getDescription: @Composable () -> AnnotatedString, + val isDone: Boolean, + val onSubtitleClick: (Int) -> Unit = {}, + ) +} diff --git a/app/src/main/kotlin/me/proton/android/drive/usecase/CanGetMoreFreeStorage.kt b/app/src/main/kotlin/me/proton/android/drive/usecase/CanGetMoreFreeStorage.kt new file mode 100644 index 00000000..c3ad6e83 --- /dev/null +++ b/app/src/main/kotlin/me/proton/android/drive/usecase/CanGetMoreFreeStorage.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.usecase + +import me.proton.core.drive.base.domain.extension.bytes +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.user.domain.extension.isFree +import me.proton.core.user.domain.entity.User +import javax.inject.Inject + +class CanGetMoreFreeStorage @Inject constructor( + private val configurationProvider: ConfigurationProvider, +) { + + operator fun invoke(user: User): Boolean = with (user) { + isFree && isNotUsingMaxFreeSpace + } + + private val User.isNotUsingMaxFreeSpace get() = maxSpace.bytes < configurationProvider.maxFreeSpace +} diff --git a/app/src/main/kotlin/me/proton/android/drive/usecase/ProcessIntent.kt b/app/src/main/kotlin/me/proton/android/drive/usecase/ProcessIntent.kt index 573512bf..b7e861a5 100644 --- a/app/src/main/kotlin/me/proton/android/drive/usecase/ProcessIntent.kt +++ b/app/src/main/kotlin/me/proton/android/drive/usecase/ProcessIntent.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -61,7 +61,12 @@ class ProcessIntent @Inject constructor( private val validateUploadLimit: ValidateUploadLimit, private val validateExternalUri: ValidateExternalUri, ) { - operator fun invoke(intent: Intent, deepLinkIntent: MutableSharedFlow, accountViewModel: AccountViewModel) = + operator fun invoke( + intent: Intent, + deepLinkIntent: MutableSharedFlow, + accountViewModel: AccountViewModel, + isNewIntent: Boolean = false, + ) = CoroutineScope(Job() + Dispatchers.IO).launch { with (intent) { getStringExtra(EXTRA_NOTIFICATION_ID)?.let { extraNotificationId -> @@ -69,6 +74,9 @@ class ProcessIntent @Inject constructor( } onActionSend { processActionSend(intent, deepLinkIntent, accountViewModel) } onActionSendMultiple { processActionSendMultiple(intent, deepLinkIntent, accountViewModel) } + onActionViewWithData(isNewIntent) { + processActionViewWithData(intent, deepLinkIntent) + } } } @@ -120,6 +128,13 @@ class ProcessIntent @Inject constructor( } } + private suspend fun processActionViewWithData( + intent: Intent, + deepLinkIntent: MutableSharedFlow, + ) { + deepLinkIntent.emit(intent) + } + private suspend fun actionSend( deepLinkIntent: MutableSharedFlow, accountViewModel: AccountViewModel, @@ -176,6 +191,12 @@ inline fun Intent.onActionSendMultiple(block: (intent: Intent) -> T): T? = t block(this) } +inline fun Intent.onActionViewWithData(accept: Boolean, block: (intent: Intent) -> T): T? = takeIf { + accept && action == Intent.ACTION_VIEW && data != null +}?.let { + block(this) +} + @Suppress("UNCHECKED_CAST", "DEPRECATION") inline fun getParcelableExtra( intent: Intent, diff --git a/app/src/main/res/values/config.xml b/app/src/main/res/values/config.xml index 2e90f6cc..64d588d3 100644 --- a/app/src/main/res/values/config.xml +++ b/app/src/main/res/values/config.xml @@ -1,7 +1,26 @@ + + false true + true true false diff --git a/app/src/main/res/xml-v25/shortcuts.xml b/app/src/main/res/xml-v25/shortcuts.xml new file mode 100644 index 00000000..b4b3b641 --- /dev/null +++ b/app/src/main/res/xml-v25/shortcuts.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..ae3b9142 --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/app/src/test/kotlin/me/proton/android/drive/telemetry/MainTelemetryEventHandlerTest.kt b/app/src/test/kotlin/me/proton/android/drive/telemetry/MainTelemetryEventHandlerTest.kt index 3bd07de7..e1559ce0 100644 --- a/app/src/test/kotlin/me/proton/android/drive/telemetry/MainTelemetryEventHandlerTest.kt +++ b/app/src/test/kotlin/me/proton/android/drive/telemetry/MainTelemetryEventHandlerTest.kt @@ -33,7 +33,7 @@ import me.proton.core.drive.base.domain.entity.Percentage import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.data.db.entity.LinkUploadEntity @@ -73,7 +73,7 @@ class MainTelemetryEventHandlerTest { @Before fun setUp() = runTest { - folderId = database.myDrive {} + folderId = database.myFiles {} val getShare: GetShare = mockk() every { getShare(any(), any()) } answers { val shareId: ShareId = arg(0) @@ -152,7 +152,7 @@ class MainTelemetryEventHandlerTest { val id = database.db.linkUploadDao.insert( LinkUploadEntity( userId = me.proton.core.drive.db.test.userId, - volumeId = volumeId, + volumeId = volumeId.id, shareId = folderId.shareId.id, parentId = folderId.id, name = "", diff --git a/app/src/test/kotlin/me/proton/android/drive/telemetry/PhotoTelemetryEventHandlerTest.kt b/app/src/test/kotlin/me/proton/android/drive/telemetry/PhotoTelemetryEventHandlerTest.kt index ab948d29..41d0bb4d 100644 --- a/app/src/test/kotlin/me/proton/android/drive/telemetry/PhotoTelemetryEventHandlerTest.kt +++ b/app/src/test/kotlin/me/proton/android/drive/telemetry/PhotoTelemetryEventHandlerTest.kt @@ -155,7 +155,7 @@ class PhotoTelemetryEventHandlerTest { val id = database.db.linkUploadDao.insert( LinkUploadEntity( userId = me.proton.core.drive.db.test.userId, - volumeId = volumeId, + volumeId = volumeId.id, shareId = folderId.shareId.id, parentId = folderId.id, name = "", @@ -186,7 +186,7 @@ class PhotoTelemetryEventHandlerTest { val id = database.db.linkUploadDao.insert( LinkUploadEntity( userId = me.proton.core.drive.db.test.userId, - volumeId = volumeId, + volumeId = volumeId.id, shareId = folderId.shareId.id, parentId = folderId.id, name = "", diff --git a/app/src/test/kotlin/me/proton/android/drive/telemetry/StatsEventHandlerTest.kt b/app/src/test/kotlin/me/proton/android/drive/telemetry/StatsEventHandlerTest.kt index bc4be080..d0d95936 100644 --- a/app/src/test/kotlin/me/proton/android/drive/telemetry/StatsEventHandlerTest.kt +++ b/app/src/test/kotlin/me/proton/android/drive/telemetry/StatsEventHandlerTest.kt @@ -60,7 +60,6 @@ import me.proton.core.drive.stats.domain.usecase.GetUploadStats import me.proton.core.drive.stats.domain.usecase.IsInitialBackup import me.proton.core.drive.stats.domain.usecase.SetOrIgnoreInitialBackup import me.proton.core.drive.stats.domain.usecase.UpdateUploadStats -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows @@ -203,7 +202,7 @@ class StatsEventHandlerTest { fileCreationDateTime: TimestampS, ) = UploadFileLink( userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, shareId = folderId.shareId, parentLinkId = folderId, uriString = "uri$index", diff --git a/app/src/test/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModelTest.kt b/app/src/test/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModelTest.kt new file mode 100644 index 00000000..2f440899 --- /dev/null +++ b/app/src/test/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModelTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.base.domain.extension.asSuccess +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.usecase.BroadcastMessages +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.usecase.RefreshDevices +import me.proton.core.drive.drivelink.device.domain.usecase.GetDecryptedDevicesSortedByName +import me.proton.core.drive.files.presentation.state.ListContentState +import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage +import me.proton.core.test.kotlin.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import me.proton.core.drive.drivelink.device.presentation.R as DriveLinkDevicePresentation +import me.proton.core.drive.i18n.R as I18N + +@RunWith(RobolectricTestRunner::class) +class ComputersViewModelTest { + private lateinit var computersViewModel: ComputersViewModel + private val getDevices: GetDecryptedDevicesSortedByName = mockk() + private val savedStateHandle: SavedStateHandle = mockk() + private val refreshDevices: RefreshDevices = mockk() + private val broadcastMessages: BroadcastMessages = mockk() + private val configurationProvider: ConfigurationProvider = mockk() + private val shouldUpgradeStorage: ShouldUpgradeStorage = mockk() + + @Before + fun setup() { + coEvery { savedStateHandle.get(any()) } returns "saved_state_handle_key_value" + coEvery { getDevices.invoke(any()) } returns flowOf(emptyList().asSuccess) + coEvery { shouldUpgradeStorage() } returns flowOf(ShouldUpgradeStorage.Result.NoUpgrade) + + computersViewModel = ComputersViewModel( + appContext = ApplicationProvider.getApplicationContext(), + getDevices = getDevices, + savedStateHandle = savedStateHandle, + refreshDevices = refreshDevices, + broadcastMessages = broadcastMessages, + configurationProvider = configurationProvider, + shouldUpgradeStorage = shouldUpgradeStorage, + ) + } + + @Test + fun `empty computers tab resources check`() = runTest { + // Given + launch { + computersViewModel.devices.collect() + } + + // When + val viewState = computersViewModel + .viewState + .filterNot { viewState -> viewState.listContentState is ListContentState.Loading } + .first() + + // Then + val emptyState = viewState.listContentState as ListContentState.Empty + assertEquals( + DriveLinkDevicePresentation.drawable.empty_devices_daynight, + emptyState.imageResId, + ) { "Wrong empty image resource" } + assertEquals( + I18N.string.computers_empty_title, + emptyState.titleId, + ) { "Wrong empty title resource" } + assertEquals( + I18N.string.computers_empty_description, + emptyState.descriptionResId, + ) { "Wrong empty description resource" } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/di/TestFeatureFlagBindModule.kt b/app/src/uiTest/kotlin/me/proton/android/drive/di/TestFeatureFlagBindModule.kt new file mode 100644 index 00000000..773d8666 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/di/TestFeatureFlagBindModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import me.proton.android.drive.repository.TestFeatureFlagRepositoryImpl +import me.proton.core.drive.feature.flag.data.di.FeatureFlagBindModule +import me.proton.core.drive.feature.flag.data.repository.FeatureFlagRepositoryImpl +import me.proton.core.drive.feature.flag.domain.repository.FeatureFlagRepository +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [FeatureFlagBindModule::class] +) +object TestFeatureFlagBindModule { + + @Provides + @Singleton + fun provideFeatureFlagRepository( + impl : FeatureFlagRepositoryImpl + ): FeatureFlagRepository = TestFeatureFlagRepositoryImpl(impl) +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/extension/ConfigurationProvider.kt b/app/src/uiTest/kotlin/me/proton/android/drive/extension/ConfigurationProvider.kt new file mode 100644 index 00000000..5549c183 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/extension/ConfigurationProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.extension + +import me.proton.android.drive.settings.DebugSettings +import me.proton.core.drive.base.domain.provider.ConfigurationProvider + +val ConfigurationProvider.debug : DebugSettings + get() = this as DebugSettings diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/repository/TestFeatureFlagRepositoryImpl.kt b/app/src/uiTest/kotlin/me/proton/android/drive/repository/TestFeatureFlagRepositoryImpl.kt new file mode 100644 index 00000000..982115b8 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/repository/TestFeatureFlagRepositoryImpl.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.repository + +import me.proton.core.drive.base.domain.log.LogTag.FEATURE_FLAG +import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag +import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId +import me.proton.core.drive.feature.flag.domain.repository.FeatureFlagRepository +import me.proton.core.util.kotlin.CoreLogger +import javax.inject.Inject + +class TestFeatureFlagRepositoryImpl @Inject constructor( + private val repository: FeatureFlagRepository, +) : FeatureFlagRepository by repository { + + override suspend fun getFeatureFlag(featureFlagId: FeatureFlagId): FeatureFlag? = + (flags[featureFlagId.id]?.let { state -> + CoreLogger.d(FEATURE_FLAG, "Overriding feature flag $featureFlagId to $state") + FeatureFlag(featureFlagId, state) + } ?: repository.getFeatureFlag(featureFlagId)) + + companion object { + val flags = mutableMapOf() + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/annotation/FeatureFlag.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/annotation/FeatureFlag.kt new file mode 100644 index 00000000..67e3abbb --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/annotation/FeatureFlag.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.annotation + +import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class FeatureFlag( + val id: String, + val state: FeatureFlag.State, +) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/annotation/Quota.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/annotation/Quota.kt index e039f60e..d6310e97 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/annotation/Quota.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/annotation/Quota.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -21,9 +21,9 @@ package me.proton.android.drive.ui.annotation @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class Quota( - val value: Int = 500, + val value: Int = 2_048, val unit: QuotaUnit = QuotaUnit.MB, val percentageFull: Int = 0 ) -enum class QuotaUnit { KB, MB, GB, TB, PB } \ No newline at end of file +enum class QuotaUnit { KB, MB, GB, TB, PB } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/extension/OngoingStubbing.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/extension/OngoingStubbing.kt new file mode 100644 index 00000000..f2cc6c8c --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/extension/OngoingStubbing.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.extension + +import android.app.Activity +import android.app.Instrumentation +import android.content.ClipData +import android.content.ClipDescription +import android.content.Intent +import android.net.Uri +import androidx.test.espresso.intent.OngoingStubbing +import java.io.File + + + +fun OngoingStubbing.respondWithFile(file: File) { + respondWithFunction { + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(Uri.fromFile(file))) + } +} + +fun OngoingStubbing.respondWithFiles(files: List) { + respondWithFunction { + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().apply { + val items = files.map { file -> + ClipData.Item(Uri.fromFile(file)) + } + clipData = ClipData( + ClipDescription( + "", files.map { "text/plain" }.toTypedArray() + ), + items.first() + ).also { clipData -> + (items - items.first()).forEach(clipData::addItem) + } + }) + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/extension/QuarkCommands.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/extension/QuarkCommands.kt index 5893b242..ef843f2d 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/extension/QuarkCommands.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/extension/QuarkCommands.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -21,11 +21,7 @@ package me.proton.android.drive.ui.extension import me.proton.core.test.quark.data.User import me.proton.core.test.quark.v2.QuarkCommand import me.proton.core.test.quark.v2.toEncodedArgs -import me.proton.core.util.kotlin.EMPTY_STRING -import okhttp3.OkHttpClient import okhttp3.Response -import kotlin.time.Duration.Companion.seconds -import kotlin.time.toJavaDuration fun QuarkCommand.populate( user: User, @@ -34,12 +30,12 @@ fun QuarkCommand.populate( ): Response = route("quark/drive:populate") .args( - listOf( + listOfNotNull( "-u" to user.name, "-p" to user.password, "-S" to user.dataSetScenario, - "--d" to if (isDevice) true.toString() else EMPTY_STRING, - "--photo" to if (isPhotos) true.toString() else EMPTY_STRING + isDevice.optionalArg("-d"), + isPhotos.optionalArg("--photo"), ).toEncodedArgs() ) .build() @@ -47,6 +43,10 @@ fun QuarkCommand.populate( client.executeQuarkRequest(it) } +private fun Boolean.optionalArg( + name: String, +): Pair? = takeIf { it }?.let { name to it.toString() } + fun QuarkCommand.quotaSetUsedSpace( user: User, usedSpace: String, @@ -64,18 +64,16 @@ fun QuarkCommand.quotaSetUsedSpace( } fun QuarkCommand.volumeCreate( - user: User, - maxSize: String + user: User ): Response = route("quark/drive:volume:create") .args( listOf( "--username" to user.name, "--pass" to user.password, - "--max-size" to maxSize ).toEncodedArgs() ) .build() .let { client.executeQuarkRequest(it) - } \ No newline at end of file + } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputerOptionsRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputerOptionsRobot.kt new file mode 100644 index 00000000..bbe81c2a --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputerOptionsRobot.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.robot + +import me.proton.android.drive.ui.dialog.ComputerOptionsTestTag +import me.proton.test.fusion.Fusion.node +import me.proton.core.drive.i18n.R as I18N + +object ComputerOptionsRobot : Robot { + private val computerOptionsScreen get() = node.withTag(ComputerOptionsTestTag.computerOptions) + private val renameButton get() = node.withText(I18N.string.common_rename_action) + + fun clickRename() = RenameRobot.apply { + renameButton.scrollTo().click() + } + + override fun robotDisplayed() { + computerOptionsScreen.assertIsDisplayed() + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputerSyncedFoldersRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputerSyncedFoldersRobot.kt new file mode 100644 index 00000000..1e7872ea --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputerSyncedFoldersRobot.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.robot + +import me.proton.android.drive.ui.screen.SyncedFoldersTestTag +import me.proton.test.fusion.Fusion.node +import me.proton.core.drive.i18n.R as I18N + +object ComputerSyncedFoldersRobot : LinksRobot { + private val syncedFoldersScreen get() = node.withTag(SyncedFoldersTestTag.screen) + + fun assertEmptySyncedFolders() { + node.withText(I18N.string.computers_synced_folders_empty_title).await { assertIsDisplayed() } + node.withText(I18N.string.computers_synced_folders_empty_description).await { assertIsDisplayed() } + } + + override fun robotDisplayed() { + syncedFoldersScreen.assertIsDisplayed() + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputersTabRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputersTabRobot.kt new file mode 100644 index 00000000..38d601f8 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/ComputersTabRobot.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.robot + +import me.proton.android.drive.ui.screen.ComputersTestTag +import me.proton.test.fusion.Fusion.node +import me.proton.test.fusion.FusionConfig.Compose.targetContext +import me.proton.core.drive.i18n.R as I18N + +object ComputersTabRobot : NavigationBarRobot, HomeRobot, LinksRobot, GrowlerRobot, Robot { + private val content get() = node.withTag(ComputersTestTag.content) + + private fun moreButton(name: String) = node + .withContentDescription( + targetContext.getString(I18N.string.computers_content_description_list_more_options, name) + ) + + fun scrollToComputer(name: String): ComputersTabRobot = apply { + content.scrollTo(node.withText(name)) + } + + fun clickMoreOnComputer(name: String) = + moreButton(name).clickTo(ComputerOptionsRobot) + + fun clickOnComputer(name: String): ComputerSyncedFoldersRobot = + node + .withText(name) + .clickTo(ComputerSyncedFoldersRobot) + + fun assertEmptyComputers() { + node.withText(I18N.string.computers_empty_title).await { assertIsDisplayed() } + node.withText(I18N.string.computers_empty_description).await { assertIsDisplayed() } + } + + fun itemIsDisplayed(name: String) = node.withText(name).await { assertIsDisplayed() } + + override fun robotDisplayed() { + homeScreenDisplayed() + computersTab.assertIsSelected() + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/HomeRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/HomeRobot.kt index 3b5b4e46..93ac4bb0 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/HomeRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/HomeRobot.kt @@ -30,11 +30,12 @@ interface HomeRobot : Robot { val homeScreen get() = node.withTag(HomeScreenTestTag.screen) val filesTab get() = tabWithText(I18N.string.title_files) val photosTab get() = tabWithText(I18N.string.photos_title) + val computersTab get() = tabWithText(I18N.string.computers_title) val sharedTab get() = tabWithText(I18N.string.title_shared) fun clickFilesTab() = filesTab.clickTo(FilesTabRobot) - fun clickPhotosTab() = photosTab.clickTo(PhotosTabRobot) + fun clickComputersTab() = computersTab.clickTo(ComputersTabRobot) fun clickSharedTab() = sharedTab.clickTo(SharedTabRobot) fun openSidebarBySwipe() = SidebarRobot.apply { diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/LauncherRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/LauncherRobot.kt index 76d06495..1124e9e9 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/LauncherRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/LauncherRobot.kt @@ -20,24 +20,42 @@ package me.proton.android.drive.ui.robot import android.content.ClipData import android.content.ClipDescription -import android.content.Context import android.content.Intent import android.net.Uri import androidx.test.core.app.ActivityScenario -import androidx.test.core.app.ApplicationProvider import me.proton.android.drive.ui.MainActivity +import me.proton.test.fusion.FusionConfig.targetContext import java.io.File import java.nio.file.Files object LauncherRobot { + /** + * Deeplink to any route in MainActivity with an intent VIEW + * Test class should inherit from EmptyBaseTest to use it. + * @param redirection the redirection to go to + */ + fun deeplinkTo(route: String, robot: T): T { + ActivityScenario.launch(Intent(targetContext, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse("drive://proton.me/$route") + }) + return robot + } + /** + * Launch the MainActivity with an intent VIEW and the redirection. + * Test class should inherit from EmptyBaseTest to use it. + * @param redirection the redirection to go to + */ + fun launch(redirection: String, robot: T): T { + return deeplinkTo("launcher?redirection=$redirection", robot) + } /** * Launch the MainActivity with an intent SEND and the file. * Test class should inherit from EmptyBaseTest to use it. * @param file the file to upload */ fun uploadTo(file: File): UploadToRobot { - val context = ApplicationProvider.getApplicationContext() - ActivityScenario.launch(Intent(context, MainActivity::class.java).apply { + ActivityScenario.launch(Intent(targetContext, MainActivity::class.java).apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file)) }) @@ -49,8 +67,7 @@ object LauncherRobot { * @param files the files to upload */ fun uploadTo(files: List): UploadToRobot { - val context = ApplicationProvider.getApplicationContext() - ActivityScenario.launch(Intent(context, MainActivity::class.java).apply { + ActivityScenario.launch(Intent(targetContext, MainActivity::class.java).apply { val uris = files.map { file -> Uri.fromFile(file) } action = Intent.ACTION_SEND_MULTIPLE putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/LinksRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/LinksRobot.kt index 51d6cb15..b85d327c 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/LinksRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/LinksRobot.kt @@ -18,6 +18,8 @@ package me.proton.android.drive.ui.robot +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import me.proton.android.drive.ui.extension.assertDownloadState import me.proton.android.drive.ui.extension.assertHasItemType import me.proton.android.drive.ui.extension.assertHasLayoutType @@ -32,18 +34,20 @@ import me.proton.core.drive.files.presentation.component.files.FilesListHeaderTe import me.proton.core.drive.files.presentation.extension.ItemType import me.proton.core.drive.files.presentation.extension.LayoutType import me.proton.core.drive.files.presentation.extension.SemanticsDownloadState -import me.proton.core.drive.i18n.R import me.proton.test.fusion.Fusion.node +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import me.proton.core.drive.i18n.R as I18N @Suppress("TooManyFunctions") interface LinksRobot : Robot { - private val content get() = node.withTag(FilesTestTag.content) + val filesContent get() = node.withTag(FilesTestTag.content) private val layoutSwitcher get() = node.withTag(FilesListHeaderTestTag.layoutSwitcher) private val selectedOptionsButton get() = node - .withContentDescription(R.string.content_description_selected_options) + .withContentDescription(I18N.string.content_description_selected_options) fun linkWithName(name: String) = node.withLinkName(name).isClickable() @@ -53,7 +57,7 @@ interface LinksRobot : Robot { fun clickLayoutSwitcher() = layoutSwitcher.clickTo(FilesTabRobot) fun scrollToItemWithName(itemName: String): LinksRobot = apply { - content.scrollTo(linkWithName(itemName)) + filesContent.scrollTo(linkWithName(itemName)) } fun clickOptions() = @@ -90,6 +94,11 @@ interface LinksRobot : Robot { fun clickOnFile(name: String, layoutType: LayoutType = LayoutType.List) = clickOnItem(name, layoutType, ItemType.File, PreviewRobot) + fun clickOnUndo(after: Duration = 0.seconds, goesTo: T): T = runBlocking { + delay(after) + node.withText(I18N.string.common_undo_action).clickTo(goesTo) + } + fun longClickOnItem(name: String) = linkWithName(name).longClickTo(FilesTabRobot) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/MoveToFolderRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/MoveToFolderRobot.kt index f4df03ae..d585a733 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/MoveToFolderRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/MoveToFolderRobot.kt @@ -21,8 +21,6 @@ package me.proton.android.drive.ui.robot import me.proton.android.drive.ui.screen.MoveToFolderScreenTestTag import me.proton.core.drive.base.presentation.component.TopAppBarComponentTestTag import me.proton.core.drive.files.presentation.component.FilesTestTag.listDetailsTitle -import me.proton.core.drive.files.presentation.extension.ItemType -import me.proton.core.drive.files.presentation.extension.LayoutType import me.proton.test.fusion.Fusion.node import me.proton.test.fusion.ui.compose.builders.OnNode import me.proton.core.drive.i18n.R as I18N @@ -33,10 +31,12 @@ object MoveToFolderRobot : NavigationBarRobot, Robot, LinksRobot, GrowlerRobot { private val rootDirectoryTitle = node.withText(I18N.string.title_my_files) private val addFolderButton get() = node.withTag(MoveToFolderScreenTestTag.plusFolderButton) private val cancelButton get() = node.withText(I18N.string.move_file_dismiss_action) - private val moveButton get() = node.withText(I18N.string.move_file_confirm_action).isEnabled() + private val moveButton get() = node.withTag(MoveToFolderScreenTestTag.moveButton) private fun itemWithName(name: String) = node.withTag(listDetailsTitle).withText(name) + fun assertMoveButtonIsDisabled() = moveButton.assertDisabled() + /** * Wait the folder to be loaded before clicking back, * to ensure that parent id is known @@ -74,6 +74,7 @@ object MoveToFolderRobot : NavigationBarRobot, Robot, LinksRobot, GrowlerRobot { override fun robotDisplayed() { moveToFolderScreen.assertIsDisplayed() addFolderButton.assertIsDisplayed() + filesContent.assertIsDisplayed() cancelButton.assertIsDisplayed() moveButton.assertIsDisplayed() } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/OfflineRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/OfflineRobot.kt index 4df86361..9f94cf9d 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/OfflineRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/OfflineRobot.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -23,10 +23,12 @@ import me.proton.core.drive.i18n.R as I18N object OfflineRobot : Robot, LinksRobot, NavigationBarRobot { private val offlineScreen get() = node.withText(I18N.string.title_offline_available) - private val emptyOfflineScreen get() = node.withText(I18N.string.title_empty_offline_available) + private val emptyOfflineTitle get() = node.withText(I18N.string.title_empty_offline_available) + private val emptyOfflineDescription get() = node.withText(I18N.string.description_empty_offline_available) - fun assertIsEmpty() = emptyOfflineScreen.await { - assertIsDisplayed() + fun assertIsEmpty() { + emptyOfflineTitle.await { assertIsDisplayed() } + emptyOfflineDescription.await { assertIsDisplayed() } } override fun robotDisplayed() { diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosNoPermissionsRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosNoPermissionsRobot.kt index 26eb198a..22faf806 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosNoPermissionsRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosNoPermissionsRobot.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -21,10 +21,10 @@ package me.proton.android.drive.ui.robot import android.os.Build import me.proton.android.drive.ui.extension.withTextResource import me.proton.test.fusion.Fusion.byObject -import me.proton.test.fusion.Fusion.device import me.proton.test.fusion.Fusion.node import me.proton.core.drive.i18n.R as I18N + object PhotosNoPermissionsRobot: Robot { private val photoPermissionsTitle = node.withTextResource(I18N.string.photos_permission_rational_title, I18N.string.app_name) @@ -35,11 +35,13 @@ object PhotosNoPermissionsRobot: Robot { private val allowAccessImage = node.withText(I18N.string.photos_permission_rational_img_text) fun denyPermissions() = apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - device.pressBack() - } else { - byObject.withText("DENY").waitForClickable().click() - } + byObject.withTextMatches( + when (Build.VERSION.SDK_INT) { + in 24..28 -> "DENY" + in 29..30 -> "Deny" + else -> "Don.+t allow" + }.toPattern() + ).waitForClickable().click() } fun clickNotNow(goesTo: T) = notNowButton.clickTo(goesTo) @@ -51,4 +53,4 @@ object PhotosNoPermissionsRobot: Robot { settingsButton.await { assertIsDisplayed() } allowAccessImage.await { assertIsDisplayed() } } -} \ No newline at end of file +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosTabRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosTabRobot.kt index db6f81b8..e02bdf2f 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosTabRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosTabRobot.kt @@ -51,6 +51,7 @@ object PhotosTabRobot : HomeRobot, LinksRobot, NavigationBarRobot { node.withTextResource(I18N.string.storage_quotas_not_full_title, "${percentage}%") private val itCanTakeAWhileLabel get() = node.withText(I18N.string.photos_empty_loading_label_progress) private val closeQuotaBannerButton get() = node.withContentDescription(I18N.string.common_close_action) + private val photosBackupDisabled get() = node.withText(I18N.string.error_creating_photo_share_not_allowed) private fun itemsLeft(items: Int) = node.withPluralTextResource(I18N.plurals.notification_content_text_backup_in_progress, items) @@ -145,6 +146,10 @@ object PhotosTabRobot : HomeRobot, LinksRobot, NavigationBarRobot { itemsLeft(items).await { assertIsDisplayed() } } + fun assertPhotosBackupDisabled() { + photosBackupDisabled.await { assertIsDisplayed() } + } + override fun robotDisplayed() { homeScreenDisplayed() photosTab.assertIsSelected() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosUpsellRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosUpsellRobot.kt new file mode 100644 index 00000000..6cf1a12b --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PhotosUpsellRobot.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.robot + +import me.proton.test.fusion.Fusion.node +import me.proton.core.drive.i18n.R as I18N + +object PhotosUpsellRobot : HomeRobot, LinksRobot, NavigationBarRobot { + + private val title get() = node.withText(I18N.string.photos_upsell_title) + private val description get() = node.withText(I18N.string.photos_upsell_description) + private val getStorage get() = node.withText(I18N.string.photos_upsell_get_storage_action) + fun clickGetStorage() = getStorage.clickTo(PhotosTabRobot) + + override fun robotDisplayed() { + title.await { assertIsDisplayed() } + description.await { assertIsDisplayed() } + } + + fun robotDoesNotExist() { + title.assertDoesNotExist() + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PreviewRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PreviewRobot.kt index f4ee172e..9afaf261 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PreviewRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/PreviewRobot.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -18,10 +18,13 @@ package me.proton.android.drive.ui.robot +import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.semantics.SemanticsActions.PageDown import androidx.compose.ui.semantics.SemanticsActions.PageLeft import androidx.compose.ui.semantics.SemanticsActions.PageRight import androidx.compose.ui.semantics.SemanticsActions.PageUp +import androidx.compose.ui.semantics.SemanticsActions.ScrollBy +import androidx.compose.ui.semantics.SemanticsActions.ScrollToIndex import androidx.compose.ui.test.performSemanticsAction import me.proton.core.drive.base.presentation.component.TopAppBarComponentTestTag import me.proton.core.drive.files.preview.presentation.component.ImagePreviewComponentTestTag @@ -65,19 +68,54 @@ object PreviewRobot : NavigationBarRobot { pager.swipePage(direction) } + fun scrollTo(index: Int) = apply { + pager.scrollTo(index) + } + override fun robotDisplayed() { previewScreen.assertIsDisplayed() } } fun NodeActions.swipePage(direction: SwipeDirection) = apply { - val action = when(direction) { + val action = when (direction) { SwipeDirection.Left -> PageRight SwipeDirection.Right -> PageLeft SwipeDirection.Up -> PageDown SwipeDirection.Down -> PageUp } - waitFor { - interaction.performSemanticsAction(action) + val semanticsNode = interaction.fetchSemanticsNode() + if (semanticsNode.config.contains(action)) { + waitFor { + interaction.performSemanticsAction(action) + } + } else { + val viewport = semanticsNode.layoutInfo.coordinates.boundsInParent().size + val (x, y) = when (direction) { + SwipeDirection.Left -> viewport.width to 0F + SwipeDirection.Right -> -viewport.width to 0F + SwipeDirection.Up -> 0F to viewport.height + SwipeDirection.Down -> 0F to -viewport.height + } + waitFor { + scrollBy(x, y) + } } -} \ No newline at end of file +} + +fun NodeActions.scrollTo(index: Int) = apply { + waitFor { + interaction.performSemanticsAction(ScrollToIndex) {action -> + action(index) + } + } +} + + +fun NodeActions.scrollBy(x: Float, y: Float) = apply { + waitFor { + interaction.performSemanticsAction(ScrollBy) {action -> + action(x, y) + } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/SidebarRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/SidebarRobot.kt index 50037445..94be62c7 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/SidebarRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/SidebarRobot.kt @@ -19,20 +19,34 @@ package me.proton.android.drive.ui.robot import androidx.annotation.StringRes +import me.proton.android.drive.ui.extension.doesNotExist +import me.proton.android.drive.ui.robot.settings.GetMoreFreeStorageRobot import me.proton.android.drive.ui.screen.HomeScreenTestTag +import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerTestTag import me.proton.test.fusion.Fusion.node import me.proton.core.drive.i18n.R as I18N object SidebarRobot : Robot { private val sidebar get() = node.withTag(HomeScreenTestTag.sidebar) + private val content get() = node.withTag(NavigationDrawerTestTag.content) + private val storageIndicator get() = node.withTag(NavigationDrawerTestTag.storageIndicator) private val trashNavigationItem get() = node.withText(I18N.string.navigation_item_trash) private val offlineNavigationItem get() = node.withText(I18N.string.navigation_item_offline) + private val getMoreFreeStorageItem get() = node.withText(I18N.string.navigation_item_get_free_storage) private fun clickSidebarMenuItem(@StringRes menuItemName: Int) { node.withText(menuItemName).click() } + fun scrollToItemWithName(itemName: String): SidebarRobot = apply { + content.scrollTo(node.withText(itemName)) + } + + fun scrollToStorageIndicator(): SidebarRobot = apply { + content.scrollTo(storageIndicator) + } + fun clickReportBug() { clickSidebarMenuItem(I18N.string.navigation_item_bug_report) } @@ -49,6 +63,12 @@ object SidebarRobot : Robot { clickSidebarMenuItem(I18N.string.navigation_item_settings) } + fun clickGetMoreFreeStorage() = getMoreFreeStorageItem.clickTo(GetMoreFreeStorageRobot) + + fun assertGetMoreFreeStorageIsNotDisplayed() { + getMoreFreeStorageItem.await { doesNotExist() } + } + override fun robotDisplayed() { sidebar.await { assertIsDisplayed() } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/StorageFullRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/StorageFullRobot.kt new file mode 100644 index 00000000..1118b2e7 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/StorageFullRobot.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.robot + +import me.proton.test.fusion.Fusion.node +import me.proton.core.drive.i18n.R as I18N + +object StorageFullRobot : Robot { + private val title get() = node.withText(I18N.string.files_upload_failure_storage_full_title) + private val dismissButton get() = node.withText(I18N.string.common_got_it_action) + + fun clickDismiss(goesTo: T) = dismissButton.clickTo(goesTo) + + override fun robotDisplayed() { + title.await { assertIsDisplayed() } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/UploadToRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/UploadToRobot.kt index 9e76c51e..df8e2c8f 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/UploadToRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/UploadToRobot.kt @@ -42,10 +42,6 @@ object UploadToRobot : LinksRobot, GrowlerRobot, Robot { ).format(count, folderName) ).await { assertIsDisplayed() } - fun assertStorageFull() = node.withText( - targetContext.resources.getString(I18N.string.files_upload_failure_storage_full_title) - ).await { assertIsDisplayed() } - override fun robotDisplayed() { uploadToScreen.await { assertIsDisplayed() } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/settings/GetMoreFreeStorageRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/settings/GetMoreFreeStorageRobot.kt new file mode 100644 index 00000000..00d5bab7 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/settings/GetMoreFreeStorageRobot.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.robot.settings + +import me.proton.android.drive.ui.robot.Robot +import me.proton.android.drive.ui.screen.GetMoreFreeStorage +import me.proton.core.drive.base.domain.entity.Bytes +import me.proton.core.drive.base.presentation.extension.asHumanReadableString +import me.proton.test.fusion.Fusion.node +import me.proton.test.fusion.FusionConfig.Compose.targetContext +import me.proton.core.drive.i18n.R as I18N + +object GetMoreFreeStorageRobot : Robot { + private val content = node.withTag(GetMoreFreeStorage.screen) + + fun assertTitleDisplayed(maxFreeSpace: Bytes) { + val title = targetContext.getString( + I18N.string.get_more_free_storage_title, + maxFreeSpace.asHumanReadableString(targetContext, numberOfDecimals = 0), + ) + node.withText(title).await { assertIsDisplayed() } + } + + fun assertSubtitleDisplayed() { + node.withText(I18N.string.get_more_free_storage_description).await { + assertIsDisplayed() + } + } + + fun assertActionsDisplayed() { + listOf( + I18N.string.get_more_free_storage_action_upload_title, + I18N.string.get_more_free_storage_action_link_title, + I18N.string.get_more_free_storage_action_recovery_title, + ).forEach { actionTitleResId -> + assertAction(actionTitleResId) + } + } + + private fun assertAction(titleResId: Int) { + node.withText(titleResId).await { assertIsDisplayed() } + } + + override fun robotDisplayed() { + content.await { assertIsDisplayed() } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/settings/PhotosBackupRobot.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/settings/PhotosBackupRobot.kt index 7ff240cc..01e94917 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/settings/PhotosBackupRobot.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/robot/settings/PhotosBackupRobot.kt @@ -32,6 +32,8 @@ object PhotosBackupRobot : Robot, NavigationBarRobot { private val confirmButton = node.withText(I18N.string.settings_photos_backup_folders_confirm_stop_sync_confirm_action) + private val photosBackupDisabled get() = node.withText(I18N.string.error_creating_photo_share_not_allowed) + fun clickBackupToggle(goesTo: T) = photosBackupToggle.clickTo(goesTo) fun assertFoldersList() = uploadFrom.await { assertIsDisplayed() } @@ -49,6 +51,10 @@ object PhotosBackupRobot : Robot, NavigationBarRobot { fun clickFolder(name: String) = node.withText(name).clickTo(PhotosBackupRobot) fun clickConfirm() = confirmButton.clickTo(PhotosBackupRobot) + fun assertPhotosBackupDisabled() { + photosBackupDisabled.await { assertIsDisplayed() } + } + override fun robotDisplayed() { photosBackupToggle.await { assertIsDisplayed() } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/NetworkSimulator.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/NetworkSimulator.kt index aef2340b..330d9887 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/NetworkSimulator.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/NetworkSimulator.kt @@ -28,6 +28,7 @@ import me.proton.core.drive.backup.domain.manager.BackupConnectivityManager import me.proton.core.drive.backup.domain.manager.BackupConnectivityManager.Connectivity.NONE import me.proton.core.drive.backup.domain.manager.BackupConnectivityManager.Connectivity.UNMETERED import me.proton.core.util.kotlin.CoreLogger +import me.proton.test.fusion.FusionConfig import okhttp3.Interceptor import okhttp3.OkHttpClient import org.junit.rules.ExternalResource @@ -56,7 +57,7 @@ object NetworkSimulator: ExternalResource() { throwIf(isNetworkTimeout, SocketTimeoutException("Simulated network timeout")) runBlocking { - throwIf(connectivity?.first() == NONE, UnknownHostException("Simulated disabled network")) + throwIf(connectivity.first() == NONE, UnknownHostException("Simulated disabled network")) } it.proceed(it.request()) @@ -82,6 +83,21 @@ object NetworkSimulator: ExternalResource() { fun enableNetwork(connectivity: BackupConnectivityManager.Connectivity = UNMETERED) = setConnectivity(connectivity) + fun disableNetworkFor(duration: Duration, block : () -> Unit) { + disableNetwork() + + block() + + val durationMillis = duration.inWholeMilliseconds + val start = System.currentTimeMillis() + + FusionConfig.Compose.testRule.get().waitUntil((durationMillis * 1.1F).toLong()){ + System.currentTimeMillis() - start > durationMillis + } + + enableNetwork() + } + fun setNetworkTimeout(isNetworkTimeout: Boolean) = apply { NetworkSimulator.isNetworkTimeout = isNetworkTimeout } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/UserLoginRule.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/UserLoginRule.kt index c98e9c9b..77318cca 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/UserLoginRule.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/UserLoginRule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,11 +19,14 @@ package me.proton.android.drive.ui.rules import kotlinx.coroutines.runBlocking +import me.proton.android.drive.repository.TestFeatureFlagRepositoryImpl +import me.proton.android.drive.ui.annotation.FeatureFlag import me.proton.android.drive.ui.annotation.Quota import me.proton.android.drive.ui.extension.populate import me.proton.android.drive.ui.extension.quotaSetUsedSpace import me.proton.android.drive.ui.extension.volumeCreate import me.proton.android.drive.ui.test.AbstractBaseTest.Companion.loginTestHelper +import me.proton.core.domain.entity.UserId import me.proton.core.test.quark.data.User import me.proton.core.test.quark.v2.QuarkCommand import me.proton.core.test.quark.v2.command.seedNewSubscriber @@ -38,37 +41,48 @@ class UserLoginRule( private val isPhotos: Boolean = false, private val quarkCommands: QuarkCommand, ) : TestWatcher() { + + var userId: UserId? = null + override fun starting(description: Description) { runBlocking { loginTestHelper.logoutAll() + val userPlanAnnotation = description.getAnnotation(UserPlan::class.java) val scenarioAnnotation = description.getAnnotation(Scenario::class.java) val quotaAnnotation = description.getAnnotation(Quota::class.java) + val featureFlagAnnotation = description.getAnnotation(FeatureFlag::class.java) - val scenarioUser = scenarioAnnotation - ?.let { - testUser.copy(dataSetScenario = it.value.toString()) - } ?: testUser + val user = testUser + .updateWith(userPlanAnnotation) + .updateWith(scenarioAnnotation) val device = scenarioAnnotation?.isDevice ?: isDevice val photos = scenarioAnnotation?.isPhotos ?: isPhotos - if (testUser.isPaid) - quarkCommands.seedNewSubscriber(testUser) + if (user.isPaid) + quarkCommands.seedNewSubscriber(user) else - quarkCommands.userCreate(testUser) + quarkCommands.userCreate(user).also { response -> + userId = response.userId.let(::UserId) + } quotaAnnotation?.let { - quarkCommands.volumeCreate(testUser, "${it.value}${it.unit}") + quarkCommands.volumeCreate(user) if (it.percentageFull in 1..100) { val usedSpace = (it.value.toDouble() * it.percentageFull / 100).roundToInt() - quarkCommands.quotaSetUsedSpace(testUser, "${usedSpace}${it.unit}") + quarkCommands.quotaSetUsedSpace(user, "${usedSpace}${it.unit}") } } - if (scenarioUser.dataSetScenario.let { it.isNotEmpty() && it != "0" }) { - quarkCommands.populate(scenarioUser, device, photos) + if (user.dataSetScenario.let { it.isNotEmpty() && it != "0" }) { + quarkCommands.populate(user, device, photos) + } + + if (featureFlagAnnotation != null) { + TestFeatureFlagRepositoryImpl.flags[featureFlagAnnotation.id] = + featureFlagAnnotation.state } } @@ -82,6 +96,23 @@ class UserLoginRule( } override fun finished(description: Description) { + TestFeatureFlagRepositoryImpl.flags.clear() loginTestHelper.logoutAll() } } + +private fun User.updateWith( + userAnnotation: UserPlan?, +): User = updateWith(userAnnotation) { annotation -> + copy(plan = annotation.value) +} + +private fun User.updateWith( + scenarioAnnotation: Scenario?, +): User = updateWith(scenarioAnnotation) { annotation -> + copy(dataSetScenario = annotation.value.toString()) +} + +private fun User.updateWith(value: T?, block: User.(T) -> User) = value?.let { + block(it) +} ?: this diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/UserPlan.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/UserPlan.kt new file mode 100644 index 00000000..cad4bd57 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/rules/UserPlan.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.rules + +import me.proton.core.test.quark.data.Plan + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class UserPlan( + val value: Plan = Plan.Free, +) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/BaseTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/BaseTest.kt index fec20a84..d05b0110 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/BaseTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/BaseTest.kt @@ -20,9 +20,18 @@ package me.proton.android.drive.ui.test import me.proton.android.drive.ui.MainActivity import me.proton.android.drive.ui.extension.createFusionAndroidComposeRule +import me.proton.core.auth.test.robot.AddAccountRobot +import me.proton.core.test.quark.data.User import org.junit.Rule abstract class BaseTest : AbstractBaseTest() { @get:Rule(order = 2) val fusionComposeRule = createFusionAndroidComposeRule() + + @Suppress("unused") + protected fun signIn(existingUser: User) { + AddAccountRobot + .clickSignIn() + .login(existingUser) + } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/ForcedSignOutFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/ForcedSignOutFlowTest.kt index 5c5282d5..4cc15af4 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/ForcedSignOutFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/ForcedSignOutFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ package me.proton.android.drive.ui.test.flow.account import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.core.auth.test.robot.AddAccountRobot import me.proton.core.test.quark.v2.command.expireSession @@ -33,7 +33,7 @@ class ForcedSignOutFlowTest : AuthenticatedBaseTest() { @Test fun forcedSignOut() { - FilesTabRobot.verify { + PhotosTabRobot.verify { robotDisplayed() } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/HomeFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/HomeFlowTest.kt index 7f3f2d3f..0922e0c2 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/HomeFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/HomeFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -32,6 +32,10 @@ class HomeFlowTest : AuthenticatedBaseTest() { .verify { robotDisplayed() } + .clickPhotosTab() + .verify { + robotDisplayed() + } .clickSharedTab() .verify { robotDisplayed() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/ReportFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/ReportFlowTest.kt index ab3e57d7..1f596d40 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/ReportFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/account/ReportFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ package me.proton.android.drive.ui.test.flow.account import androidx.test.platform.app.InstrumentationRegistry import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.test.BaseTest import me.proton.core.report.test.MinimalReportInternalTests import me.proton.core.test.quark.Quark @@ -47,17 +47,18 @@ class ReportFlowTest : BaseTest(), MinimalReportInternalTests { } override fun verifyBefore() { - FilesTabRobot.verify { homeScreenDisplayed() } + PhotosTabRobot + .verify { homeScreenDisplayed() } } override fun startReport() { - FilesTabRobot + PhotosTabRobot .openSidebarBySwipe() .verify { robotDisplayed() } .clickReportBug() } override fun verifyAfter() { - FilesTabRobot.verify { homeScreenDisplayed() } + PhotosTabRobot.verify { homeScreenDisplayed() } } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/EmptyComputersTabTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/EmptyComputersTabTest.kt new file mode 100644 index 00000000..9ee9ae05 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/EmptyComputersTabTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.computers + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import org.junit.Test + +@HiltAndroidTest +class EmptyComputersTabTest : AuthenticatedBaseTest() { + + @Test + fun emptyComputers() { + FilesTabRobot + .clickComputersTab() + .verify { + robotDisplayed() + assertEmptyComputers() + } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/RenamingComputerErrorFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/RenamingComputerErrorFlowTest.kt new file mode 100644 index 00000000..de0deed6 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/RenamingComputerErrorFlowTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.computers + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.RenameRobot +import me.proton.android.drive.ui.rules.Scenario +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import me.proton.android.drive.utils.getRandomString +import me.proton.core.test.android.instrumented.utils.StringUtils +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import me.proton.core.drive.i18n.R as I18N + +@HiltAndroidTest +@RunWith(Parameterized::class) +class RenamingComputerErrorFlowTest( + private val computerToBeRenamed: String, + private val newComputerName: String, + private val errorMessage: String, + @Suppress("unused") private val friendlyName: String, +) : AuthenticatedBaseTest() { + + @Test + @Scenario(value = 2, isDevice = true) + fun renameComputerWithInvalidName() { + FilesTabRobot + .clickComputersTab() + .scrollToComputer(computerToBeRenamed) + .clickMoreOnComputer(computerToBeRenamed) + .clickRename() + .clearName() + .typeName(newComputerName) + .clickRename(RenameRobot) + .verify { + nodeWithTextDisplayed(errorMessage) + } + } + + companion object { + private const val MY_DEVICE_1 = "MyDevice1" + @get:Parameterized.Parameters(name = "{3}") + @get:JvmStatic + val data = listOf( + arrayOf( + MY_DEVICE_1, + "", + StringUtils.stringFromResource(I18N.string.common_error_name_is_blank), + "Empty device name" + ), + arrayOf( + MY_DEVICE_1, + " \t ", + StringUtils.stringFromResource(I18N.string.common_error_name_is_blank), + "Blank device name" + ), + arrayOf( + MY_DEVICE_1, + getRandomString(256), + StringUtils.stringFromResource(I18N.string.common_error_name_too_long, 255), + "Too long (256) name" + ) + ) + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/RenamingComputerSuccessFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/RenamingComputerSuccessFlowTest.kt new file mode 100644 index 00000000..0905a356 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/RenamingComputerSuccessFlowTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.computers + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.ComputersTabRobot +import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.rules.Scenario +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import org.junit.Test + +@HiltAndroidTest +class RenamingComputerSuccessFlowTest : AuthenticatedBaseTest() { + + @Test + @Scenario(value = 2, isDevice = true) + fun renameComputer() { + val computerToBeRenamed = MY_DEVICE_1 + val newComputerName = "My device X" + FilesTabRobot + .clickComputersTab() + .scrollToComputer(computerToBeRenamed) + .clickMoreOnComputer(computerToBeRenamed) + .clickRename() + .clearName() + .typeName(newComputerName) + .clickRename(ComputersTabRobot) + .verify { + itemIsDisplayed(newComputerName) + } + } + + companion object { + private const val MY_DEVICE_1 = "MyDevice1" + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/SyncedFoldersFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/SyncedFoldersFlowTest.kt new file mode 100644 index 00000000..e2568357 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/computers/SyncedFoldersFlowTest.kt @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.computers + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.net.Uri +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.intent.rule.IntentsRule +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.ComputersTabRobot +import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.MoveToFolderRobot +import me.proton.android.drive.ui.robot.ShareRobot +import me.proton.android.drive.ui.rules.ExternalFilesRule +import me.proton.android.drive.ui.rules.Scenario +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import me.proton.core.drive.base.domain.extension.bytes +import me.proton.core.drive.files.presentation.extension.SemanticsDownloadState +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class SyncedFoldersFlowTest : AuthenticatedBaseTest() { + + @get:Rule + val intentsTestRule = IntentsRule() + + @get:Rule + val externalFilesRule = ExternalFilesRule() + + @Test + @Scenario(value = 2, isDevice = true) + fun emptySyncedFolders() { + FilesTabRobot + .clickComputersTab() + .scrollToComputer(MY_DEVICE_3) + .clickOnComputer(MY_DEVICE_3) + .verify { + assertEmptySyncedFolders() + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun uploadAFile() { + val fileName = "1kB.txt" + val file = externalFilesRule.createFile(fileName, 1000.bytes.value) + + Intents.intending(IntentMatchers.hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFunction { + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(Uri.fromFile(file))) + } + + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .clickPlusButton() + .clickUploadAFile() + .verify { + itemIsDisplayed(fileName) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun createAFolderViaPlusButton() { + val newFolderName = "TestFolder" + + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .clickPlusButton() + .clickCreateFolder() + .typeFolderName(newFolderName) + .clickCreate(FilesTabRobot) + .dismissFolderCreateSuccessGrowler(newFolderName, FilesTabRobot) + .verify { + itemIsDisplayed(newFolderName) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun moveFile() { + val file = "file3" + val folder = "folder" + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER_3) + .scrollToItemWithName(file) + .clickMoreOnItem(file) + .clickMove() + .scrollToItemWithName(folder) + .clickOnFolderToMove(folder) + .clickMoveToFolder(folder) + + ComputersTabRobot + .verify { + robotDisplayed() + } + .verify { + itemIsNotDisplayed(file) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun moveFileToAnotherSyncedFolder() { + val file = "file1" + val folder = MY_DEVICE_1_SYNC_FOLDER_3 + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .scrollToItemWithName(file) + .clickMoreOnItem(file) + .clickMove() + .verify { + itemIsDisplayed(file) + } + .clickBack(MoveToFolderRobot) + .verify { + assertMoveButtonIsDisabled() + } + .scrollToItemWithName(folder) + .clickOnFolderToMove(folder) + .clickMoveToFolder(folder) + + ComputersTabRobot + .verify { + robotDisplayed() + } + .clickBack(ComputersTabRobot) + .clickOnFolder(folder) + .verify { + itemIsDisplayed(file) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun renameFile() { + val itemToBeRenamed = "presentation.pdf" + val newItemName = "Dummy_PDF_file.pdf" + + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .scrollToItemWithName(itemToBeRenamed) + .clickMoreOnItem(itemToBeRenamed) + .clickRename() + .clearName() + .typeName(newItemName) + .clickRename() + .scrollToItemWithName(newItemName) + .verify { + itemIsDisplayed(newItemName) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun shareFile() { + val file = "picWithThumbnail.jpg" + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .clickMoreOnItem(file) + .clickGetLink() + .verifyShareLinkFile(file) + + ShareRobot + .clickBack(ComputersTabRobot) + + FilesTabRobot + .clickSharedTab() + .scrollToItemWithName(file) + .verify { + itemIsDisplayed(file) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun moveFileToTrash() { + val file = "picWithThumbnail.jpg" + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .clickMoreOnItem(file) + .clickMoveToTrash() + .dismissMoveToTrashSuccessGrowler(1, FilesTabRobot) + .verify { + itemIsNotDisplayed(file) + } + .openSidebarBySwipe() + .clickTrash() + .verify { + itemIsDisplayed(file) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun restoreFromTrash() { + val file = "trashedFileInDevice" + FilesTabRobot + .clickSidebarButton() + .clickTrash() + .clickMoreOnItem(file) + .clickRestoreTrash() + .clickBack(FilesTabRobot) + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .verify { + itemIsDisplayed(file) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun makeAvailableOffline() { + val file = "presentation.pdf" + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .scrollToItemWithName(file) + .clickMoreOnItem(file) + .clickMakeAvailableOffline() + .verify { + itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded) + } + .openSidebarBySwipe() + .clickOffline() + .verify { + itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded) + } + } + + @Test + @Scenario(value = 2, isDevice = true) + fun previewPdfFile() { + val file = "presentation.pdf" + FilesTabRobot + .navigateToComputerSyncedFolder(MY_DEVICE_1, MY_DEVICE_1_SYNC_FOLDER) + .scrollToItemWithName(file) + .clickOnFile(file) + .verify { + nodeWithTextDisplayed("1 / 1") + } + } + + private fun FilesTabRobot.navigateToComputerSyncedFolder( + computerName: String, + syncedFolderName: String, + ): FilesTabRobot = + this + .clickComputersTab() + .scrollToComputer(computerName) + .clickOnComputer(computerName) + .clickOnFolder(syncedFolderName) + + companion object { + private const val MY_DEVICE_1 = "MyDevice1" + private const val MY_DEVICE_1_SYNC_FOLDER = "syncFolder" + private const val MY_DEVICE_1_SYNC_FOLDER_3 = "syncFolder3" + private const val MY_DEVICE_3 = "MyDevice3" + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderEmptyFlowSuccessTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderEmptyFlowSuccessTest.kt index 122c6816..799d2237 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderEmptyFlowSuccessTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderEmptyFlowSuccessTest.kt @@ -21,6 +21,7 @@ package me.proton.android.drive.ui.test.flow.creatingFolder import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.CreateFolderRobot import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.android.drive.utils.getRandomString import org.junit.Test @@ -32,7 +33,8 @@ class CreatingFolderEmptyFlowSuccessTest : AuthenticatedBaseTest() { @Test fun createAFolderViaPlusButton() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickPlusButton() .clickCreateFolder() @@ -42,7 +44,8 @@ class CreatingFolderEmptyFlowSuccessTest : AuthenticatedBaseTest() { @Test fun createChildFolderViaCTAButton() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickAddFilesButton() .clickCreateFolder() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderFlowErrorTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderFlowErrorTest.kt index 396fc091..9a796c23 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderFlowErrorTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderFlowErrorTest.kt @@ -19,6 +19,7 @@ package me.proton.android.drive.ui.test.flow.creatingFolder import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.android.drive.utils.getRandomString @@ -39,7 +40,8 @@ class CreatingFolderFlowErrorTest( @Test @Scenario(2) fun createFolderError() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickPlusButton() .clickCreateFolder() .typeFolderName(folderName) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderFlowSuccessTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderFlowSuccessTest.kt index 97b91b01..2e39f497 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderFlowSuccessTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/creatingFolder/CreatingFolderFlowSuccessTest.kt @@ -22,6 +22,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.CreateFolderRobot import me.proton.android.drive.ui.robot.FilesTabRobot import me.proton.android.drive.ui.robot.MoveToFolderRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.android.drive.ui.test.SmokeTest @@ -40,7 +41,8 @@ class CreatingFolderFlowSuccessTest : AuthenticatedBaseTest() { val subFolderName = "folder1" val newFolderName = getRandomString() - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(subFolderName) .clickMoreOnItem(subFolderName) .clickMove() @@ -57,7 +59,8 @@ class CreatingFolderFlowSuccessTest : AuthenticatedBaseTest() { @Test @Scenario(2) fun createFolderInGridLayout() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickLayoutSwitcher() .clickPlusButton() .clickCreateFolder() @@ -70,7 +73,8 @@ class CreatingFolderFlowSuccessTest : AuthenticatedBaseTest() { fun createAFolderViaSubFolderPlusButton() { val subFolderName = "folder1" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickOnFolder(subFolderName) .clickPlusButton() .clickCreateFolder() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/deeplink/DeeplinkFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/deeplink/DeeplinkFlowTest.kt new file mode 100644 index 00000000..6e4c9d36 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/deeplink/DeeplinkFlowTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.deeplink + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.navigation.Screen +import me.proton.android.drive.ui.robot.ComputersTabRobot +import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.LauncherRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot +import me.proton.android.drive.ui.robot.SharedTabRobot +import me.proton.android.drive.ui.robot.StorageFullRobot +import me.proton.android.drive.ui.rules.UserLoginRule +import me.proton.android.drive.ui.test.EmptyBaseTest +import me.proton.android.drive.utils.getRandomString +import me.proton.core.test.quark.data.User +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class DeeplinkFlowTest : EmptyBaseTest() { + + private val testUser = User(name = "proton_drive_${getRandomString(15)}") + + @get:Rule(order = 1) + val userLoginRule: UserLoginRule = + UserLoginRule(testUser, quarkCommands = quarkRule.quarkCommands) + + @Test + fun launchFiles() { + LauncherRobot.launch("files", FilesTabRobot) + .verify { robotDisplayed() } + } + + @Test + fun homeFiles() { + LauncherRobot.deeplinkTo(Screen.Files(userLoginRule.userId!!), FilesTabRobot) + .verify { robotDisplayed() } + } + + @Test + fun launchPhotos() { + LauncherRobot.launch("photos", PhotosTabRobot) + .verify { robotDisplayed() } + } + + @Test + fun homePhotos() { + LauncherRobot.deeplinkTo(Screen.Photos(userLoginRule.userId!!), PhotosTabRobot) + .verify { robotDisplayed() } + } + + @Test + fun launchComputers() { + LauncherRobot.launch("computers", ComputersTabRobot) + .verify { robotDisplayed() } + } + + @Test + fun homeComputers() { + LauncherRobot.deeplinkTo(Screen.Computers(userLoginRule.userId!!), ComputersTabRobot) + .verify { robotDisplayed() } + } + + + @Test + fun launchShared() { + LauncherRobot.launch("shared", SharedTabRobot) + .verify { robotDisplayed() } + } + @Test + fun homeShared() { + LauncherRobot.deeplinkTo(Screen.Shared(userLoginRule.userId!!), SharedTabRobot) + .verify { robotDisplayed() } + } + + @Test + fun storageFull() { + LauncherRobot.deeplinkTo( + Screen.Dialogs.StorageFull(userLoginRule.userId!!), + StorageFullRobot + ) + .verify { robotDisplayed() } + .clickDismiss(PhotosTabRobot) + .verify { robotDisplayed() } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/details/DetailsFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/details/DetailsFlowTest.kt index 5a3f941b..aa46c81f 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/details/DetailsFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/details/DetailsFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -21,6 +21,7 @@ package me.proton.android.drive.ui.test.flow.details import android.os.Build import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.core.drive.base.domain.entity.TimestampS @@ -36,7 +37,8 @@ class DetailsFlowTest : AuthenticatedBaseTest() { @Test @Scenario(4) fun checkFileDetailsAndCloseDetails() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .verify { robotDisplayed() } .scrollToItemWithName(sharedImage.name) .clickMoreOnItem(sharedImage.name) @@ -85,7 +87,8 @@ class DetailsFlowTest : AuthenticatedBaseTest() { @Test @Scenario(4) fun checkFolderDetailsAndCloseDetails() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .verify { robotDisplayed() } .scrollToItemWithName(sharedFolder.name) .clickMoreOnItem(sharedFolder.name) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileDeepFlowSuccessTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileDeepFlowSuccessTest.kt index 9e5c5f1c..d2ed3468 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileDeepFlowSuccessTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileDeepFlowSuccessTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -20,6 +20,7 @@ package me.proton.android.drive.ui.test.flow.move import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test @@ -34,7 +35,8 @@ class MoveFileDeepFlowSuccessTest : AuthenticatedBaseTest() { val file = "file4" val folder2 = "folder2" val folder5 = "folder5" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickOnFolder(folder2) .clickOnFolder(folder5) .clickMoreOnItem(file) @@ -57,7 +59,8 @@ class MoveFileDeepFlowSuccessTest : AuthenticatedBaseTest() { val folder1 = "folder1" val folder2 = "folder2" val folder5 = "folder5" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickOnFolder(folder2) .clickOnFolder(folder5) .clickMoreOnItem(file) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileFlowErrorTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileFlowErrorTest.kt index 35b120ec..44020363 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileFlowErrorTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileFlowErrorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ package me.proton.android.drive.ui.test.flow.move import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test @@ -33,7 +33,8 @@ class MoveFileFlowErrorTest: AuthenticatedBaseTest() { val file = "sharedChild.html" val folderSource = "expiredSharedFolder" val folderDestination = "sharedFolder" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(folderSource) .clickOnFolder(folderSource) .verify { diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileFlowSuccessTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileFlowSuccessTest.kt index 2c99cf7c..aa3f6720 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileFlowSuccessTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFileFlowSuccessTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -20,9 +20,11 @@ package me.proton.android.drive.ui.test.flow.move import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test +import kotlin.time.Duration.Companion.seconds import me.proton.core.drive.i18n.R as I18N @HiltAndroidTest @@ -32,7 +34,8 @@ class MoveFileFlowSuccessTest : AuthenticatedBaseTest() { fun moveAFileToRoot() { val file = "sharedChild.html" val folder = "sharedFolder" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(folder) .clickOnFolder(folder) .scrollToItemWithName(file) @@ -56,7 +59,8 @@ class MoveFileFlowSuccessTest : AuthenticatedBaseTest() { fun moveRootFileToFolder() { val file = "shared.jpg" val folder = "folder3" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(file) .clickMoreOnItem(file) .clickMove() @@ -79,7 +83,8 @@ class MoveFileFlowSuccessTest : AuthenticatedBaseTest() { fun moveFilesFromRootFolderToAnotherFolder() { val file = "shared.jpg" val folder = "folder3" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(file) .longClickOnItem(file) .clickOptions() @@ -91,4 +96,33 @@ class MoveFileFlowSuccessTest : AuthenticatedBaseTest() { } // TODO: Verify that the file is really moved when DRVAND-449 is fixed } + + @Test + @Scenario(4) + // This test might be flaky due to snackbar not showing + fun undoMoveRootFileToFolder() { + val file = "shared.jpg" + val folder = "folder3" + PhotosTabRobot + .clickFilesTab() + .scrollToItemWithName(file) + .clickMoreOnItem(file) + .clickMove() + .scrollToItemWithName(folder) + .clickOnFolderToMove(folder) + .clickMoveToFolder(folder) + .verify { + nodeWithTextDisplayed(I18N.string.file_operation_moving_file_successful) + } + // Server sometimes responds with 422 "This file or folder was out of date, move failed." + // if undo is called too quickly, thus a 2 seconds delay before clicking on Undo + .clickOnUndo(after = 2.seconds, FilesTabRobot) + .verify { + nodeWithTextDisplayed(I18N.string.file_operation_moving_file_successful) + } + .scrollToItemWithName(file) + .verify { + itemIsDisplayed(file) + } + } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFolderFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFolderFlowTest.kt index 8e4af466..79ac2189 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFolderFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFolderFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -22,6 +22,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.annotation.Slow import me.proton.android.drive.ui.robot.FilesTabRobot import me.proton.android.drive.ui.robot.MoveToFolderRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test @@ -36,7 +37,7 @@ class MoveFolderFlowTest : AuthenticatedBaseTest() { fun moveAFolderToRoot() { val parent = "folder1" val folder = "folder3" - FilesTabRobot + PhotosTabRobot .clickOnFolder(parent) .clickMoreOnItem(folder) .clickMove() @@ -57,7 +58,8 @@ class MoveFolderFlowTest : AuthenticatedBaseTest() { fun moveRootFolderToFolder() { val folder = "folder1" val folderDestination = "folder2" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMove() .clickOnFolderToMove(folderDestination) @@ -78,7 +80,8 @@ class MoveFolderFlowTest : AuthenticatedBaseTest() { val parent = "folder1" val folder = "folder3" val folderDestination = "folder2" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickOnFolder(parent) .clickMoreOnItem(folder) .clickMove() @@ -102,7 +105,8 @@ class MoveFolderFlowTest : AuthenticatedBaseTest() { @Scenario(2) fun cannotMoveAFolderToSubFolderOfItself() { val folder = "folder1" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMove() .clickOnFolder(folder) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFolderGridFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFolderGridFlowTest.kt new file mode 100644 index 00000000..b50eafff --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/move/MoveFolderGridFlowTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.move + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.PhotosTabRobot +import me.proton.android.drive.ui.rules.Scenario +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import me.proton.core.drive.files.presentation.extension.LayoutType +import org.junit.Test +import me.proton.core.drive.i18n.R as I18N + +@HiltAndroidTest +class MoveFolderGridFlowTest : AuthenticatedBaseTest() { + + @Test + @Scenario(1) + fun moveAFolderInGrid() { + val folder = "folder1" + val folderDestination = "folder2" + + PhotosTabRobot + .clickFilesTab() + .clickLayoutSwitcher() + .scrollToItemWithName(folder) + .clickMoreOnItem(folder) + .clickMove() + .clickOnFolderToMove(folderDestination) + .clickMoveToFolder(folderDestination) + .verify { + nodeWithTextDisplayed(I18N.string.file_operation_moving_folder_successful) + robotDisplayed() + } + .scrollToItemWithName(folderDestination) + .clickOnFolder(folderDestination, LayoutType.Grid) + .verify { + itemIsDisplayed(folder) + } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/AvailableOfflineFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/AvailableOfflineFlowTest.kt new file mode 100644 index 00000000..9cd1199b --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/AvailableOfflineFlowTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.offline + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot +import me.proton.android.drive.ui.rules.NetworkSimulator +import me.proton.android.drive.ui.rules.Scenario +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import me.proton.core.drive.files.presentation.extension.SemanticsDownloadState +import org.junit.Test + +@HiltAndroidTest +class AvailableOfflineFlowTest : AuthenticatedBaseTest() { + + @Test + fun emptyAvailableOfflineScreen() { + PhotosTabRobot + .openSidebarBySwipe() + .clickOffline() + .verify { + assertIsEmpty() + } + } + + @Test + @Scenario(2) + fun aChildOfADeletedFolderShouldNotBeDisplayedInAvailableOffline() { + val folder = "folder1" + val file = "file2" + PhotosTabRobot + .clickFilesTab() + .clickOnFolder(folder) + .clickMoreOnItem(file) + .clickMakeAvailableOffline() + .verify { + itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded) + } + .clickBack(FilesTabRobot) + .clickMoreOnItem(folder) + .clickMoveToTrash() + .dismissMoveToTrashSuccessGrowler(1, FilesTabRobot) + .openSidebarBySwipe() + .clickOffline() + .verify { + itemIsNotDisplayed(file) + assertIsEmpty() + } + } + + @Test + @Scenario(2) + fun previewItemsWithoutConnection() { + val file = "image.jpg" + PhotosTabRobot + .clickFilesTab() + .clickMoreOnItem(file) + .clickMakeAvailableOffline() + .verify { + itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded) + } + + NetworkSimulator.disableNetwork() + + FilesTabRobot + .clickOnFile(file) + .verify { + assertPreviewIsDisplayed(file) + } + } + + @Test + @Scenario(2) + fun newlyAddedFilesToAFolderShouldBeDownloadedInstantly() { + + val file = "image.jpg" + val folder = "folder1" + PhotosTabRobot + .clickFilesTab() + .clickMoreOnItem(folder) + .clickMakeAvailableOffline() + .verify { + // Wait for folder to be downloaded before after adding a new file + // else it will be ignored + itemIsDisplayed(folder, downloadState = SemanticsDownloadState.Downloaded) + } + .clickMoreOnItem(file) + .clickMove() + .clickOnFolderToMove(folder) + .clickMoveToFolder(folder) + .clickOnFolder(folder) + .verify { + itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded) + } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/MakeAvailableOfflineFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/MakeAvailableOfflineFlowTest.kt index a5957b74..d400c5eb 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/MakeAvailableOfflineFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/MakeAvailableOfflineFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -20,12 +20,15 @@ package me.proton.android.drive.ui.test.flow.offline import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.data.ImageName +import me.proton.android.drive.ui.robot.FileFolderOptionsRobot import me.proton.android.drive.ui.robot.FilesTabRobot import me.proton.android.drive.ui.robot.PhotosTabRobot +import me.proton.android.drive.ui.rules.NetworkSimulator import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.core.drive.files.presentation.extension.SemanticsDownloadState import org.junit.Test +import kotlin.time.Duration.Companion.seconds @HiltAndroidTest class MakeAvailableOfflineFlowTest : AuthenticatedBaseTest() { @@ -34,7 +37,8 @@ class MakeAvailableOfflineFlowTest : AuthenticatedBaseTest() { fun makeFileAvailableOffline() { val folder = "folder1" val file = "presentation.pdf" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickOnFolder(folder) .scrollToItemWithName(file) .clickMoreOnItem(file) @@ -54,7 +58,8 @@ class MakeAvailableOfflineFlowTest : AuthenticatedBaseTest() { fun makeFolderWithChildFolderAvailableOffline() { val folder = "folder2" val subfolder = "folder5" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMakeAvailableOffline() .verify { @@ -76,7 +81,8 @@ class MakeAvailableOfflineFlowTest : AuthenticatedBaseTest() { @Scenario(4) fun makeEmptyFolderAvailableOffline() { val folder = "folder1" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMakeAvailableOffline() .verify { @@ -95,8 +101,7 @@ class MakeAvailableOfflineFlowTest : AuthenticatedBaseTest() { val firstImage = ImageName.Yesterday val secondImage = ImageName.Now - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .verify { assertPhotoDisplayed(firstImage) assertPhotoDisplayed(secondImage) @@ -124,7 +129,8 @@ class MakeAvailableOfflineFlowTest : AuthenticatedBaseTest() { fun makeFolderWithFileInsideAvailableOffline() { val folder = "sharedFolder" val file = "sharedChild.html" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMakeAvailableOffline() .verify { @@ -151,7 +157,8 @@ class MakeAvailableOfflineFlowTest : AuthenticatedBaseTest() { fun makeFolderAndChildFileAvailableOfflineSeparately() { val folder = "sharedFolder" val file = "sharedChild.html" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMakeAvailableOffline() .clickOnFolder(folder) @@ -165,4 +172,24 @@ class MakeAvailableOfflineFlowTest : AuthenticatedBaseTest() { itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded) } } + + @Test + @Scenario(2) + fun loseConnectionWhenDownloadingIsStarted() { + + val file = "image.jpg" + + PhotosTabRobot + .clickFilesTab() + .clickMoreOnItem(file) + + NetworkSimulator.disableNetworkFor(5.seconds) { + FileFolderOptionsRobot + .clickMakeAvailableOffline() + } + + FilesTabRobot.verify { + itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded) + } + } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/RemoveAvailableOfflineFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/RemoveAvailableOfflineFlowTest.kt index aa23efe8..2791e998 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/RemoveAvailableOfflineFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/RemoveAvailableOfflineFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -35,7 +35,8 @@ class RemoveAvailableOfflineFlowTest : AuthenticatedBaseTest() { @Scenario(4) fun removeFileFromAvailableOfflineFromMyFile() { val file = "shared.jpg" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(file) .clickMoreOnItem(file) .clickMakeAvailableOffline() @@ -53,7 +54,8 @@ class RemoveAvailableOfflineFlowTest : AuthenticatedBaseTest() { @Scenario(4) fun removeFileFromAvailableOfflineFromOffline() { val file = "shared.jpg" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(file) .clickMoreOnItem(file) .clickMakeAvailableOffline() @@ -74,7 +76,8 @@ class RemoveAvailableOfflineFlowTest : AuthenticatedBaseTest() { @Scenario(4) fun removeFolderFromAvailableOffline() { val folder = "folder1" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMakeAvailableOffline() .verify { @@ -99,7 +102,8 @@ class RemoveAvailableOfflineFlowTest : AuthenticatedBaseTest() { fun separatelyAddedFileAreAvailableOfflineAfterParentIsRemoved() { val folder = "sharedFolder" val file = "sharedChild.html" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMakeAvailableOffline() .clickOnFolder(folder) @@ -132,8 +136,7 @@ class RemoveAvailableOfflineFlowTest : AuthenticatedBaseTest() { fun removePhotoFromAvailableOffline() { val image = ImageName.Now - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .longClickOnPhoto(image.fileName) .clickOptions() .clickMakeAvailableOffline() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/RemoveAvailableOfflineLargeFileFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/RemoveAvailableOfflineLargeFileFlowTest.kt index 3f993699..4c8627a9 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/RemoveAvailableOfflineLargeFileFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/offline/RemoveAvailableOfflineLargeFileFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -20,6 +20,7 @@ package me.proton.android.drive.ui.test.flow.offline import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.core.drive.files.presentation.extension.SemanticsDownloadState @@ -33,7 +34,8 @@ class RemoveAvailableOfflineLargeFileFlowTest : AuthenticatedBaseTest() { val folder = "folder1" val file = "40mbfile" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickOnFolder(folder) .scrollToItemWithName(file) .clickMoreOnItem(file) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/NoConnectivityTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/NoConnectivityTest.kt index 5d38a3e1..bb4c5e61 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/NoConnectivityTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/NoConnectivityTest.kt @@ -19,7 +19,6 @@ package me.proton.android.drive.ui.test.flow.photos import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.NetworkSimulator import me.proton.android.drive.ui.test.PhotosBaseTest @@ -32,8 +31,7 @@ class NoConnectivityTest: PhotosBaseTest() { @Before fun prepare() { pictureCameraFolder.copyDirFromAssets("images/basic") - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .verify { robotDisplayed() assertEnableBackupDisplayed() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/NoPermissionsTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/NoPermissionsTest.kt index 0dfb3729..64304233 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/NoPermissionsTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/NoPermissionsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,10 +19,8 @@ package me.proton.android.drive.ui.test.flow.photos import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot import me.proton.android.drive.ui.robot.PhotosNoPermissionsRobot import me.proton.android.drive.ui.robot.PhotosTabRobot -import me.proton.android.drive.ui.robot.SettingsRobot import me.proton.android.drive.ui.robot.settings.PhotosBackupRobot import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test @@ -31,8 +29,7 @@ import org.junit.Test class NoPermissionsTest : AuthenticatedBaseTest() { @Test fun denyPermissionsFromPhotoTab() { - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .enableBackup() PhotosNoPermissionsRobot @@ -48,7 +45,11 @@ class NoPermissionsTest : AuthenticatedBaseTest() { @Test fun denyPermissionsFromSettings() { - FilesTabRobot + PhotosTabRobot + .verify { + // Wait for photo share to be created to avoid collision + assertEnableBackupDisplayed() + } .openSidebarBySwipe() .clickSettings() .clickPhotosBackup() @@ -62,4 +63,4 @@ class NoPermissionsTest : AuthenticatedBaseTest() { robotDisplayed() } } -} \ No newline at end of file +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/PhotosSyncFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/PhotosSyncFlowTest.kt index 2488c407..52f79f68 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/PhotosSyncFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/PhotosSyncFlowTest.kt @@ -24,29 +24,43 @@ import dagger.hilt.InstallIn import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules import dagger.hilt.components.SingletonComponent +import me.proton.android.drive.extension.debug import me.proton.android.drive.photos.data.di.PhotosConfigurationModule import me.proton.android.drive.photos.domain.provider.PhotosDefaultConfigurationProvider import me.proton.android.drive.provider.PhotosConnectedDefaultConfigurationProvider +import me.proton.android.drive.ui.annotation.FeatureFlag import me.proton.android.drive.ui.robot.FilesTabRobot import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.robot.SettingsRobot +import me.proton.android.drive.ui.robot.settings.PhotosBackupRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.PhotosBaseTest +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED +import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_PHOTOS_UPLOAD_DISABLED +import org.junit.Before import org.junit.Test +import javax.inject.Inject import javax.inject.Singleton @HiltAndroidTest @UninstallModules(PhotosConfigurationModule::class) class PhotosSyncFlowTest : PhotosBaseTest() { + @Inject lateinit var configurationProvider: ConfigurationProvider + + @Before + fun setUp(){ + configurationProvider.debug.photosUpsellPhotoCount = Int.MAX_VALUE + } + @Test @Scenario(2) fun syncMultipleFolders() { pictureCameraFolder.copyDirFromAssets("images/basic") dcimCameraFolder.copyFileFromAssets("boat.jpg") - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .enableBackup() .verify { assertBackupCompleteDisplayed() @@ -59,8 +73,7 @@ class PhotosSyncFlowTest : PhotosBaseTest() { fun syncNewPhotos() { dcimCameraFolder.copyDirFromAssets("images/basic") - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .enableBackup() .verify { assertBackupCompleteDisplayed() @@ -81,8 +94,7 @@ class PhotosSyncFlowTest : PhotosBaseTest() { dcimCameraFolder.copyDirFromAssets("images/basic") dcimCameraFolder.copyFileFromAssets("boat.mp4") - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .enableBackup() dcimCameraFolder.copyFileFromAssets("boat.jpg") @@ -101,8 +113,7 @@ class PhotosSyncFlowTest : PhotosBaseTest() { val videoFile = "boat.mp4" pictureCameraFolder.copyFileFromAssets(videoFile) - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .enableBackup() .verify { assertPhotoDisplayed(videoFile) @@ -122,7 +133,11 @@ class PhotosSyncFlowTest : PhotosBaseTest() { fun turnOnBackupWithFilesInCameraFolderFromSettings() { dcimCameraFolder.copyFileFromAssets("boat.jpg") - FilesTabRobot + PhotosTabRobot + .verify { + // wait photo share to be created + assertEnableBackupDisplayed() + } .openSidebarBySwipe() .clickSettings() .clickPhotosBackup() @@ -142,7 +157,7 @@ class PhotosSyncFlowTest : PhotosBaseTest() { @Test fun noPhotoInCameraFolder() { - FilesTabRobot + PhotosTabRobot .openSidebarBySwipe() .clickSettings() .clickPhotosBackup() @@ -155,7 +170,11 @@ class PhotosSyncFlowTest : PhotosBaseTest() { fun photosFolderEnableFromSettings() { picturePhotosFolder.copyFileFromAssets("boat.jpg") - FilesTabRobot + PhotosTabRobot + .verify { + // wait photo share to be created + assertNoBackupsDisplayed() + } .openSidebarBySwipe() .clickSettings() .clickPhotosBackup() @@ -177,8 +196,7 @@ class PhotosSyncFlowTest : PhotosBaseTest() { fun photosFolderEnableFromPhoto() { picturePhotosFolder.copyFileFromAssets("boat.jpg") - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .verify { assertMissingFolderDisplayed() } @@ -199,7 +217,7 @@ class PhotosSyncFlowTest : PhotosBaseTest() { fun photosFolderEnableAndDisableFromSettings() { picturePhotosFolder.copyFileFromAssets("boat.jpg") - FilesTabRobot + PhotosTabRobot .openSidebarBySwipe() .clickSettings() .clickPhotosBackup() @@ -227,8 +245,7 @@ class PhotosSyncFlowTest : PhotosBaseTest() { pictureCameraFolder.copyDirFromAssets("videos/formats") val videoFiles = arrayOf("3gp.3gp", "mov.mov", "mp4.mp4") - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .enableBackup() .verify { assertBackupCompleteDisplayed() @@ -250,8 +267,7 @@ class PhotosSyncFlowTest : PhotosBaseTest() { pictureCameraFolder.copyDirFromAssets("images/basic/1") pictureCameraFolder.copyDirFromAssets("videos/formats") - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .enableBackup() .verify { assertLeftToBackup(5) @@ -270,13 +286,36 @@ class PhotosSyncFlowTest : PhotosBaseTest() { } } + @Test + @FeatureFlag(DRIVE_PHOTOS_UPLOAD_DISABLED, ENABLED) + fun featureDisabled() { + PhotosTabRobot + .verify { + assertPhotosBackupDisabled() + } + } + @Test + @FeatureFlag(DRIVE_PHOTOS_UPLOAD_DISABLED, ENABLED) + fun featureDisabledFromSettings() { + PhotosTabRobot + .openSidebarBySwipe() + .clickSettings() + .clickPhotosBackup() + .clickBackupToggle(PhotosBackupRobot) + .verify { + assertPhotosBackupDisabled() + } + } @Module @InstallIn(SingletonComponent::class) + @Suppress("Unused") interface TestPhotosConfigurationModule { @Binds @Singleton - fun bindPhotosDefaultConfigurationProvider(impl: PhotosConnectedDefaultConfigurationProvider): PhotosDefaultConfigurationProvider + fun bindPhotosDefaultConfigurationProvider( + impl: PhotosConnectedDefaultConfigurationProvider, + ): PhotosDefaultConfigurationProvider } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/UpsellPhotosUsersTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/UpsellPhotosUsersTest.kt new file mode 100644 index 00000000..88dbb4a9 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/UpsellPhotosUsersTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.photos + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import dagger.hilt.components.SingletonComponent +import me.proton.android.drive.photos.data.di.PhotosConfigurationModule +import me.proton.android.drive.photos.domain.provider.PhotosDefaultConfigurationProvider +import me.proton.android.drive.provider.PhotosConnectedDefaultConfigurationProvider +import me.proton.android.drive.ui.robot.PhotosTabRobot +import me.proton.android.drive.ui.robot.PhotosUpsellRobot +import me.proton.android.drive.ui.rules.Scenario +import me.proton.android.drive.ui.rules.UserPlan +import me.proton.android.drive.ui.test.PhotosBaseTest +import me.proton.core.plan.test.robot.SubscriptionRobot +import me.proton.core.test.quark.data.Plan +import org.junit.Before +import org.junit.Test +import javax.inject.Singleton + +@HiltAndroidTest +@UninstallModules(PhotosConfigurationModule::class) +class UpsellPhotosUsersTest : PhotosBaseTest() { + + @Before fun setUp(){ + pictureCameraFolder.copyDirFromAssets("images/basic") + dcimCameraFolder.copyFileFromAssets("boat.jpg") + + PhotosTabRobot + .enableBackup() + .verify { + assertBackupCompleteDisplayed() + assertPhotoCountEquals(5) + } + } + + @Test + @Scenario(2) + @UserPlan(Plan.Free) + fun upsellPopUpIsShownForFreeUsers() { + PhotosUpsellRobot + .verify { + robotDisplayed() + } + .clickGetStorage() + .verify { + SubscriptionRobot.verifySubscriptionIsShown() + } + } + + @Test + @Scenario(2) + @UserPlan(Plan.PassPlus) + fun upsellPopUpIsShownForPassPlusUsers() { + PhotosUpsellRobot + .verify { + robotDisplayed() + } + .clickGetStorage() + .verify { + SubscriptionRobot.verifySubscriptionIsShown() + } + } + + @Test + @Scenario(2) + @UserPlan(Plan.MailPlus) + fun upsellPopUpIsNotShownForMailPlusUsers() { + PhotosUpsellRobot + .verify { + robotDoesNotExist() + } + } + + @Test + @Scenario(2) + @UserPlan(Plan.Unlimited) + fun upsellPopUpIsNotShownForDriveUsers() { + PhotosUpsellRobot + .verify { + robotDoesNotExist() + } + } + + @Module + @InstallIn(SingletonComponent::class) + @Suppress("Unused") + interface TestPhotosConfigurationModule { + @Binds + @Singleton + fun bindPhotosDefaultConfigurationProvider( + impl: PhotosConnectedDefaultConfigurationProvider, + ): PhotosDefaultConfigurationProvider + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/UsedSpaceTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/UsedSpaceTest.kt index be2ae4d8..a398ac61 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/UsedSpaceTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/photos/UsedSpaceTest.kt @@ -28,7 +28,6 @@ import me.proton.android.drive.photos.data.di.PhotosConfigurationModule import me.proton.android.drive.photos.domain.provider.PhotosDefaultConfigurationProvider import me.proton.android.drive.provider.PhotosConnectedDefaultConfigurationProvider import me.proton.android.drive.ui.annotation.Quota -import me.proton.android.drive.ui.robot.FilesTabRobot import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.test.PhotosBaseTest import org.junit.Before @@ -43,8 +42,7 @@ class UsedSpaceTest : PhotosBaseTest() { fun prepare() { dcimCameraFolder.copyFileFromAssets("boat.jpg") - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .enableBackup() } @@ -86,9 +84,12 @@ class UsedSpaceTest : PhotosBaseTest() { @Module @InstallIn(SingletonComponent::class) + @Suppress("Unused") interface TestPhotosConfigurationModule { @Binds @Singleton - fun bindPhotosDefaultConfigurationProvider(impl: PhotosConnectedDefaultConfigurationProvider): PhotosDefaultConfigurationProvider + fun bindPhotosDefaultConfigurationProvider( + impl: PhotosConnectedDefaultConfigurationProvider, + ): PhotosDefaultConfigurationProvider } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/preview/PreviewFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/preview/PreviewFlowTest.kt index cc50814e..e952f7bd 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/preview/PreviewFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/preview/PreviewFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ package me.proton.android.drive.ui.test.flow.preview import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.android.drive.ui.test.SmokeTest @@ -36,7 +36,9 @@ class PreviewFlowTest : AuthenticatedBaseTest() { @Scenario(1) fun previewTextFileInGrid() { val file = "example.txt" - FilesTabRobot.verify { robotDisplayed() } + PhotosTabRobot + .clickFilesTab() + .verify { robotDisplayed() } .clickLayoutSwitcher() .clickOnFolder(parent, LayoutType.Grid) .scrollToItemWithName(file) @@ -51,7 +53,9 @@ class PreviewFlowTest : AuthenticatedBaseTest() { @SmokeTest fun previewImageFile() { val file = "image.jpg" - FilesTabRobot.verify { robotDisplayed() } + PhotosTabRobot + .clickFilesTab() + .verify { robotDisplayed() } .clickOnFolder(parent) .scrollToItemWithName(file) .clickOnFile(file) @@ -64,7 +68,9 @@ class PreviewFlowTest : AuthenticatedBaseTest() { @Scenario(1) fun previewBrokenImageFile() { val file = "broken.jpg" - FilesTabRobot.verify { robotDisplayed() } + PhotosTabRobot + .clickFilesTab() + .verify { robotDisplayed() } .clickOnFolder(parent) .scrollToItemWithName(file) .clickOnFile(file) @@ -77,7 +83,9 @@ class PreviewFlowTest : AuthenticatedBaseTest() { @Scenario(1) fun previewPdfFile() { val file = "presentation.pdf" - FilesTabRobot.verify { robotDisplayed() } + PhotosTabRobot + .clickFilesTab() + .verify { robotDisplayed() } .clickOnFolder(parent) .scrollToItemWithName(file) .clickOnFile(file) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/preview/PreviewPhotosFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/preview/PreviewPhotosFlowTest.kt index 75d0fa81..0d6b9333 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/preview/PreviewPhotosFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/preview/PreviewPhotosFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -20,26 +20,22 @@ package me.proton.android.drive.ui.test.flow.preview import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.data.ImageName -import me.proton.android.drive.ui.robot.FilesTabRobot import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import me.proton.android.drive.ui.test.SmokeTest import me.proton.test.fusion.FusionConfig import me.proton.test.fusion.ui.common.enums.SwipeDirection import org.junit.Before import org.junit.Test import kotlin.time.Duration.Companion.seconds -import me.proton.android.drive.ui.test.SmokeTest @HiltAndroidTest class PreviewPhotosFlowTest: AuthenticatedBaseTest() { @Before - fun goToPhotosTab() { - FilesTabRobot - .clickPhotosTab() - - FusionConfig.Compose.waitTimeout.set(15.seconds) + fun setUp() { + FusionConfig.Compose.waitTimeout.set(30.seconds) } @Test diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFileSuccessFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFileSuccessFlowTest.kt index f2b83347..afd462a8 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFileSuccessFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFileSuccessFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ package me.proton.android.drive.ui.test.flow.rename import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.robot.PreviewRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest @@ -35,7 +35,8 @@ class RenamingFileSuccessFlowTest : AuthenticatedBaseTest() { val oldName = "image.jpg" val newName = "picture.jpg" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(oldName) .clickOnFile(oldName) .clickOnContextualButton() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFlowSuccessTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFlowSuccessTest.kt index 9d39753a..41220240 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFlowSuccessTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFlowSuccessTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ package me.proton.android.drive.ui.test.flow.rename import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test @@ -36,7 +36,8 @@ class RenamingFlowSuccessTest( @Test @Scenario(4) fun renameSuccess() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(itemToBeRenamed) .clickMoreOnItem(itemToBeRenamed) .clickRename() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFolderFlowErrorTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFolderFlowErrorTest.kt index ec8334c4..2642bc81 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFolderFlowErrorTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingFolderFlowErrorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ package me.proton.android.drive.ui.test.flow.rename import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.android.drive.utils.getRandomString @@ -41,7 +41,8 @@ class RenamingFolderFlowErrorTest( @Test @Scenario(4) fun renameError() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(itemToBeRenamed) .clickMoreOnItem(itemToBeRenamed) .clickRename() @@ -60,7 +61,7 @@ class RenamingFolderFlowErrorTest( arrayOf( "folder1", "folder2", - "An item with that name already exists in current folder", + "A file or folder with that name already exists", "Existing folder" ), arrayOf( @@ -74,7 +75,13 @@ class RenamingFolderFlowErrorTest( getRandomString(256), StringUtils.stringFromResource(I18N.string.common_error_name_too_long, 255), "Very long name" - ) + ), + arrayOf( + "folder1", + "folder1/", + StringUtils.stringFromResource(I18N.string.common_error_name_with_forbidden_characters), + "Forbidden characters" + ), ) } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingGridSuccessFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingGridSuccessFlowTest.kt new file mode 100644 index 00000000..1cf08fca --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingGridSuccessFlowTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.rename + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.PhotosTabRobot +import me.proton.android.drive.ui.rules.Scenario +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import org.junit.Test + +@HiltAndroidTest +class RenamingGridSuccessFlowTest : AuthenticatedBaseTest() { + + @Test + @Scenario(4) + fun renameFileInGrid() { + val file = "conf.json" + val renamedFile = "fnoc.json" + + PhotosTabRobot + .clickFilesTab() + .clickLayoutSwitcher() + .scrollToItemWithName(file) + .clickMoreOnItem(file) + .clickRename() + .clearName() + .typeName(renamedFile) + .clickRename() + .scrollToItemWithName(renamedFile) + .verify { + itemIsDisplayed(renamedFile) + } + } + + @Test + @Scenario(4) + fun renameFolderInGridViaMultiSelection() { + val folder = "folder1" + val renamedFolder = "1redlof" + + PhotosTabRobot + .clickFilesTab() + .clickLayoutSwitcher() + .scrollToItemWithName(folder) + .longClickOnItem(folder) + .clickOptions() + .clickRename() + .clearName() + .typeName(renamedFolder) + .clickRename() + .scrollToItemWithName(renamedFolder) + .verify { + itemIsDisplayed(renamedFolder) + } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingViaPreviewErrorTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingViaPreviewErrorTest.kt new file mode 100644 index 00000000..5105f6ba --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/rename/RenamingViaPreviewErrorTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.rename + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.PhotosTabRobot +import me.proton.android.drive.ui.robot.PreviewRobot +import me.proton.android.drive.ui.rules.Scenario +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import org.junit.Test + +@HiltAndroidTest +class RenamingViaPreviewErrorTest : AuthenticatedBaseTest() { + + @Test + @Scenario(1) + fun renameViaPreviewToAlreadyExistingName() { + val parent = "folder1" + val file = "presentation.pdf" + val renamed = "image.jpg" + + PhotosTabRobot + .clickFilesTab() + .clickOnFolder(parent) + .scrollToItemWithName(file) + .clickOnFile(file) + .verify { + nodeWithTextDisplayed("1 / 1") + } + + PreviewRobot + .clickOnContextualButton() + .clickRename() + .clearName() + .typeName(renamed) + .clickRename(PreviewRobot) + .verify { + nodeWithTextDisplayed("A file or folder with that name already exists") + } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/ClearLocalCacheFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/ClearLocalCacheFlowTest.kt index f80556aa..040391cb 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/ClearLocalCacheFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/ClearLocalCacheFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ package me.proton.android.drive.ui.test.flow.settings import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test @@ -27,7 +27,7 @@ import org.junit.Test class ClearLocalCacheFlowTest : AuthenticatedBaseTest() { @Test fun clearLocalCacheSucceeds() { - FilesTabRobot + PhotosTabRobot .openSidebarBySwipe() .clickSettings() .clickToClearLocalCache() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/GetMoreFreeStorageFreeUserTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/GetMoreFreeStorageFreeUserTest.kt new file mode 100644 index 00000000..703d2167 --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/GetMoreFreeStorageFreeUserTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.settings + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import org.junit.Test + +@HiltAndroidTest +class GetMoreFreeStorageFreeUserTest : AuthenticatedBaseTest() { + + @Test + fun freeUserWithoutMaxFreeSpaceShouldHaveGetMoreFreeStorageOption() { + FilesTabRobot + .openSidebarBySwipe() + .scrollToStorageIndicator() + .clickGetMoreFreeStorage() + .verify { + robotDisplayed() + assertTitleDisplayed(uiTestHelper.configurationProvider.maxFreeSpace) + assertSubtitleDisplayed() + assertActionsDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/GetMoreFreeStoragePaidUserTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/GetMoreFreeStoragePaidUserTest.kt new file mode 100644 index 00000000..8a6d3bbf --- /dev/null +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/settings/GetMoreFreeStoragePaidUserTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.ui.test.flow.settings + +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.rules.UserLoginRule +import me.proton.android.drive.ui.test.BaseTest +import me.proton.android.drive.utils.getRandomString +import me.proton.core.test.quark.data.Plan +import me.proton.core.test.quark.data.User +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class GetMoreFreeStoragePaidUserTest : BaseTest() { + private val paidUser = User( + name = "proton_drive_${getRandomString(15)}", + plan = Plan.Unlimited, + ) + + @get:Rule(order = 1) + val userLoginRule: UserLoginRule = UserLoginRule(paidUser, quarkCommands = quarkRule.quarkCommands) + + @Test + fun paidUserShouldNotHaveGetMoreFreeStorageOption() { + FilesTabRobot + .openSidebarBySwipe() + .scrollToStorageIndicator() + .verify { + robotDisplayed() + assertGetMoreFreeStorageIsNotDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/ChangeExpirationDate.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/ChangeExpirationDate.kt index 58975786..e226aada 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/ChangeExpirationDate.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/ChangeExpirationDate.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -22,6 +22,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.extension.tomorrow import me.proton.android.drive.ui.robot.FileFolderOptionsRobot import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.robot.ShareRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest @@ -42,7 +43,8 @@ class ChangeExpirationDate( @Test @Scenario(4) fun expirationDate() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(fileName) .clickMoreOnItem(fileName) .clickToShareAction() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/DeleteSharedLinkFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/DeleteSharedLinkFlowTest.kt index 0d5fcb6c..ac6ec092 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/DeleteSharedLinkFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/DeleteSharedLinkFlowTest.kt @@ -20,7 +20,7 @@ package me.proton.android.drive.ui.test.flow.share import androidx.test.ext.junit.runners.AndroidJUnit4 import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test @@ -36,7 +36,8 @@ class DeleteSharedLinkFlowTest : AuthenticatedBaseTest() { fun stopSharingActiveLinkViaBottomSheet() { val file = "shared.jpg" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(file) .clickMoreOnItem(file) .clickStopSharing() @@ -52,7 +53,8 @@ class DeleteSharedLinkFlowTest : AuthenticatedBaseTest() { fun stopSharingActiveLinkViaManageLink() { val file = "shared.jpg" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(file) .clickMoreOnItem(file) .clickManageLink() @@ -70,7 +72,8 @@ class DeleteSharedLinkFlowTest : AuthenticatedBaseTest() { fun stopSharingExpiredLinkViaBottomSheet() { val file = "expiredSharedFile.jpg" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .scrollToItemWithName(file) .clickMoreOnItem(file) .clickStopSharing() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/SetCustomPasswordFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/SetCustomPasswordFlowTest.kt index 46307f74..148cc4d2 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/SetCustomPasswordFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/SetCustomPasswordFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -21,6 +21,7 @@ package me.proton.android.drive.ui.test.flow.share import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.FileFolderOptionsRobot import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.robot.ShareRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest @@ -118,7 +119,8 @@ class SetCustomPasswordFlowTest : AuthenticatedBaseTest() { } private fun showShareViaLinkScreen(file: String, clickToShareRobot: FileFolderOptionsRobot.() -> ShareRobot) { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .verify { robotDisplayed() } .scrollToItemWithName(file) .clickMoreOnItem(file) diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/SharingFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/SharingFlowTest.kt index 5ddce85f..cf7ea651 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/SharingFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/share/SharingFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ package me.proton.android.drive.ui.test.flow.share import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.data.ImageName -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest import org.junit.Test @@ -31,7 +31,8 @@ class SharingFlowTest : AuthenticatedBaseTest() { @Scenario(2) fun shareFile() { val file = "image.jpg" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(file) .clickGetLink() .verifyShareLinkFile(file) @@ -41,8 +42,7 @@ class SharingFlowTest : AuthenticatedBaseTest() { @Scenario(2, isPhotos = true) fun sharePhoto() { val image = ImageName.Main - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .longClickOnPhoto(image.fileName) .clickOptions() .clickGetLink() @@ -53,9 +53,10 @@ class SharingFlowTest : AuthenticatedBaseTest() { @Scenario(2) fun shareFolder() { val folder = "folder1" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickGetLink() .verifyShareLinkFolder(folder) } -} \ No newline at end of file +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/subscription/SubscriptionFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/subscription/SubscriptionFlowTest.kt index 19cc141c..c5116122 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/subscription/SubscriptionFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/subscription/SubscriptionFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -21,7 +21,6 @@ package me.proton.android.drive.ui.test.flow.subscription import androidx.test.ext.junit.runners.AndroidJUnit4 import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.robot.FilesTabRobot -import me.proton.android.drive.ui.test.AbstractBaseTest import me.proton.android.drive.ui.test.BaseTest import me.proton.core.auth.test.robot.AddAccountRobot import me.proton.core.plan.test.MinimalSubscriptionTests @@ -53,7 +52,7 @@ class SubscriptionFlowTest : BaseTest(), MinimalSubscriptionTests { .login(user) FilesTabRobot - .verify { robotDisplayed() } + .verify { homeScreenDisplayed() } .openSidebarBySwipe() .verify { robotDisplayed() } .clickSubscription() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/DeletePermanentlyTests.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/DeletePermanentlyTests.kt index f104e96d..b7e8ce31 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/DeletePermanentlyTests.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/DeletePermanentlyTests.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -35,7 +35,8 @@ class DeletePermanentlyTests: AuthenticatedBaseTest() { val folder = "folder1" val photo = ImageName.Main.fileName - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folder) .clickMoveToTrash() .dismissMoveToTrashSuccessGrowler(1, FilesTabRobot) @@ -75,4 +76,4 @@ class DeletePermanentlyTests: AuthenticatedBaseTest() { itemIsNotDisplayed(photo) } } -} \ No newline at end of file +} diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/MoveToTrashTests.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/MoveToTrashTests.kt index 595ea6a7..ac57ea52 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/MoveToTrashTests.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/MoveToTrashTests.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -42,7 +42,8 @@ class MoveToTrashTests : AuthenticatedBaseTest() { val folder7 = "folder7" val emptyFolder = "folder8" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .verify { robotDisplayed() } .clickLayoutSwitcher() .clickOnFolder(folder1, Grid) @@ -69,7 +70,8 @@ class MoveToTrashTests : AuthenticatedBaseTest() { @Test @Scenario(2, isPhotos = true) fun moveAFileAndPhotoToTrash() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(fileName) .clickMoveToTrash() .dismissMoveToTrashSuccessGrowler(1, FilesTabRobot) @@ -85,7 +87,8 @@ class MoveToTrashTests : AuthenticatedBaseTest() { itemIsNotDisplayed(ImageName.Main.fileName) } - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickSidebarButton() .clickTrash() .verify { @@ -97,7 +100,8 @@ class MoveToTrashTests : AuthenticatedBaseTest() { @Test @Scenario(2) fun removeFolderWithFiles() { - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickMoreOnItem(folderName) .clickMoveToTrash() .dismissMoveToTrashSuccessGrowler(1, FilesTabRobot) @@ -106,6 +110,7 @@ class MoveToTrashTests : AuthenticatedBaseTest() { } FilesTabRobot + .clickFilesTab() .clickSidebarButton() .clickTrash() .verify { diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/RestoreFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/RestoreFlowTest.kt index a461b3a7..d0b58c3d 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/RestoreFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/trash/RestoreFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -25,24 +25,17 @@ import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.robot.TrashRobot import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest -import org.junit.Before import org.junit.Test @HiltAndroidTest class RestoreFlowTest : AuthenticatedBaseTest() { - @Before - fun setUp() { - FilesTabRobot.verify { - robotDisplayed() - } - } - @Test @Scenario(4) fun restoreAnItemFromTrashViaThreeDotsButtonOfTheItem() { val fileName = "trashedFile.json" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickSidebarButton() .clickTrash() .clickMoreOnItem(fileName) @@ -63,7 +56,8 @@ class RestoreFlowTest : AuthenticatedBaseTest() { fun restoreAFolderWithFilesInside() { val folderName = "trashedFolderWithChildren" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickSidebarButton() .clickTrash() .clickMoreOnItem(folderName) @@ -88,7 +82,8 @@ class RestoreFlowTest : AuthenticatedBaseTest() { val restoreErrorMessage = "This file or folder is no longer available. A parent folder has been deleted." - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickOnFolder(parent) .moveToTrash(child) .clickBack(FilesTabRobot) @@ -107,7 +102,8 @@ class RestoreFlowTest : AuthenticatedBaseTest() { fun restoreAChildFolder() { val folder1 = "folder1" val folder3 = "folder3" - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickOnFolder(folder1) .moveToTrash(folder3) .clickSidebarButton() @@ -130,8 +126,7 @@ class RestoreFlowTest : AuthenticatedBaseTest() { @Scenario(2, isPhotos = true) fun restoreAPhoto() { val item = ImageName.Main.fileName - FilesTabRobot - .clickPhotosTab() + PhotosTabRobot .longClickOnPhoto(item) .clickOptions() .clickMoveToTrash() diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/TakeAPhotoFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/TakeAPhotoFlowTest.kt index 30ebbad3..ebb7c726 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/TakeAPhotoFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/TakeAPhotoFlowTest.kt @@ -20,22 +20,21 @@ package me.proton.android.drive.ui.test.flow.upload import android.app.Activity import android.app.Instrumentation -import android.content.Context import android.content.Intent import android.net.Uri import android.provider.MediaStore import android.provider.OpenableColumns import androidx.core.content.IntentCompat.getParcelableExtra -import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.rule.IntentsRule import androidx.test.platform.app.InstrumentationRegistry import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.core.drive.i18n.R import me.proton.core.test.android.instrumented.utils.StringUtils +import me.proton.test.fusion.FusionConfig.targetContext import org.junit.Rule import org.junit.Test @@ -58,7 +57,8 @@ class TakeAPhotoFlowTest : AuthenticatedBaseTest() { Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) } - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickPlusButton() .clickTakePhoto() .verify { @@ -75,8 +75,7 @@ class TakeAPhotoFlowTest : AuthenticatedBaseTest() { .getInstrumentation() .context .assets.open(fileName).use { input -> - ApplicationProvider.getApplicationContext() - .contentResolver.openOutputStream(uri, "w")?.use { output -> + targetContext.contentResolver.openOutputStream(uri, "w")?.use { output -> input.copyTo(output) } } @@ -84,7 +83,7 @@ class TakeAPhotoFlowTest : AuthenticatedBaseTest() { private fun Uri.getFileName(): String { return requireNotNull( - ApplicationProvider.getApplicationContext().contentResolver.query( + targetContext.contentResolver.query( this, null, null, diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadFlowTest.kt index f4e51ac7..0c2757be 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -18,20 +18,22 @@ package me.proton.android.drive.ui.test.flow.upload -import android.app.Activity -import android.app.Instrumentation -import android.content.ClipData -import android.content.ClipDescription import android.content.Intent -import android.net.Uri import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.rule.IntentsRule import androidx.test.rule.GrantPermissionRule import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.android.drive.ui.annotation.Quota +import me.proton.android.drive.ui.extension.respondWithFile +import me.proton.android.drive.ui.extension.respondWithFiles import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot +import me.proton.android.drive.ui.robot.StorageFullRobot import me.proton.android.drive.ui.rules.ExternalFilesRule +import me.proton.android.drive.ui.rules.Scenario import me.proton.android.drive.ui.test.AuthenticatedBaseTest +import me.proton.core.drive.base.domain.extension.MiB import me.proton.core.test.android.instrumented.utils.StringUtils import org.junit.Rule import org.junit.Test @@ -56,11 +58,10 @@ class UploadFlowTest : AuthenticatedBaseTest() { fun uploadEmptyFileWithPlusButton() { val file = externalFilesRule.createEmptyFile("empty.txt") - Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFunction { - Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(Uri.fromFile(file))) - } + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickPlusButton() .clickUploadAFile() .verify { @@ -73,11 +74,10 @@ class UploadFlowTest : AuthenticatedBaseTest() { fun uploadEmptyFileWithAddFilesButton() { val file = externalFilesRule.createEmptyFile("empty.txt") - Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFunction { - Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(Uri.fromFile(file))) - } + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickAddFilesButton() .clickUploadAFile() .verify { @@ -90,11 +90,10 @@ class UploadFlowTest : AuthenticatedBaseTest() { fun cancelFileUpload() { val file = externalFilesRule.create1BFile("cancel.txt") - Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFunction { - Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(Uri.fromFile(file))) - } + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickPlusButton() .clickUploadAFile() .clickCancelUpload() @@ -107,11 +106,10 @@ class UploadFlowTest : AuthenticatedBaseTest() { fun upload6MBFile() { val file = externalFilesRule.createFile("6MB.txt", 6 * 1024 * 1024) - Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFunction { - Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(Uri.fromFile(file))) - } + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickPlusButton() .clickUploadAFile() .verify { @@ -132,23 +130,10 @@ class UploadFlowTest : AuthenticatedBaseTest() { val file3 = externalFilesRule.create1BFile("file3.txt") val files = listOf(file1, file2, file3) - Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFunction { - Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().apply { - val items = files.map { file -> - ClipData.Item(Uri.fromFile(file)) - } - clipData = ClipData( - ClipDescription( - "", files.map { "text/plain" }.toTypedArray() - ), - items.first() - ).also { clipData -> - (items - items.first()).forEach(clipData::addItem) - } - }) - } + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFiles(files) - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickPlusButton() .clickUploadAFile() .verify { @@ -160,4 +145,134 @@ class UploadFlowTest : AuthenticatedBaseTest() { itemIsDisplayed("file3.txt") } } + + @Test + @Scenario(2) + fun uploadAFileInGridSucceeds() { + val file = externalFilesRule.create1BFile("file.txt") + + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) + + PhotosTabRobot + .clickFilesTab() + .clickLayoutSwitcher() + .clickPlusButton() + .clickUploadAFile() + .verify { + assertFilesBeingUploaded(1, StringUtils.stringFromResource(I18N.string.title_my_files)) + } + .verify { + itemIsDisplayed("file.txt") + } + } + + @Test + fun uploadTheSameFileTwice() { + val file = externalFilesRule.create1BFile("file.txt") + + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) + + PhotosTabRobot + .clickFilesTab() + .clickPlusButton() + .clickUploadAFile() + .verify { + assertFilesBeingUploaded(1, StringUtils.stringFromResource(I18N.string.title_my_files)) + } + .verify { + itemIsDisplayed("file.txt") + } + .clickPlusButton() + .clickUploadAFile() + .verify { + assertFilesBeingUploaded(1, StringUtils.stringFromResource(I18N.string.title_my_files)) + } + .verify { + itemIsDisplayed("file (1).txt") + } + } + + @Test + @Quota(percentageFull = 99) + fun notEnoughSpaceWhenUploadOneFileBiggerThanStorage() { + val file = externalFilesRule.createFile("50MB.txt", 50.MiB.value) + + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) + + PhotosTabRobot + .clickFilesTab() + .clickPlusButton() + .clickUploadAFile() + .verify { + StorageFullRobot.robotDisplayed() + } + } + + @Test + fun uploadMultipleBatches() { + val batch200 = (1..200).map { index -> + externalFilesRule.create1BFile("file$index.txt") + } + val batch100 = (201..300).map { index -> + externalFilesRule.create1BFile("file$index.txt") + } + + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFiles(batch200) + + FilesTabRobot + .clickPlusButton() + .clickUploadAFile() + .verify { + assertFilesBeingUploaded(200, StringUtils.stringFromResource(I18N.string.title_my_files)) + } + + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFiles(batch100) + + FilesTabRobot + .clickPlusButton() + .clickUploadAFile() + .verify { + assertFilesBeingUploaded(100, StringUtils.stringFromResource(I18N.string.title_my_files)) + } + } + + @Test + @Scenario(2) + fun navigationIsAllowedDuringFileUpload() { + val file = externalFilesRule.create1BFile("file.txt") + + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) + + PhotosTabRobot + .clickFilesTab() + .clickOnFolder("folder1") + .clickPlusButton() + .clickUploadAFile() + .clickBack(FilesTabRobot) + .clickOnFolder("folder1") + .verify { + itemIsDisplayed("file.txt") + } + } + + @Test + @Scenario(2) + fun switchLayoutWhileUploading() { + val file = externalFilesRule.create1BFile("file.txt") + + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWithFile(file) + + PhotosTabRobot + .clickFilesTab() + .clickPlusButton() + .clickUploadAFile() + .verify { + assertFilesBeingUploaded(1, StringUtils.stringFromResource(I18N.string.title_my_files)) + } + .clickLayoutSwitcher() + .verify { + itemIsDisplayed("file.txt") + } + } + } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadViaThirdPartyAppFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadViaThirdPartyAppFlowTest.kt index a9397faa..05fd8958 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadViaThirdPartyAppFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadViaThirdPartyAppFlowTest.kt @@ -22,6 +22,7 @@ import androidx.test.rule.GrantPermissionRule import dagger.hilt.android.testing.HiltAndroidTest import me.proton.android.drive.ui.annotation.Quota import me.proton.android.drive.ui.robot.LauncherRobot +import me.proton.android.drive.ui.robot.StorageFullRobot import me.proton.android.drive.ui.robot.UploadToRobot import me.proton.android.drive.ui.rules.ExternalFilesRule import me.proton.android.drive.ui.rules.UserLoginRule @@ -102,9 +103,9 @@ class UploadViaThirdPartyAppFlowTest : EmptyBaseTest() { @Quota(percentageFull = 99) fun uploadMultipleItemsWhenStorageIsAlmostFull() { val files = listOf( - externalFilesRule.createFile("1.txt", 5.MiB.value), - externalFilesRule.createFile("2.txt", 5.MiB.value), - externalFilesRule.createFile("3.txt", 5.MiB.value), + externalFilesRule.createFile("1.txt", 10.MiB.value), + externalFilesRule.createFile("2.txt", 10.MiB.value), + externalFilesRule.createFile("3.txt", 10.MiB.value), ) LauncherRobot.uploadTo(files) @@ -114,7 +115,7 @@ class UploadViaThirdPartyAppFlowTest : EmptyBaseTest() { } .clickUpload() .verify { - assertStorageFull() + StorageFullRobot.robotDisplayed() } } } diff --git a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadWithThumbnailFlowTest.kt b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadWithThumbnailFlowTest.kt index 2c491b09..b0e17c48 100644 --- a/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadWithThumbnailFlowTest.kt +++ b/app/src/uiTest/kotlin/me/proton/android/drive/ui/test/flow/upload/UploadWithThumbnailFlowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -28,7 +28,7 @@ import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.rule.IntentsRule import androidx.test.rule.GrantPermissionRule import dagger.hilt.android.testing.HiltAndroidTest -import me.proton.android.drive.ui.robot.FilesTabRobot +import me.proton.android.drive.ui.robot.PhotosTabRobot import me.proton.android.drive.ui.rules.ExternalFilesRule import me.proton.android.drive.ui.test.AuthenticatedBaseTest import me.proton.core.test.android.instrumented.utils.StringUtils @@ -68,7 +68,8 @@ class UploadWithThumbnailFlowTest( ) } - FilesTabRobot + PhotosTabRobot + .clickFilesTab() .clickPlusButton() .clickUploadAFile() .verify { diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 17e29d2f..db01d82f 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -22,7 +22,7 @@ object Config { const val minSdk = 23 const val targetSdk = 33 const val testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - const val versionName = "2.3.1" + const val versionName = "2.4.0" const val archivesBaseName = "ProtonDrive-$versionName" val resourceConfigurations = listOf("en") } diff --git a/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/handler/UploadErrorHandlerImpl.kt b/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/handler/UploadErrorHandlerImpl.kt index 9e8dec32..8dc26179 100644 --- a/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/handler/UploadErrorHandlerImpl.kt +++ b/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/handler/UploadErrorHandlerImpl.kt @@ -25,13 +25,13 @@ import me.proton.core.crypto.common.pgp.exception.CryptoException import me.proton.core.drive.backup.data.extension.toBackupError import me.proton.core.drive.backup.data.worker.BackupNotificationWorker import me.proton.core.drive.backup.domain.entity.BackupErrorType -import me.proton.core.drive.backup.domain.exception.BackupStopException import me.proton.core.drive.backup.domain.handler.UploadErrorHandler import me.proton.core.drive.backup.domain.usecase.DeleteFile import me.proton.core.drive.backup.domain.usecase.HasFolders import me.proton.core.drive.backup.domain.usecase.MarkAsFailed import me.proton.core.drive.backup.domain.usecase.StopBackup import me.proton.core.drive.base.data.extension.log +import me.proton.core.drive.base.domain.exception.BackupStopException import me.proton.core.drive.base.domain.log.LogTag.BACKUP import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import me.proton.core.drive.upload.domain.manager.UploadErrorManager diff --git a/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/manager/BackupManagerImpl.kt b/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/manager/BackupManagerImpl.kt index 5b417fd0..6b44f79a 100644 --- a/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/manager/BackupManagerImpl.kt +++ b/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/manager/BackupManagerImpl.kt @@ -84,12 +84,12 @@ class BackupManagerImpl @Inject constructor( override suspend fun stop(folderId: FolderId) { CoreLogger.d(BACKUP, "stop") - workManager.getWorkInfosByTag(TAG).await() - .filter { workInfo -> workInfo.tags.contains(folderId.id) } - .forEach { workInfo -> workManager.cancelWorkById(workInfo.id) } getAllFolders(folderId).getOrNull()?.forEach { backupFolder -> uploadWorkManager.cancelAllByFolder(backupFolder.folderId.userId, backupFolder.folderId) } + workManager.getWorkInfosByTag(TAG).await() + .filter { workInfo -> workInfo.tags.contains(folderId.id) } + .forEach { workInfo -> workManager.cancelWorkById(workInfo.id) } } override suspend fun cancelSync(backupFolder: BackupFolder) { diff --git a/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/repository/ContextScanFolderRepository.kt b/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/repository/ContextScanFolderRepository.kt index 92cc6ff6..acd47998 100644 --- a/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/repository/ContextScanFolderRepository.kt +++ b/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/repository/ContextScanFolderRepository.kt @@ -89,15 +89,15 @@ private fun Cursor.createMedia(backupFolder: BackupFolder, uri: Uri): List + uriString = Uri.withAppendedPath(uri, getInt(id).toString())?.let { uriMedia -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { MediaStore.setRequireOriginal(uriMedia) } else { uriMedia } }.toString(), - mimeType = getString(mimeType), - name = getString(displayName), + mimeType = getStringOrThrow(mimeType), + name = getStringOrThrow(displayName), hash = null, size = getInt(size).bytes, state = BackupFileState.IDLE, @@ -113,6 +113,12 @@ private fun Cursor.createMedia(backupFolder: BackupFolder, uri: Uri): List error("Column $columnIndex is null") + Cursor.FIELD_TYPE_STRING -> getString(columnIndex) + else -> error("Column $columnIndex is not a string but $type") +} + private val imageCollection: Uri by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) diff --git a/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/worker/BackupFileWatcherWorker.kt b/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/worker/BackupFileWatcherWorker.kt index fe8f828f..cff2496e 100644 --- a/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/worker/BackupFileWatcherWorker.kt +++ b/drive/backup/data/src/main/kotlin/me/proton/core/drive/backup/data/worker/BackupFileWatcherWorker.kt @@ -40,6 +40,7 @@ import me.proton.core.drive.backup.domain.entity.BucketUpdate import me.proton.core.drive.backup.domain.usecase.RescanAllFolders import me.proton.core.drive.backup.domain.usecase.SyncFolders import me.proton.core.drive.base.data.extension.log +import me.proton.core.drive.base.domain.exception.BackupSyncException import me.proton.core.drive.base.domain.log.LogTag.BACKUP import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.linkupload.domain.entity.UploadFileLink @@ -66,17 +67,21 @@ class BackupFileWatcherWorker @AssistedInject constructor( val notMediaUris = triggeredContentUris.filterNot { uri -> uri.pathSegments.size == EXTERNAL_PATH_SEGMENTS.size + 1 } - when { - triggeredContentAuthorities.isEmpty() -> rescanAllFolders { "no authorities" }.onFailure { error -> + val mediaUris = triggeredContentUris - notMediaUris.toSet() + if (triggeredContentAuthorities.isEmpty()) { + rescanAllFolders { "no authorities" }.onFailure { error -> error.log(BACKUP, "Cannot rescan all folders") } - - notMediaUris.isNotEmpty() -> rescanAllFolders { "found not media uris: $notMediaUris" }.onFailure { error -> - error.log(BACKUP, "Cannot rescan all folders") + } else { + if (notMediaUris.isNotEmpty()) { + BackupSyncException("Non media uris: $notMediaUris") + .log(BACKUP, "Cannot sync non media uris ${notMediaUris.size}), media uri: ${mediaUris.size}") } - else -> syncAllFoldersFromUri(triggeredContentUris).onFailure { error -> - error.log(BACKUP, "Cannot sync all folders") + if (mediaUris.isNotEmpty()) { + syncAllFoldersFromUri(mediaUris).onFailure { error -> + error.log(BACKUP, "Cannot sync all folders") + } } } workManager.enqueueUniqueWork( diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupErrorRepositoryImplTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupErrorRepositoryImplTest.kt index 4d2cc5b3..6c6c381c 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupErrorRepositoryImplTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupErrorRepositoryImplTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -23,7 +23,7 @@ import kotlinx.coroutines.test.runTest import me.proton.core.drive.backup.domain.entity.BackupError import me.proton.core.drive.backup.domain.entity.BackupErrorType import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals import org.junit.Before @@ -53,7 +53,7 @@ class BackupErrorRepositoryImplTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } repository = BackupErrorRepositoryImpl(database.db) } diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFileRepositoryImplStatsTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFileRepositoryImplStatsTest.kt index a0bf13c6..0e97c526 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFileRepositoryImplStatsTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFileRepositoryImplStatsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -26,8 +26,7 @@ import me.proton.core.drive.backup.domain.entity.BackupStateCount import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive -import me.proton.core.drive.db.test.shareId +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FolderId @@ -53,7 +52,7 @@ class BackupFileRepositoryImplStatsTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val backupFolderRepository = BackupFolderRepositoryImpl(database.db) backupFolder = BackupFolder(bucketId, folderId) @@ -139,8 +138,8 @@ class BackupFileRepositoryImplStatsTest { LinkUploadEntity( id = 0, userId = userId, - volumeId = volumeId, - shareId = shareId, + volumeId = volumeId.id, + shareId = folderId.shareId.id, parentId = folderId.id, uri = "uri$index", name = "", diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFileRepositoryImplTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFileRepositoryImplTest.kt index d8166c23..334b6f05 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFileRepositoryImplTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFileRepositoryImplTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -27,9 +27,9 @@ import me.proton.core.drive.backup.domain.entity.BackupStatus import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.photo -import me.proton.core.drive.db.test.shareId +import me.proton.core.drive.db.test.photoShareId import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FolderId @@ -56,7 +56,7 @@ class BackupFileRepositoryImplTest { @Before fun setUp() = runTest { rootPhotoId = database.photo {} - rootMainId = database.myDrive {} + rootMainId = database.myFiles {} val backupFolderRepository = BackupFolderRepositoryImpl(database.db) backupFolderRepository.insertFolder(BackupFolder(bucketId, rootPhotoId)) backupFolderRepository.insertFolder(BackupFolder(1, rootMainId)) @@ -349,8 +349,8 @@ class BackupFileRepositoryImplTest { LinkUploadEntity( id = 0, userId = userId, - volumeId = volumeId, - shareId = shareId, + volumeId = volumeId.id, + shareId = photoShareId.id, parentId = rootPhotoId.id, uri = uri, name = "", diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFolderRepositoryImplGetFolderByFileUriTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFolderRepositoryImplGetFolderByFileUriTest.kt index 27b79492..8022de84 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFolderRepositoryImplGetFolderByFileUriTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFolderRepositoryImplGetFolderByFileUriTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -23,7 +23,7 @@ import me.proton.core.drive.backup.data.db.entity.BackupFileEntity import me.proton.core.drive.backup.domain.entity.BackupFileState import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.domain.entity.UploadFileLink @@ -47,7 +47,7 @@ class BackupFolderRepositoryImplGetFolderByFileUriTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupFolder = BackupFolder(0, folderId) repository = BackupFolderRepositoryImpl(database.db) repository.insertFolder(backupFolder) diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFolderRepositoryImplTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFolderRepositoryImplTest.kt index 1e7fb609..6628b9e8 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFolderRepositoryImplTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/repository/BackupFolderRepositoryImplTest.kt @@ -24,7 +24,7 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.folder -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals @@ -47,7 +47,7 @@ class BackupFolderRepositoryImplTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } repository = BackupFolderRepositoryImpl(database.db) } @@ -139,7 +139,7 @@ class BackupFolderRepositoryImplTest { @Test fun `Given a child folder when check has folders for folder should returns false`() = runTest { - folderId = database.myDrive { + folderId = database.myFiles { folder("child") } val hasFolders = repository.hasFolders(folderId) diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupCheckDuplicatesWorkerTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupCheckDuplicatesWorkerTest.kt index c57f4f05..deea8715 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupCheckDuplicatesWorkerTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupCheckDuplicatesWorkerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -46,15 +46,12 @@ import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.crypto.domain.usecase.file.GetContentHash import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive -import me.proton.core.drive.db.test.shareId -import me.proton.core.drive.db.test.userId +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.key.domain.entity.Key import me.proton.core.drive.key.domain.usecase.GetNodeKey import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.Link -import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.upload.domain.resolver.UriResolver import org.junit.Assert.assertEquals import org.junit.Before @@ -85,7 +82,7 @@ class BackupCheckDuplicatesWorkerTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val nodeKey: Key.Node = mockk() coEvery { getNodeKey(folderId) } returns Result.success(nodeKey) @@ -204,7 +201,7 @@ class BackupCheckDuplicatesWorkerTest { parentId = folderId, hash = hash, contentHash = contentHash, - linkId = FileId(ShareId(userId, shareId), "link-id"), + linkId = FileId(folderId.shareId, "link-id"), linkState = Link.State.ACTIVE, revisionId = "revision-id", clientUid = "" diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupCleanRevisionsWorkerTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupCleanRevisionsWorkerTest.kt index b5f17483..04826e1f 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupCleanRevisionsWorkerTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupCleanRevisionsWorkerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -36,15 +36,12 @@ import me.proton.core.drive.backup.domain.usecase.AddBackupError import me.proton.core.drive.backup.domain.usecase.CleanRevisions import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive -import me.proton.core.drive.db.test.shareId -import me.proton.core.drive.db.test.userId +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.folder.domain.repository.FolderRepository import me.proton.core.drive.folder.domain.usecase.DeleteFolderChildren import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.Link -import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.network.domain.ApiException import me.proton.core.network.domain.ApiResult import org.junit.Assert.assertEquals @@ -69,7 +66,7 @@ class BackupCleanRevisionsWorkerTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupDuplicateRepository = BackupDuplicateRepositoryImpl(database.db) backupErrorRepository = BackupErrorRepositoryImpl(database.db) backupDuplicates = listOf( @@ -78,7 +75,7 @@ class BackupCleanRevisionsWorkerTest { parentId = folderId, hash = "hash", contentHash = null, - linkId = FileId(ShareId(userId, shareId), "link-id"), + linkId = FileId(folderId.shareId, "link-id"), linkState = Link.State.DRAFT, revisionId = "revision-Id", clientUid = null, diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupClearFileWorkerTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupClearFileWorkerTest.kt index 4cbc494f..57c1b503 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupClearFileWorkerTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupClearFileWorkerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -34,7 +34,7 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.backup.domain.usecase.AddBackupError import me.proton.core.drive.backup.domain.usecase.MarkAsCompleted import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals import org.junit.Before @@ -61,7 +61,7 @@ class BackupClearFileWorkerTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupFile = NullableBackupFile(bucketId, folderId, "uri") val backupFolderRepository = BackupFolderRepositoryImpl(database.db) backupFolder = BackupFolder(bucketId, folderId) diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupFindDuplicatesWorkerTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupFindDuplicatesWorkerTest.kt index 058563ed..66987a9e 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupFindDuplicatesWorkerTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupFindDuplicatesWorkerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -50,7 +50,7 @@ import me.proton.core.drive.base.domain.usecase.CreateUuid import me.proton.core.drive.base.domain.usecase.GetClientUid import me.proton.core.drive.base.domain.usecase.GetOrCreateClientUid import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.data.api.LinkApiDataSource import me.proton.core.drive.link.data.api.response.CheckAvailableHashesResponse import me.proton.core.drive.link.data.repository.LinkRepositoryImpl @@ -87,7 +87,7 @@ class BackupFindDuplicatesWorkerTest { backupFileRepository = BackupFileRepositoryImpl(database.db) backupErrorRepository = BackupErrorRepositoryImpl(database.db) - folderId = database.myDrive { } + folderId = database.myFiles { } backupFolder = BackupFolder( bucketId = 0, diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupScanFolderRepositoryWorkerTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupScanFolderRepositoryWorkerTest.kt index 33283b95..d9357b00 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupScanFolderRepositoryWorkerTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupScanFolderRepositoryWorkerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -43,7 +43,7 @@ import me.proton.core.drive.backup.domain.usecase.UpdateFolder import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.crypto.domain.usecase.base.UseHashKey import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.domain.entity.UploadFileLink @@ -89,7 +89,7 @@ class BackupScanFolderRepositoryWorkerTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupFolder = BackupFolder( bucketId = 0, folderId = folderId, diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupScheduleUploadFolderWorkerTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupScheduleUploadFolderWorkerTest.kt index 3388aa38..0d3634f3 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupScheduleUploadFolderWorkerTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupScheduleUploadFolderWorkerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -28,7 +28,7 @@ import io.mockk.mockk import kotlinx.coroutines.test.runTest import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -49,7 +49,7 @@ class BackupScheduleUploadFolderWorkerTest { @Before fun setUp() = runTest { - val folderId = database.myDrive { } + val folderId = database.myFiles { } backupFolder = BackupFolder( bucketId = bucketId, folderId = folderId, diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupSyncFoldersWorkerTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupSyncFoldersWorkerTest.kt index 972ec04e..6630518f 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupSyncFoldersWorkerTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupSyncFoldersWorkerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -33,7 +33,7 @@ import me.proton.core.drive.backup.domain.usecase.AddBackupError import me.proton.core.drive.backup.domain.usecase.GetAllFolders import me.proton.core.drive.backup.domain.usecase.SyncFolders import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import org.junit.Assert.assertEquals @@ -56,7 +56,7 @@ class BackupSyncFoldersWorkerTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } repository = BackupFolderRepositoryImpl(database.db) } diff --git a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupUploadFolderWorkerTest.kt b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupUploadFolderWorkerTest.kt index 43941662..358bbcc1 100644 --- a/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupUploadFolderWorkerTest.kt +++ b/drive/backup/data/src/test/kotlin/me/proton/core/drive/backup/data/worker/BackupUploadFolderWorkerTest.kt @@ -53,7 +53,7 @@ import me.proton.core.drive.base.domain.extension.GiB import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.domain.usecase.GetInternalStorageInfo import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.drivelink.data.repository.DriveLinkRepositoryImpl import me.proton.core.drive.drivelink.domain.usecase.GetDriveLink @@ -109,7 +109,7 @@ class BackupUploadFolderWorkerTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupFolderRepository = BackupFolderRepositoryImpl(database.db) backupFolderRepository.insertFolder(BackupFolder(bucketId, folderId)) repository = BackupFileRepositoryImpl(database.db) diff --git a/drive/backup/domain/build.gradle.kts b/drive/backup/domain/build.gradle.kts index 62c89669..4fc57cdd 100644 --- a/drive/backup/domain/build.gradle.kts +++ b/drive/backup/domain/build.gradle.kts @@ -32,6 +32,7 @@ driveModule( api(project(":drive:drivelink-upload:domain")) api(project(":drive:drivelink-crypto:domain")) api(project(":drive:feature-flag:domain")) + api(project(":drive:user:domain")) implementation(project(":drive:base:domain")) testImplementation(project(":drive:backup:data")) testImplementation(project(":drive:key:data")) diff --git a/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupState.kt b/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupState.kt index c8c93715..e47454a0 100644 --- a/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupState.kt +++ b/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupState.kt @@ -22,10 +22,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transformLatest import me.proton.core.drive.backup.domain.entity.BackupConfiguration import me.proton.core.drive.backup.domain.entity.BackupError +import me.proton.core.drive.backup.domain.entity.BackupErrorType import me.proton.core.drive.backup.domain.entity.BackupNetworkType import me.proton.core.drive.backup.domain.entity.BackupPermissions import me.proton.core.drive.backup.domain.entity.BackupState @@ -33,10 +33,11 @@ import me.proton.core.drive.backup.domain.entity.BackupStatus import me.proton.core.drive.backup.domain.manager.BackupConnectivityManager import me.proton.core.drive.backup.domain.manager.BackupManager import me.proton.core.drive.backup.domain.manager.BackupPermissionsManager -import me.proton.core.drive.base.domain.extension.combine -import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.domain.usecase.IsBackgroundRestricted import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.extension.userId +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.usecase.HasCanceledUserMessages import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -46,25 +47,15 @@ class GetBackupState @Inject constructor( private val permissionsManager: BackupPermissionsManager, private val connectivityManager: BackupConnectivityManager, private val getErrors: GetErrors, - private val getAllBuckets: GetAllBuckets, private val isBackgroundRestricted: IsBackgroundRestricted, - private val configurationProvider: ConfigurationProvider, + private val hasCanceledUserMessages: HasCanceledUserMessages, private val getConfiguration: GetConfiguration, + private val getDisabledBackupState: GetDisabledBackupState, ) { operator fun invoke(folderId: FolderId): Flow = backupManager.isEnabled(folderId).transformLatest { enable -> if (!enable) { - emitAll( - getAllBuckets().map { bucketEntries -> - BackupState( - isBackupEnabled = false, - hasDefaultFolder = bucketEntries?.any { entry -> - entry.bucketName == configurationProvider.backupDefaultBucketName - }, - backupStatus = null, - ) - } - ) + emitAll(getDisabledBackupState()) } else { emitAll( combine( @@ -97,36 +88,38 @@ class GetBackupState @Inject constructor( getErrors(folderId), connectivityManager.connectivity, getConfiguration(folderId), - backupManager.isUploading(folderId), isBackgroundRestricted(), - ) { permissions, errors, connectivity, configuration, uploading, isBackgroundRestricted -> + ) { permissions, errors, connectivity, configuration, isBackgroundRestricted -> (errors + listOf( permissionError(permissions), - connectivityError(connectivity, configuration, uploading), + connectivityError(connectivity, configuration), backgroundRestrictionsError(isBackgroundRestricted), )).filterNotNull().distinct() + }.combine( + hasCanceledUserMessages(folderId.userId, UserMessage.BACKUP_BATTERY_SETTINGS) + ) { errors, hasCanceledUserMessages -> + if (hasCanceledUserMessages) { + errors.filterNot { error -> error.type == BackupErrorType.BACKGROUND_RESTRICTIONS } + } else { + errors + } } private fun connectivityError( connectivity: BackupConnectivityManager.Connectivity, configuration: BackupConfiguration?, - uploading: Boolean, - ) = if (!uploading) { - when (connectivity) { - BackupConnectivityManager.Connectivity.NONE -> when (configuration?.networkType) { - BackupNetworkType.UNMETERED -> BackupError.WifiConnectivity() - else -> BackupError.Connectivity() - } - - BackupConnectivityManager.Connectivity.UNMETERED -> null - - BackupConnectivityManager.Connectivity.CONNECTED -> when (configuration?.networkType) { - BackupNetworkType.UNMETERED -> BackupError.WifiConnectivity() - else -> null - } + ) = when (connectivity) { + BackupConnectivityManager.Connectivity.NONE -> when (configuration?.networkType) { + BackupNetworkType.UNMETERED -> BackupError.WifiConnectivity() + else -> BackupError.Connectivity() + } + + BackupConnectivityManager.Connectivity.UNMETERED -> null + + BackupConnectivityManager.Connectivity.CONNECTED -> when (configuration?.networkType) { + BackupNetworkType.UNMETERED -> BackupError.WifiConnectivity() + else -> null } - } else { - null } private fun permissionError( diff --git a/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/GetDisabledBackupState.kt b/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/GetDisabledBackupState.kt new file mode 100644 index 00000000..59b05cf3 --- /dev/null +++ b/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/GetDisabledBackupState.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.backup.domain.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import me.proton.core.drive.backup.domain.entity.BackupState +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import javax.inject.Inject + +class GetDisabledBackupState @Inject constructor( + private val getAllBuckets: GetAllBuckets, + private val configurationProvider: ConfigurationProvider, +) { + operator fun invoke(): Flow = getAllBuckets().map { bucketEntries -> + BackupState( + isBackupEnabled = false, + hasDefaultFolder = bucketEntries?.any { entry -> + entry.bucketName == configurationProvider.backupDefaultBucketName + }, + backupStatus = null, + ) + } +} diff --git a/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/StopBackup.kt b/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/StopBackup.kt index 87f4b1a4..2a60706f 100644 --- a/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/StopBackup.kt +++ b/drive/backup/domain/src/main/kotlin/me/proton/core/drive/backup/domain/usecase/StopBackup.kt @@ -43,12 +43,12 @@ class StopBackup @Inject constructor( CoreLogger.d(BACKUP, "Stopping after: $error") announceEvent(folderId.userId, Event.BackupStopped(folderId, error.type.toEventBackupState())) logBackupStats(folderId) - manager.stop(folderId) addBackupError(folderId, error).getOrThrow() getAllFolders(folderId).getOrThrow().forEach { backupFolder -> markAllEnqueuedAsReady(backupFolder).onSuccess { count -> CoreLogger.d(BACKUP, "Mark $count files as ready") }.getOrThrow() } + manager.stop(folderId) } } diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CheckAvailableSpaceTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CheckAvailableSpaceTest.kt index 9b6f2f75..ccca5caf 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CheckAvailableSpaceTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CheckAvailableSpaceTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -31,7 +31,7 @@ import me.proton.core.drive.base.domain.entity.Bytes import me.proton.core.drive.base.domain.extension.MiB import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.NoNetworkConfigurationProvider -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.user.domain.entity.User @@ -58,7 +58,7 @@ class CheckAvailableSpaceTest { @Before fun setup() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val folderRepository = BackupFolderRepositoryImpl(database.db) val fileRepository = BackupFileRepositoryImpl(database.db) val errorRepository = BackupErrorRepositoryImpl(database.db) @@ -122,5 +122,6 @@ class CheckAvailableSpaceTest { keys = emptyList(), recovery = null, createdAtUtc = 0, + type = null, ) } diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CheckDuplicatesTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CheckDuplicatesTest.kt index 46dd8203..46248d04 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CheckDuplicatesTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CheckDuplicatesTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -35,15 +35,13 @@ import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.crypto.domain.usecase.file.GetContentHash import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive -import me.proton.core.drive.db.test.shareId +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.key.domain.entity.Key import me.proton.core.drive.key.domain.usecase.GetNodeKey import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.Link -import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.upload.domain.resolver.UriResolver import org.junit.Assert.assertEquals import org.junit.Before @@ -74,7 +72,7 @@ class CheckDuplicatesTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val nodeKey: Key.Node = mockk() coEvery { getNodeKey(folderId) } returns Result.success(nodeKey) @@ -348,7 +346,7 @@ class CheckDuplicatesTest { parentId = folderId, hash = hash, contentHash = contentHash, - linkId = FileId(ShareId(userId, shareId), "link-id"), + linkId = FileId(folderId.shareId, "link-id"), linkState = Link.State.ACTIVE, revisionId = "revision-id", clientUid = "" diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CleanRevisionsTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CleanRevisionsTest.kt index e7665139..64b22e7e 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CleanRevisionsTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CleanRevisionsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -26,15 +26,12 @@ import me.proton.core.drive.backup.data.repository.BackupDuplicateRepositoryImpl import me.proton.core.drive.backup.domain.entity.BackupDuplicate import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive -import me.proton.core.drive.db.test.shareId -import me.proton.core.drive.db.test.userId +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.folder.domain.repository.FolderRepository import me.proton.core.drive.folder.domain.usecase.DeleteFolderChildren import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.Link -import me.proton.core.drive.share.domain.entity.ShareId import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Before @@ -55,7 +52,7 @@ class CleanRevisionsTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupDuplicateRepository = BackupDuplicateRepositoryImpl(database.db) cleanRevisions = CleanRevisions( repository = backupDuplicateRepository, @@ -138,7 +135,7 @@ class CleanRevisionsTest { parentId = folderId, hash = "hash", contentHash = null, - linkId = FileId(ShareId(userId, shareId), "link-id"), + linkId = FileId(folderId.shareId, "link-id"), linkState = state, revisionId = "revision-Id", clientUid = null, diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CleanUpCompleteBackupTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CleanUpCompleteBackupTest.kt index 59702ace..8e01370b 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CleanUpCompleteBackupTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/CleanUpCompleteBackupTest.kt @@ -33,7 +33,7 @@ import me.proton.core.drive.backup.domain.handler.StubbedEventHandler import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals @@ -62,7 +62,7 @@ class CleanUpCompleteBackupTest { @Before fun setUp() = runTest { - folderId = database.myDrive {} + folderId = database.myFiles {} val backupFileRepository = BackupFileRepositoryImpl(database.db) val backupFolderRepository = BackupFolderRepositoryImpl(database.db) cleanUpCompleteBackup = CleanUpCompleteBackup( diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ConfigurationTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ConfigurationTest.kt index d5b4463a..cc1d7610 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ConfigurationTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ConfigurationTest.kt @@ -28,7 +28,7 @@ import me.proton.core.drive.backup.domain.entity.BackupNetworkType import me.proton.core.drive.backup.domain.manager.BackupManager import me.proton.core.drive.backup.domain.manager.started import me.proton.core.drive.backup.domain.manager.stopped -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.test.DriveRule @@ -67,7 +67,7 @@ class ConfigurationTest { @Before fun setUp() = runTest { - folderId = driveRule.db.myDrive { } + folderId = driveRule.db.myFiles { } connectedConfiguration = BackupConfiguration(folderId, BackupNetworkType.CONNECTED) unmeteredConfiguration = BackupConfiguration(folderId, BackupNetworkType.UNMETERED) } diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ErrorTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ErrorTest.kt index a14c3b8f..9fe3c65e 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ErrorTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ErrorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -25,7 +25,7 @@ import me.proton.core.drive.backup.domain.entity.BackupError import me.proton.core.drive.backup.domain.entity.BackupErrorType import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.NoNetworkConfigurationProvider -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals import org.junit.Before @@ -57,7 +57,7 @@ class ErrorTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val repository = BackupErrorRepositoryImpl(database.db) addBackupError = AddBackupError(repository) deleteAllBackupError = DeleteAllBackupError(repository) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FilesResolveTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FilesResolveTest.kt index 4c51d69b..9f43e4e1 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FilesResolveTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FilesResolveTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -29,7 +29,7 @@ import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals import org.junit.Before @@ -52,7 +52,7 @@ class FilesResolveTest { @Before fun setUp() = runTest { - folderId = database.myDrive {} + folderId = database.myFiles {} val backupFileRepository = BackupFileRepositoryImpl(database.db) val backupFolderRepository = BackupFolderRepositoryImpl(database.db) getAllFailedFiles = GetAllFailedFiles(object : ConfigurationProvider { diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FilesTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FilesTest.kt index af9c4e87..4df5b81f 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FilesTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FilesTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -27,7 +27,7 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FolderId @@ -35,7 +35,6 @@ import me.proton.core.drive.linkupload.data.factory.UploadBlockFactoryImpl import me.proton.core.drive.linkupload.data.repository.LinkUploadRepositoryImpl import me.proton.core.drive.linkupload.domain.entity.NetworkTypeProviderType import me.proton.core.drive.linkupload.domain.entity.UploadFileLink -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -59,7 +58,7 @@ class FilesTest { @Before fun setUp() = runTest { - folderId = database.myDrive {} + folderId = database.myFiles {} val backupFileRepository = BackupFileRepositoryImpl(database.db) val backupFolderRepository = BackupFolderRepositoryImpl(database.db) getFilesToBackup = GetFilesToBackup(backupFileRepository) @@ -118,7 +117,7 @@ class FilesTest { linkUploadRepository.insertUploadFileLink( UploadFileLink( userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, shareId = folderId.shareId, parentLinkId = folderId, uriString = "uri1", diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FindDuplicatesTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FindDuplicatesTest.kt index bd084bf8..89571088 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FindDuplicatesTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FindDuplicatesTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -41,9 +41,7 @@ import me.proton.core.drive.base.domain.usecase.CreateUuid import me.proton.core.drive.base.domain.usecase.GetClientUid import me.proton.core.drive.base.domain.usecase.GetOrCreateClientUid import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive -import me.proton.core.drive.db.test.shareId -import me.proton.core.drive.db.test.userId +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.data.api.LinkApiDataSource import me.proton.core.drive.link.data.api.response.CheckAvailableHashesResponse import me.proton.core.drive.link.data.api.response.PendingHashDto @@ -52,7 +50,6 @@ import me.proton.core.drive.link.domain.entity.CheckAvailableHashesInfo import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.Link -import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.network.domain.ApiException import me.proton.core.network.domain.ApiResult import org.junit.Assert.assertEquals @@ -83,7 +80,7 @@ class FindDuplicatesTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupFolder = BackupFolder( bucketId = 0, folderId = folderId @@ -193,7 +190,7 @@ class FindDuplicatesTest { parentId = folderId, hash = "hash1", contentHash = null, - linkId = FileId(ShareId(userId, shareId), "link-id"), + linkId = FileId(folderId.shareId, "link-id"), linkState = Link.State.DRAFT, revisionId = "revision-id", clientUid = clientUid diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FoldersTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FoldersTest.kt index e38749f0..49d228e1 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FoldersTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/FoldersTest.kt @@ -23,7 +23,7 @@ import me.proton.core.drive.backup.data.repository.BackupFolderRepositoryImpl import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals import org.junit.Before @@ -46,7 +46,7 @@ class FoldersTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val repository = BackupFolderRepositoryImpl(database.db) addFolder = AddFolder(repository) updateFolder = UpdateFolder(repository) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupStateTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupStateTest.kt index 3f210ea1..61269efe 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupStateTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupStateTest.kt @@ -21,14 +21,15 @@ package me.proton.core.drive.backup.domain.usecase import android.app.Application import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import me.proton.core.drive.backup.data.manager.BackupPermissionsManagerImpl import me.proton.core.drive.backup.data.repository.BackupConfigurationRepositoryImpl import me.proton.core.drive.backup.data.repository.BackupErrorRepositoryImpl import me.proton.core.drive.backup.data.repository.BackupFileRepositoryImpl import me.proton.core.drive.backup.data.repository.BackupFolderRepositoryImpl +import me.proton.core.drive.backup.domain.entity.BackupError import me.proton.core.drive.backup.domain.entity.BackupFile import me.proton.core.drive.backup.domain.entity.BackupFileState import me.proton.core.drive.backup.domain.entity.BackupFolder @@ -46,8 +47,13 @@ import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.domain.usecase.IsBackgroundRestricted import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.NoNetworkConfigurationProvider -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.extension.userId +import me.proton.core.drive.user.data.repository.UserMessageRepositoryImpl +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.usecase.CancelUserMessage +import me.proton.core.drive.user.domain.usecase.HasCanceledUserMessages import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -68,6 +74,7 @@ class GetBackupStateTest { private lateinit var setFiles: SetFiles private lateinit var markAsCompleted: MarkAsCompleted private lateinit var markAsFailed: MarkAsFailed + private lateinit var cancelUserMessage: CancelUserMessage private lateinit var backupState: Flow private lateinit var backupManager: StubbedBackupManager @@ -77,9 +84,11 @@ class GetBackupStateTest { private var bucketEntries = listOf(BucketEntry(0, "Camera")) + private val isBackgroundRestricted = MutableStateFlow(false) + @Before fun setUp() = runTest { - folderId = database.myDrive {} + folderId = database.myFiles {} val folderRepository = BackupFolderRepositoryImpl(database.db) val fileRepository = BackupFileRepositoryImpl(database.db) val errorRepository = BackupErrorRepositoryImpl(database.db) @@ -92,6 +101,7 @@ class GetBackupStateTest { backupManager = StubbedBackupManager(folderRepository) addFolder = AddFolder(folderRepository) deleteFolders = DeleteFolders(folderRepository) + cancelUserMessage = CancelUserMessage(UserMessageRepositoryImpl(database.db)) permissionsManager.onPermissionChanged(BackupPermissions.Granted) val getBackupState = GetBackupState( @@ -100,19 +110,22 @@ class GetBackupStateTest { permissionsManager = permissionsManager, connectivityManager = connectivityManager, getErrors = GetErrors(errorRepository, NoNetworkConfigurationProvider.instance), - getAllBuckets = GetAllBuckets(object : BucketRepository { - override suspend fun getAll(): List = bucketEntries - }, permissionsManager), - configurationProvider = object : ConfigurationProvider { - override val host = "" - override val baseUrl = "" - override val appVersionHeader = "" - override val backupDefaultBucketName = "Camera" - }, isBackgroundRestricted = object : IsBackgroundRestricted { - override fun invoke(): Flow = flowOf(false) + override fun invoke(): Flow = isBackgroundRestricted }, + hasCanceledUserMessages = HasCanceledUserMessages(UserMessageRepositoryImpl(database.db)), getConfiguration = GetConfiguration(BackupConfigurationRepositoryImpl(database.db)), + getDisabledBackupState = GetDisabledBackupState( + getAllBuckets = GetAllBuckets(object : BucketRepository { + override suspend fun getAll(): List = bucketEntries + }, permissionsManager), + configurationProvider = object : ConfigurationProvider { + override val host = "" + override val baseUrl = "" + override val appVersionHeader = "" + override val backupDefaultBucketName = "Camera" + }, + ), ) backupState = getBackupState(folderId) @@ -134,7 +147,7 @@ class GetBackupStateTest { @Test fun `blank backup state with folder`() = runTest { - database.myDrive {} + database.myFiles {} assertEquals( BackupState( @@ -256,6 +269,43 @@ class GetBackupStateTest { ) } + @Test + fun `background restricted`() = runTest { + addFolder(BackupFolder(0, folderId)).getOrThrow() + + isBackgroundRestricted.value = true + + assertEquals( + BackupState( + isBackupEnabled = true, + hasDefaultFolder = true, + backupStatus = BackupStatus.Failed( + totalBackupPhotos = 0, + pendingBackupPhotos = 0, + errors = listOf(BackupError.BackgroundRestrictions()) + ), + ), + backupState.first(), + ) + } + + @Test + fun `background restricted ignored`() = runTest { + addFolder(BackupFolder(0, folderId)).getOrThrow() + + isBackgroundRestricted.value = true + cancelUserMessage(folderId.userId, UserMessage.BACKUP_BATTERY_SETTINGS).getOrThrow() + + assertEquals( + BackupState( + isBackupEnabled = true, + hasDefaultFolder = true, + backupStatus = BackupStatus.Complete(totalBackupPhotos = 0) + ), + backupState.first(), + ) + } + private fun backupFile(uriString: String) = BackupFile( bucketId = 0, folderId = folderId, diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupStatusTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupStatusTest.kt index b45b71c4..834d420a 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupStatusTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetBackupStatusTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -30,7 +30,7 @@ import me.proton.core.drive.backup.domain.entity.BackupStatus import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FolderId @@ -38,7 +38,6 @@ import me.proton.core.drive.linkupload.data.factory.UploadBlockFactoryImpl import me.proton.core.drive.linkupload.data.repository.LinkUploadRepositoryImpl import me.proton.core.drive.linkupload.domain.entity.NetworkTypeProviderType import me.proton.core.drive.linkupload.domain.entity.UploadFileLink -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -63,7 +62,7 @@ class GetBackupStatusTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val backupFolderRepository = BackupFolderRepositoryImpl(database.db) val addFolder = AddFolder(backupFolderRepository) backupFolder = BackupFolder(0, folderId) @@ -221,7 +220,7 @@ class GetBackupStatusTest { private fun uploadFileLink(uriString: String, folderId: FolderId) = UploadFileLink( userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, shareId = folderId.shareId, parentLinkId = folderId, uriString = uriString, diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetFolderFromFileTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetFolderFromFileTest.kt index f74ba55d..b106caa7 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetFolderFromFileTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/GetFolderFromFileTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -27,7 +27,7 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals @@ -50,7 +50,7 @@ class GetFolderFromFileTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val folderRepository = BackupFolderRepositoryImpl(database.db) val fileRepository = BackupFileRepositoryImpl(database.db) addFolder = AddFolder(folderRepository) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/MarkAllFailedAsReadyTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/MarkAllFailedAsReadyTest.kt index 480eccc0..bec36c2a 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/MarkAllFailedAsReadyTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/MarkAllFailedAsReadyTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -27,7 +27,7 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals import org.junit.Before @@ -48,7 +48,7 @@ class MarkAllFailedAsReadyTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val folderRepository = BackupFolderRepositoryImpl(database.db) val fileRepository = BackupFileRepositoryImpl(database.db) addFolder = AddFolder(folderRepository) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/RescanAllFoldersTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/RescanAllFoldersTest.kt index ac97f557..c1afe971 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/RescanAllFoldersTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/RescanAllFoldersTest.kt @@ -24,7 +24,7 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.backup.domain.manager.StubbedBackupManager import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals import org.junit.Before @@ -48,7 +48,7 @@ class RescanAllFoldersTest { @Before fun setup() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val folderRepository = BackupFolderRepositoryImpl(database.db) backupManager = StubbedBackupManager(folderRepository) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/RetryBackupTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/RetryBackupTest.kt index 16c4cb0d..9cf8508c 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/RetryBackupTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/RetryBackupTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -37,7 +37,7 @@ import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.NoNetworkConfigurationProvider -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.feature.flag.domain.repository.FeatureFlagRepository import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlag @@ -72,7 +72,7 @@ class RetryBackupTest { @Before fun setup() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val folderRepository = BackupFolderRepositoryImpl(database.db) fileRepository = BackupFileRepositoryImpl(database.db) val errorRepository = BackupErrorRepositoryImpl(database.db) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ScanFolderTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ScanFolderTest.kt index 042092f0..e4a47fe2 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ScanFolderTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/ScanFolderTest.kt @@ -32,7 +32,7 @@ import me.proton.core.drive.backup.domain.repository.ScanFolderRepository import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.crypto.domain.usecase.base.UseHashKey import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.domain.entity.UploadFileLink @@ -78,7 +78,7 @@ class ScanFolderTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupFolder = BackupFolder( bucketId = bucketId, folderId = folderId, diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/StartBackupAfterErrorResolvedTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/StartBackupAfterErrorResolvedTest.kt index 676bba9c..0181101c 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/StartBackupAfterErrorResolvedTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/StartBackupAfterErrorResolvedTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -29,7 +29,7 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.backup.domain.manager.StubbedBackupManager import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.NoNetworkConfigurationProvider -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals @@ -56,7 +56,7 @@ class StartBackupAfterErrorResolvedTest { @Before fun setup() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val folderRepository = BackupFolderRepositoryImpl(database.db) val errorRepository = BackupErrorRepositoryImpl(database.db) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/StopBackupTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/StopBackupTest.kt index 4f340d5b..c724a20e 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/StopBackupTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/StopBackupTest.kt @@ -35,7 +35,7 @@ import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.NoNetworkConfigurationProvider -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import org.junit.Assert.assertEquals @@ -62,7 +62,7 @@ class StopBackupTest { @Before fun setup() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val folderRepository = BackupFolderRepositoryImpl(database.db) fileRepository = BackupFileRepositoryImpl(database.db) val errorRepository = BackupErrorRepositoryImpl(database.db) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/SyncFoldersTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/SyncFoldersTest.kt index 3bca40b8..9b39f471 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/SyncFoldersTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/SyncFoldersTest.kt @@ -25,7 +25,7 @@ import me.proton.core.drive.backup.domain.entity.BucketUpdate import me.proton.core.drive.backup.domain.manager.StubbedBackupManager import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.domain.entity.UploadFileLink @@ -50,7 +50,7 @@ class SyncFoldersTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } repository = BackupFolderRepositoryImpl(database.db) backupManager = StubbedBackupManager(repository) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/SyncStaleFoldersTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/SyncStaleFoldersTest.kt index 1df79fd1..a94c94ad 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/SyncStaleFoldersTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/SyncStaleFoldersTest.kt @@ -25,7 +25,7 @@ import me.proton.core.drive.backup.domain.manager.StubbedBackupManager import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import org.junit.Assert.assertEquals @@ -52,7 +52,7 @@ class SyncStaleFoldersTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } repository = BackupFolderRepositoryImpl(database.db) backupManager = StubbedBackupManager(repository) diff --git a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/UploadFolderTest.kt b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/UploadFolderTest.kt index 7dd4cb7f..05a98c39 100644 --- a/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/UploadFolderTest.kt +++ b/drive/backup/domain/src/test/kotlin/me/proton/core/drive/backup/domain/usecase/UploadFolderTest.kt @@ -41,7 +41,7 @@ import me.proton.core.drive.base.domain.extension.MiB import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.domain.usecase.GetInternalStorageInfo import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.drivelink.data.repository.DriveLinkRepositoryImpl @@ -65,7 +65,6 @@ import me.proton.core.drive.linkupload.domain.entity.NetworkTypeProviderType import me.proton.core.drive.linkupload.domain.entity.UploadFileDescription import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import me.proton.core.drive.linkupload.domain.usecase.GetUploadFileLinksPaged -import me.proton.core.drive.volume.domain.entity.VolumeId import me.proton.core.user.domain.entity.User import me.proton.core.user.domain.usecase.GetUser import org.junit.Assert.assertEquals @@ -101,7 +100,7 @@ class UploadFolderTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } backupFile = NullableBackupFile( bucketId = bucketId, folderId = folderId, @@ -394,7 +393,7 @@ class UploadFolderTest { private fun uploadFileLink(uriString: String) = UploadFileLink( userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, shareId = folderId.shareId, parentLinkId = folderId, uriString = uriString, diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/BaseDatabase.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/BaseDatabase.kt new file mode 100644 index 00000000..0eac1aef --- /dev/null +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/BaseDatabase.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.data.db + +import androidx.sqlite.db.SupportSQLiteDatabase +import me.proton.core.data.room.db.Database +import me.proton.core.data.room.db.migration.DatabaseMigration +import me.proton.core.drive.base.data.db.dao.UrlLastFetchDao + +interface BaseDatabase : Database { + val urlLastFetchDao: UrlLastFetchDao + + companion object { + val MIGRATION_0 = object : DatabaseMigration { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `UrlLastFetchEntity` ( + `user_id` TEXT NOT NULL, + `url` TEXT NOT NULL, + `last_fetch_timestamp` INTEGER, + PRIMARY KEY(`user_id`, `url`), + FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) + ON UPDATE NO ACTION ON DELETE CASCADE ) + """.trimIndent() + ) + database.execSQL( + """ + CREATE INDEX IF NOT EXISTS `index_UrlLastFetchEntity_user_id` ON `UrlLastFetchEntity` (`user_id`) + """.trimIndent() + ) + database.execSQL( + """ + CREATE INDEX IF NOT EXISTS `index_UrlLastFetchEntity_url` ON `UrlLastFetchEntity` (`url`) + """.trimIndent() + ) + } + } + } +} diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/Column.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/Column.kt index f1c0f733..e46a24fe 100644 --- a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/Column.kt +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/Column.kt @@ -144,6 +144,7 @@ object Column { const val URL_PASSWORD_SALT = "url_password_salt" const val USED_SPACE = "used_space" const val USER_ID = "user_id" + const val USER_MESSAGE = "user_message" const val VALUE = "value" const val VERIFIER_TOKEN = "verifier_token" const val VERSION = "version" diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/dao/UrlLastFetchDao.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/dao/UrlLastFetchDao.kt new file mode 100644 index 00000000..dee0ee62 --- /dev/null +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/dao/UrlLastFetchDao.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import me.proton.core.data.room.db.BaseDao +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.data.db.entity.UrlLastFetchEntity + +@Dao +abstract class UrlLastFetchDao : BaseDao() { + + @Query( + """ + SELECT last_fetch_timestamp FROM UrlLastFetchEntity + WHERE user_id = :userId AND url = :url + """ + ) + abstract suspend fun get(userId: UserId, url: String): Long? +} diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/entity/UrlLastFetchEntity.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/entity/UrlLastFetchEntity.kt new file mode 100644 index 00000000..3cfe71fb --- /dev/null +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/db/entity/UrlLastFetchEntity.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import me.proton.core.account.data.entity.AccountEntity +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.data.db.Column +import me.proton.core.drive.base.data.db.Column.LAST_FETCH_TIMESTAMP +import me.proton.core.drive.base.data.db.Column.URL +import me.proton.core.drive.base.data.db.Column.USER_ID + +@Entity( + primaryKeys = [USER_ID, URL], + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = [Column.Core.USER_ID], + childColumns = [USER_ID], + onDelete = ForeignKey.CASCADE + ), + ], + indices = [ + Index(value = [USER_ID]), + Index(value = [URL]), + ], +) +data class UrlLastFetchEntity( + @ColumnInfo(name = USER_ID) + val userId: UserId, + @ColumnInfo(name = URL) + val url: String, + @ColumnInfo(name = LAST_FETCH_TIMESTAMP) + val lastFetchTimestamp: Long? = null, +) diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/di/BaseBindModule.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/di/BaseBindModule.kt index 5ea43f92..e6c733e0 100644 --- a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/di/BaseBindModule.kt +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/di/BaseBindModule.kt @@ -22,11 +22,13 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import me.proton.core.drive.base.data.formatter.DateTimeFormatterImpl +import me.proton.core.drive.base.data.repository.BaseRepositoryImpl import me.proton.core.drive.base.data.usecase.CopyToClipboardImpl import me.proton.core.drive.base.data.usecase.GetInternalStorageInfoImpl import me.proton.core.drive.base.data.usecase.GetMemoryInfoImpl import me.proton.core.drive.base.data.usecase.Sha256Impl import me.proton.core.drive.base.domain.formatter.DateTimeFormatter +import me.proton.core.drive.base.domain.repository.BaseRepository import me.proton.core.drive.base.domain.usecase.CopyToClipboard import me.proton.core.drive.base.domain.usecase.GetInternalStorageInfo import me.proton.core.drive.base.domain.usecase.GetMemoryInfo @@ -56,4 +58,8 @@ interface BaseBindModule { @Binds @Singleton fun bindsGetInternalStorageInfoImpl(impl: GetInternalStorageInfoImpl): GetInternalStorageInfo + + @Binds + @Singleton + fun bindsRepositoryImpl(impl: BaseRepositoryImpl): BaseRepository } diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/BackupStopException.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/BackupStopException.kt new file mode 100644 index 00000000..b307726f --- /dev/null +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/BackupStopException.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.base.data.extension + +import android.content.Context +import me.proton.core.drive.base.domain.exception.BackupStopException +import me.proton.core.util.kotlin.CoreLogger +import me.proton.core.drive.i18n.R as I18N + +fun BackupStopException.getDefaultMessage(context: Context): String = + context.getString(I18N.string.common_error_internal) + +fun BackupStopException.log(tag: String, message: String = this.message.orEmpty()): BackupStopException = also { + CoreLogger.e(tag, this, message) +} diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/BackupSyncException.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/BackupSyncException.kt new file mode 100644 index 00000000..dec19a11 --- /dev/null +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/BackupSyncException.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.base.data.extension + +import android.content.Context +import me.proton.core.drive.base.domain.exception.BackupSyncException +import me.proton.core.util.kotlin.CoreLogger +import me.proton.core.drive.i18n.R as I18N + +fun BackupSyncException.getDefaultMessage(context: Context): String = + context.getString(I18N.string.common_error_internal) + +fun BackupSyncException.log(tag: String, message: String = this.message.orEmpty()): BackupSyncException = also { + CoreLogger.e(tag, this, message) +} diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/Throwable.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/Throwable.kt index c301a310..c5e92dcc 100644 --- a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/Throwable.kt +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/extension/Throwable.kt @@ -23,6 +23,8 @@ import android.system.ErrnoException import kotlinx.coroutines.CancellationException import me.proton.core.crypto.common.pgp.exception.CryptoException import me.proton.core.drive.base.data.api.ProtonApiCode.FEATURE_DISABLED +import me.proton.core.drive.base.domain.exception.BackupStopException +import me.proton.core.drive.base.domain.exception.BackupSyncException import me.proton.core.drive.base.domain.exception.InvalidFieldException import me.proton.core.network.data.ProtonErrorException import me.proton.core.network.domain.ApiException @@ -43,6 +45,8 @@ fun Throwable.getDefaultMessage( } else { when (this) { is ApiException -> getDefaultMessage(context) + is BackupStopException -> getDefaultMessage(context) + is BackupSyncException -> getDefaultMessage(context) is CancellationException -> getDefaultMessage(context) is CryptoException -> getDefaultMessage(context) is HttpException -> getDefaultMessage(context) @@ -62,6 +66,8 @@ fun Throwable.getDefaultMessage( fun Throwable.log(tag: String, message: String? = null): Throwable = this.also { when (this) { is ApiException -> message?.let { log(tag, message) } ?: log(tag) + is BackupStopException -> message?.let { log(tag, message) } ?: log(tag) + is BackupSyncException -> message?.let { log(tag, message) } ?: log(tag) is CancellationException -> message?.let { log(tag, message) } ?: log(tag) is CryptoException -> message?.let { log(tag, message) } ?: log(tag) is HttpException -> message?.let { log(tag, message) } ?: log(tag) diff --git a/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/repository/BaseRepositoryImpl.kt b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/repository/BaseRepositoryImpl.kt new file mode 100644 index 00000000..8e905327 --- /dev/null +++ b/drive/base/data/src/main/kotlin/me/proton/core/drive/base/data/repository/BaseRepositoryImpl.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.data.repository + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.data.db.BaseDatabase +import me.proton.core.drive.base.data.db.entity.UrlLastFetchEntity +import me.proton.core.drive.base.domain.entity.TimestampMs +import me.proton.core.drive.base.domain.repository.BaseRepository +import javax.inject.Inject + +class BaseRepositoryImpl @Inject constructor( + private val db: BaseDatabase, +) :BaseRepository { + + override suspend fun getLastFetch(userId: UserId, url: String): TimestampMs? = + db.urlLastFetchDao.get(userId, url)?.let(::TimestampMs) + + override suspend fun setLastFetch(userId: UserId, url: String, lastFetchTimestamp: TimestampMs) { + db.urlLastFetchDao.insertOrUpdate( + UrlLastFetchEntity( + userId = userId, + url = url, + lastFetchTimestamp = lastFetchTimestamp.value, + ) + ) + } +} diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/exception/BackupStopException.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/exception/BackupStopException.kt new file mode 100644 index 00000000..3f7c3b7e --- /dev/null +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/exception/BackupStopException.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.domain.exception + +class BackupStopException( + message: String? = null, + cause: Throwable? = null, +) : Throwable(message, cause) diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/exception/BackupSyncException.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/exception/BackupSyncException.kt new file mode 100644 index 00000000..21d03bba --- /dev/null +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/exception/BackupSyncException.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.domain.exception + +class BackupSyncException( + message: String? = null, + cause: Throwable? = null, +) : Throwable(message, cause) diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Percentage.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Percentage.kt index 157c3e28..21628e52 100644 --- a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Percentage.kt +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Percentage.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -18,8 +18,12 @@ package me.proton.core.drive.base.domain.extension import me.proton.core.drive.base.domain.entity.Percentage +import java.math.RoundingMode import java.text.NumberFormat import java.util.Locale fun Percentage.toPercentString(locale: Locale): String = NumberFormat.getPercentInstance(locale).format(value) + +fun Percentage.rounded(): Percentage = + Percentage(value.toBigDecimal().setScale(2, RoundingMode.UP).toFloat()) diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Timestamp.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Timestamp.kt new file mode 100644 index 00000000..1bc43325 --- /dev/null +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/extension/Timestamp.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.domain.extension + +import me.proton.core.drive.base.domain.entity.TimestampMs +import me.proton.core.drive.base.domain.entity.TimestampS +import me.proton.core.drive.base.domain.entity.toTimestampMs +import kotlin.time.Duration + +fun TimestampMs?.isOlderThen(duration: Duration): Boolean = this?.let { timestamp -> + (timestamp.value + duration.inWholeMilliseconds) < System.currentTimeMillis() +} ?: true + +fun TimestampS?.isOlderThen(duration: Duration): Boolean = this?.toTimestampMs().isOlderThen(duration) diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/log/LogTag.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/log/LogTag.kt index 278da2b5..f687feb6 100644 --- a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/log/LogTag.kt +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/log/LogTag.kt @@ -34,6 +34,7 @@ object LogTag { const val SHARE = "$OPERATION.share" const val PAGING = "$DEFAULT.paging" const val BACKUP = "$DEFAULT.backup" + const val PHOTO = "$DEFAULT.photo" const val TELEMETRY = "$DEFAULT.telemetry" const val UPLOAD = "$DEFAULT.upload" const val UPLOAD_BULK = "$UPLOAD.bulk" diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/provider/ConfigurationProvider.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/provider/ConfigurationProvider.kt index d357cb7f..157209d2 100644 --- a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/provider/ConfigurationProvider.kt +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/provider/ConfigurationProvider.kt @@ -19,11 +19,14 @@ package me.proton.core.drive.base.domain.provider import android.os.Build import me.proton.core.drive.base.domain.entity.Bytes +import me.proton.core.drive.base.domain.entity.TimestampMs +import me.proton.core.drive.base.domain.extension.GiB import me.proton.core.drive.base.domain.extension.KiB import me.proton.core.drive.base.domain.extension.MiB import me.proton.core.drive.base.domain.extension.bytes import kotlin.time.Duration import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -66,6 +69,7 @@ interface ConfigurationProvider { val useExceptionMessage: Boolean get() = false val photosFeatureFlag: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N val photosSavedCounter: Boolean get() = false + val photosUpsellPhotoCount: Int get() = 5 val backupLeftSpace: Bytes get() = 25.MiB val contentDigestAlgorithm: String get() = "SHA1" val digestAlgorithms: List get() = listOf(contentDigestAlgorithm) @@ -85,6 +89,8 @@ interface ConfigurationProvider { val useVerifier: Boolean get() = true val backupDefaultThumbnailsCacheLimit: Int get() = 1000 val backupDefaultThumbnailsCacheLocalStorageThreshold: Bytes get() = 500.MiB + val maxFreeSpace: Bytes get() = 5.GiB + val activeUserPingDuration: Duration get() = 6.hours data class Thumbnail( val maxWidth: Int, diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/repository/BaseRepository.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/repository/BaseRepository.kt new file mode 100644 index 00000000..2ee6e061 --- /dev/null +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/repository/BaseRepository.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.domain.repository + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.entity.TimestampMs + +interface BaseRepository { + + suspend fun getLastFetch(userId: UserId, url: String): TimestampMs? + + suspend fun setLastFetch(userId: UserId, url: String, lastFetchTimestamp: TimestampMs) +} diff --git a/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/usecase/IsValidEmailAddress.kt b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/usecase/IsValidEmailAddress.kt new file mode 100644 index 00000000..4425d885 --- /dev/null +++ b/drive/base/domain/src/main/kotlin/me/proton/core/drive/base/domain/usecase/IsValidEmailAddress.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.domain.usecase + +import javax.inject.Inject + +class IsValidEmailAddress @Inject constructor() { + + operator fun invoke(emailAddress: String): Boolean { + val regex = EMAIL_VALIDATION_PATTERN.toRegex(RegexOption.IGNORE_CASE) + return emailAddress.isNotBlank() && regex.matches(emailAddress) + } + + private companion object { + + // Taken from core, apparently valid for RFC 5322. Does not impose maximum length restrictions, we will + // rely in the API to give us an error in that case. + @Suppress("MaxLineLength") + const val EMAIL_VALIDATION_PATTERN = + """(?:[a-z0-9!#${'$'}%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#${'$'}%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])""" + } +} diff --git a/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/extension/UserAvailableSpaceTest.kt b/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/extension/UserAvailableSpaceTest.kt index b33f01ee..483e1837 100644 --- a/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/extension/UserAvailableSpaceTest.kt +++ b/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/extension/UserAvailableSpaceTest.kt @@ -93,5 +93,6 @@ class UserAvailableSpaceTest { keys = emptyList(), recovery = null, createdAtUtc = 0, + type = null, ) } diff --git a/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/usecase/IsValidEmailAddressTest.kt b/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/usecase/IsValidEmailAddressTest.kt new file mode 100644 index 00000000..b646a0dc --- /dev/null +++ b/drive/base/domain/src/test/kotlin/me/proton/core/drive/base/domain/usecase/IsValidEmailAddressTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.domain.usecase + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class IsValidEmailAddressTest( + private val emailAddress: String, + private val isValid: Boolean, +) { + private val isValidEmailAddress = IsValidEmailAddress() + + @Test + fun test() { + assertEquals(isValid, isValidEmailAddress(emailAddress)) + } + + companion object { + @get:Parameterized.Parameters(name = "{0} should be a valid email address: {1}") + @get:JvmStatic + val data = listOf( + arrayOf("", false), + arrayOf("test", false), + arrayOf("test@", false), + arrayOf("@test.com", false), + arrayOf("a@b.c", true), + ) + } +} + diff --git a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/IllustratedMessage.kt b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/IllustratedMessage.kt new file mode 100644 index 00000000..1ac8bfe2 --- /dev/null +++ b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/IllustratedMessage.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.base.presentation.component + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm +import me.proton.core.compose.theme.headlineNorm +import me.proton.core.drive.base.presentation.R +import me.proton.core.drive.i18n.R as I18N + +@Composable +fun IllustratedMessage( + @DrawableRes imageResId: Int, + @StringRes titleResId: Int, + modifier: Modifier = Modifier, + @StringRes descriptionResId: Int? = null, +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(painter = painterResource(id = imageResId), contentDescription = null) + Text( + text = stringResource(id = titleResId), + style = ProtonTheme.typography.headlineNorm.copy(textAlign = TextAlign.Center), + modifier = Modifier.padding( + top = ProtonDimens.DefaultSpacing, + start = ProtonDimens.DefaultSpacing, + end = ProtonDimens.DefaultSpacing, + ) + ) + descriptionResId?.let { + Text( + text = stringResource(id = descriptionResId), + style = ProtonTheme.typography.defaultNorm.copy(textAlign = TextAlign.Center), + modifier = Modifier.padding( + top = ProtonDimens.SmallSpacing, + start = ProtonDimens.DefaultSpacing, + end = ProtonDimens.DefaultSpacing, + ) + ) + } + } + } +} + +@Preview +@Composable +fun PreviewListEmpty() { + ProtonTheme { + IllustratedMessage( + imageResId = R.drawable.empty_folder_daynight, + titleResId = I18N.string.title_empty_folder, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Preview +@Composable +fun PreviewListEmptyWithDescription() { + ProtonTheme { + IllustratedMessage( + imageResId = R.drawable.empty_folder_daynight, + titleResId = I18N.string.title_empty_folder, + descriptionResId = I18N.string.description_empty_folder, + modifier = Modifier.fillMaxSize(), + ) + } +} diff --git a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/ProtonListItem.kt b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/ProtonListItem.kt index c21a433e..3e399504 100644 --- a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/ProtonListItem.kt +++ b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/ProtonListItem.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -43,6 +44,7 @@ import androidx.compose.ui.unit.dp import me.proton.core.compose.theme.ProtonDimens.DefaultIconSize import me.proton.core.compose.theme.ProtonTheme import me.proton.core.compose.theme.default +import me.proton.core.compose.theme.defaultNorm @Composable fun ProtonListItem( @@ -51,7 +53,8 @@ fun ProtonListItem( modifier: Modifier = Modifier, iconTitlePadding: Dp = ListItemTextStartPadding, iconTintColor: Color = ProtonTheme.colors.iconNorm, -) = ProtonListItem(painterResource(icon), stringResource(title), modifier, iconTitlePadding, iconTintColor) + textStyle: TextStyle = ProtonTheme.typography.defaultNorm, +) = ProtonListItem(painterResource(icon), stringResource(title), modifier, iconTitlePadding, iconTintColor, textStyle) @Composable fun ProtonListItem( @@ -60,6 +63,7 @@ fun ProtonListItem( modifier: Modifier = Modifier, iconTitlePadding: Dp = ListItemTextStartPadding, iconTintColor: Color = ProtonTheme.colors.iconNorm, + textStyle: TextStyle = ProtonTheme.typography.defaultNorm, ) { Row( modifier = modifier @@ -79,7 +83,7 @@ fun ProtonListItem( ) Text( text = title, - style = ProtonTheme.typography.default, + style = textStyle, modifier = Modifier.padding(start = iconTitlePadding), maxLines = 1, overflow = TextOverflow.Ellipsis diff --git a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/TopAppBar.kt b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/TopAppBar.kt index bc3c0009..f8d81dd4 100644 --- a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/TopAppBar.kt +++ b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/component/TopAppBar.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.Flow +import me.proton.core.compose.component.BoxWithNotificationDot import me.proton.core.compose.flow.rememberFlowWithLifecycle import me.proton.core.compose.theme.ProtonDimens.DefaultButtonMinHeight import me.proton.core.compose.theme.ProtonDimens.DefaultIconSize @@ -58,6 +59,7 @@ fun TopAppBar( modifier: Modifier = Modifier, isTitleEncrypted: Boolean = false, backgroundColor: Color = ProtonTheme.colors.backgroundNorm, + notificationDotVisible: Boolean = false, actions: @Composable RowScope.() -> Unit = {}, ) { TopAppBar( @@ -72,6 +74,7 @@ fun TopAppBar( }, modifier = modifier, backgroundColor = backgroundColor, + notificationDotVisible = notificationDotVisible, actions = actions, ) } @@ -83,6 +86,7 @@ fun TopAppBar( title: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier, backgroundColor: Color = ProtonTheme.colors.backgroundNorm, + notificationDotVisible: Boolean = false, actions: @Composable RowScope.() -> Unit = {}, ) { TopAppBar( @@ -90,15 +94,11 @@ fun TopAppBar( title = { title(Modifier.testTag(TopAppBarComponentTestTag.appBar)) }, navigationIcon = { if (navigationIcon != null) { - IconButton( - onClick = { onNavigationIcon() }, - modifier = Modifier.testTag(navigationButton), - ) { - Icon( - painter = navigationIcon, - contentDescription = null, - ) - } + NavigationIconButton( + navigationIcon = navigationIcon, + onNavigationIcon = onNavigationIcon, + notificationDotVisible = notificationDotVisible + ) } }, actions = actions, @@ -126,6 +126,27 @@ private fun Title( } } +@Composable +private fun NavigationIconButton( + navigationIcon: Painter, + onNavigationIcon: () -> Unit, + notificationDotVisible: Boolean +) { + IconButton( + onClick = { onNavigationIcon() }, + modifier = Modifier.testTag(navigationButton), + ) { + BoxWithNotificationDot( + notificationDotVisible = notificationDotVisible, + ) { + Icon( + painter = navigationIcon, + contentDescription = null, + ) + } + } +} + @Composable fun ActionButton( @DrawableRes icon: Int, diff --git a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Bytes.kt b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Bytes.kt index 0debcfd3..76e6e72a 100644 --- a/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Bytes.kt +++ b/drive/base/presentation/src/main/kotlin/me/proton/core/drive/base/presentation/extension/Bytes.kt @@ -33,7 +33,12 @@ fun Bytes.asHumanReadableString(context: Context): String = .replace("\u200f", "") */ @Suppress("UNUSED_PARAMETER") -fun Bytes.asHumanReadableString(context: Context, units: SizeUnits = SizeUnits.BASE2_LEGACY, locale: Locale = Locale.getDefault()): String { +fun Bytes.asHumanReadableString( + context: Context, + units: SizeUnits = SizeUnits.BASE2_LEGACY, + locale: Locale = Locale.getDefault(), + numberOfDecimals: Int = 2, +): String { val absB = if (value == Long.MIN_VALUE) Long.MAX_VALUE else abs(value) if (absB < 1024) return "$value B" var value = absB @@ -50,7 +55,7 @@ fun Bytes.asHumanReadableString(context: Context, units: SizeUnits = SizeUnits.B SizeUnits.BASE2_IEC -> "iB" SizeUnits.BASE10_SI -> throw IllegalArgumentException("Base 10 SI units are not supported") } - return String.format(locale, "%.2f %c$suffix", value / 1024.0, ci.current()) + return String.format(locale, "%.${numberOfDecimals}f %c$suffix", value / 1024.0, ci.current()) } enum class SizeUnits { diff --git a/drive/base/presentation/src/main/res/drawable/img_free_storage.xml b/drive/base/presentation/src/main/res/drawable/img_free_storage.xml new file mode 100644 index 00000000..d4f868f8 --- /dev/null +++ b/drive/base/presentation/src/main/res/drawable/img_free_storage.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/drive/base/presentation/src/main/res/drawable/img_upsell_drive_dark.xml b/drive/base/presentation/src/main/res/drawable/img_upsell_drive_dark.xml new file mode 100644 index 00000000..4458302b --- /dev/null +++ b/drive/base/presentation/src/main/res/drawable/img_upsell_drive_dark.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + diff --git a/drive/base/presentation/src/main/res/drawable/img_upsell_drive_light.xml b/drive/base/presentation/src/main/res/drawable/img_upsell_drive_light.xml new file mode 100644 index 00000000..ae7d48fc --- /dev/null +++ b/drive/base/presentation/src/main/res/drawable/img_upsell_drive_light.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + diff --git a/drive/base/presentation/src/main/res/values-night/drawables.xml b/drive/base/presentation/src/main/res/values-night/drawables.xml index d9757ac2..9d48b4b6 100644 --- a/drive/base/presentation/src/main/res/values-night/drawables.xml +++ b/drive/base/presentation/src/main/res/values-night/drawables.xml @@ -1,19 +1,19 @@ @@ -22,5 +22,6 @@ @drawable/empty_shared_by_me_dark @drawable/empty_trash_dark @drawable/empty_offline_dark + @drawable/img_upsell_drive_dark diff --git a/drive/base/presentation/src/main/res/values/drawables.xml b/drive/base/presentation/src/main/res/values/drawables.xml index e540f912..0e693490 100644 --- a/drive/base/presentation/src/main/res/values/drawables.xml +++ b/drive/base/presentation/src/main/res/values/drawables.xml @@ -1,19 +1,19 @@ @@ -22,5 +22,6 @@ @drawable/empty_shared_by_me_light @drawable/empty_trash_light @drawable/empty_offline_light + @drawable/img_upsell_drive_light diff --git a/drive/contact/domain/build.gradle.kts b/drive/contact/domain/build.gradle.kts new file mode 100644 index 00000000..599522bb --- /dev/null +++ b/drive/contact/domain/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +android { + namespace = "me.proton.core.drive.contact.domain" +} + +driveModule(hilt = true) { + api(project(":drive:base:domain")) + api(project(":drive:share-crypto:domain")) + api(libs.core.contact.domain) +} diff --git a/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/GetContactEmails.kt b/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/GetContactEmails.kt new file mode 100644 index 00000000..31bf4a0d --- /dev/null +++ b/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/GetContactEmails.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.domain.usecase + +import kotlinx.coroutines.flow.Flow +import me.proton.core.contact.domain.entity.ContactEmail +import me.proton.core.contact.domain.repository.ContactRepository +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class GetContactEmails @Inject constructor( + private val contactRepository: ContactRepository, +) { + operator fun invoke(userId: UserId): Flow>> = + contactRepository.observeAllContactEmails(userId) +} diff --git a/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/GetContacts.kt b/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/GetContacts.kt new file mode 100644 index 00000000..6d1d3ee5 --- /dev/null +++ b/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/GetContacts.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.domain.usecase + +import kotlinx.coroutines.flow.Flow +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.contact.domain.repository.ContactRepository +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class GetContacts @Inject constructor( + private val contactRepository: ContactRepository, +) { + operator fun invoke(userId: UserId): Flow>> = + contactRepository.observeAllContacts(userId) +} + diff --git a/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/SearchContacts.kt b/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/SearchContacts.kt new file mode 100644 index 00000000..6b85fd2d --- /dev/null +++ b/drive/contact/domain/src/main/kotlin/me/proton/core/drive/contact/domain/usecase/SearchContacts.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.domain.usecase + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.asSuccess +import me.proton.core.drive.base.domain.extension.transformSuccess +import me.proton.core.util.kotlin.containsNoCase +import me.proton.core.util.kotlin.takeIfNotBlank +import javax.inject.Inject + + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchContacts @Inject constructor( + private val getContacts: GetContacts +) { + + operator fun invoke(userId: UserId, query: String): Flow>> = + getContacts(userId).distinctUntilChanged().transformSuccess { (_, contacts) -> + query.trim().takeIfNotBlank()?.run { + emit(search(contacts, query).asSuccess) + } + }.distinctUntilChanged() + + private fun search(contacts: List, query: String): List = contacts.mapNotNull { contact -> + if (contact.name.containsNoCase(query)) { // if Contact's display name matches, return all contactEmails + contact + } else { // otherwise, return Contact with only matching contactEmails + val matchingContactEmails = contact.contactEmails.filter { contactEmail -> + contactEmail.email.containsNoCase(query) + } + + if (matchingContactEmails.isNotEmpty()) { + contact.copy(contactEmails = matchingContactEmails) + } else null + } + } +} diff --git a/drive/contact/domain/src/test/kotlin/me/proton/core/drive/contact/domain/usecase/SearchContactsTest.kt b/drive/contact/domain/src/test/kotlin/me/proton/core/drive/contact/domain/usecase/SearchContactsTest.kt new file mode 100644 index 00000000..0b4562c8 --- /dev/null +++ b/drive/contact/domain/src/test/kotlin/me/proton/core/drive/contact/domain/usecase/SearchContactsTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.domain.usecase + +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.contact.domain.entity.ContactEmail +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.ResponseSource +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.filterSuccessOrError +import me.proton.core.drive.base.domain.extension.toResult +import org.junit.Assert.assertEquals +import org.junit.Test + +private val testUserId = UserId("user-id") + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchContactsTest { + + private val getContacts = mockk { + coEvery { this@mockk.invoke(testUserId) } returns flowOf(DataResult.Success( + ResponseSource.Local, + ContactTestData.contacts, + )) + } + + private val searchContacts = SearchContacts(getContacts) + + @Test + fun `when there are multiple matching contacts, they are emitted`() = runTest { + val query = "cont" + + val contacts = searchContacts(testUserId, query).filterSuccessOrError().toResult().getOrThrow() + + assertEquals(ContactTestData.contacts, contacts) + } + + @Test + fun `when there is contact matched only by name, it is emitted with all ContactEmails`() = runTest { + // Given + val query = "impo" + + val contact = ContactTestData.buildContactWith( + userId = testUserId, + name = "important contact display name", // <-- match + contactEmails = listOf( + ContactTestData.buildContactEmailWith( + name = "name 1", + address = "address1@proton.ch" + ), + ContactTestData.buildContactEmailWith( + name = "name 2", + address = "address2@protonmail.ch" + ) + ) + ) + coEvery { getContacts(testUserId) } returns flowOf(DataResult.Success( + ResponseSource.Local, + ContactTestData.contacts + contact, + )) + + val contacts = searchContacts(testUserId, query).filterSuccessOrError().toResult().getOrThrow() + + assertEquals(listOf(contact), contacts) + } + + @Test + fun `when there is contact matched only by ContactEmail, it is emitted with only matching ContactEmails`() = + runTest { + // Given + val query = "mail" + + val contact = ContactTestData.buildContactWith( + userId = testUserId, + name = "important contact display name", + contactEmails = listOf( + ContactTestData.buildContactEmailWith( + name = "name 1", + address = "address1@proton.ch" + ), + ContactTestData.buildContactEmailWith( + name = "name 2", + address = "address2@protonmail.ch" // <-- match + ) + ) + ) + coEvery { getContacts(testUserId) } returns flowOf(DataResult.Success( + ResponseSource.Local, + ContactTestData.contacts + contact, + )) + + val contacts = searchContacts(testUserId, query).filterSuccessOrError().toResult().getOrThrow() + + assertEquals(1, contacts.size) + + val matchedContact = contacts.first() + + assertEquals(contact.userId, matchedContact.userId) + assertEquals(contact.id, matchedContact.id) + assertEquals(contact.name, matchedContact.name) + assertEquals( + listOf(contact.contactEmails[1]), // return only 2nd ContactEmail + listOf(matchedContact.contactEmails.first()) + ) + } + + @Test + fun `when there are no matching contacts, empty list is emitted`() = runTest { + val query = "there is no contact like this" + + val contacts = searchContacts(testUserId, query).filterSuccessOrError().toResult().getOrThrow() + + assertEquals(emptyList(), contacts) + } +} + +object ContactTestData { + + private val contact1 = Contact(testUserId, ContactIdTestData.contactId1, "first contact", emptyList()) + private val contact2 = Contact(testUserId, ContactIdTestData.contactId2, "second contact", emptyList()) + + val contacts = listOf( + contact1, + contact2 + ) + + fun buildContactWith( + userId: UserId = testUserId, + contactId: ContactId = ContactIdTestData.contactId1, + contactEmails: List, + name: String? = null + ) = Contact( + userId = userId, + id = contactId, + name = name ?: "contact name", + contactEmails = contactEmails + ) + + fun buildContactEmailWith( + userId: UserId = testUserId, + contactEmailId: ContactEmailId = ContactIdTestData.contactEmailId1, + contactId: ContactId = ContactIdTestData.contactId1, + name: String, + address: String + ) = ContactEmail( + userId = userId, + id = contactEmailId, + name = name, + email = address, + defaults = 0, + order = 0, + contactId = contactId, + canonicalEmail = address, + labelIds = emptyList(), + isProton = null + ) +} + +object ContactIdTestData { + + val contactId1 = ContactId("1") + val contactId2 = ContactId("2") + + val contactEmailId1 = ContactEmailId("1") +} + + diff --git a/drive/contact/presentation/build.gradle.kts b/drive/contact/presentation/build.gradle.kts new file mode 100644 index 00000000..eb294d3f --- /dev/null +++ b/drive/contact/presentation/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +plugins { + id("com.android.library") +} + +android { + namespace = "me.proton.core.drive.contact.presentation" +} + +driveModule( + hilt = true, + compose = true, + i18n = true, +) { + api(project(":drive:base:presentation")) + api(project(":drive:contact:domain")) + api(libs.core.presentation.compose) +} diff --git a/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/cc b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/cc new file mode 100644 index 00000000..e69de29b diff --git a/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsListField.kt b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsListField.kt new file mode 100644 index 00000000..95b3e9ae --- /dev/null +++ b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsListField.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.presentation.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultSmallNorm + +/* + Composable that displays a ChipsListTextField with a label in a row (useful for forms) + */ +@Composable +fun ChipsListField( + hint: String, + value: List, + modifier: Modifier = Modifier, + chipValidator: (String) -> Boolean = { true }, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + focusRequester: FocusRequester? = null, + focusOnClick: Boolean = true, + actions: ChipsListField.Actions, + contactSuggestionState: ContactSuggestionState, +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier, + ) { + var hintVisible by remember(value) { mutableStateOf(value.isEmpty()) } + if (hintVisible) { + Text( + text = hint, + modifier = Modifier.align(Alignment.CenterStart), + color = ProtonTheme.colors.textWeak, + style = ProtonTheme.typography.defaultSmallNorm + ) + } + ChipsListTextField( + modifier = Modifier + .thenIf(focusOnClick) { + clickable( + interactionSource = interactionSource, + indication = null, + onClick = { focusRequester?.requestFocus() } + ) + } + .fillMaxSize(), + chipValidator = chipValidator, + onListChanged = actions.onListChanged, + value = value, + keyboardOptions = keyboardOptions, + focusRequester = focusRequester, + actions = ChipsListTextField.Actions( + onSuggestionTermTyped = { searchTerm -> + hintVisible = value.isEmpty() && searchTerm.isEmpty() + actions.onSuggestionTermTyped(searchTerm) + }, + onSuggestionsDismissed = actions.onSuggestionsDismissed + ), + contactSuggestionState = contactSuggestionState + ) + } +} + +@Stable +data class ContactSuggestionState( + val areSuggestionsExpanded: Boolean, + val suggestionItems: List, +) + +object ChipsListField { + data class Actions( + val onSuggestionTermTyped: (String) -> Unit, + val onSuggestionsDismissed: () -> Unit, + val onListChanged: (List) -> Unit, + ) +} + +fun Modifier.thenIf(condition: Boolean, modifier: Modifier.() -> Modifier): Modifier { + return if (condition) { + then(modifier()) + } else { + this + } +} + + diff --git a/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsListTextField.kt b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsListTextField.kt new file mode 100644 index 00000000..b0456fe5 --- /dev/null +++ b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsListTextField.kt @@ -0,0 +1,494 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.presentation.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.InputChip +import androidx.compose.material3.SuggestionChip +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm +import me.proton.core.compose.theme.defaultSmallNorm +import me.proton.core.compose.theme.defaultSmallWeak +import me.proton.core.util.kotlin.takeIfNotBlank + +@Stable +sealed class ChipItem(open val value: String) { + + data class Valid(override val value: String) : ChipItem(value) + data class Invalid(override val value: String) : ChipItem(value) + data class Counter(override val value: String) : ChipItem(value) +} + +@Stable +data class SuggestionItem( + val header: String, + val subheader: String +) + +/* + Composable that displays a TextField where the user can type and + a chips list will be created from the text typed. A chip can be added + when the user presses space or when the focus is lost from the field (by tapping in + a different field or tapping the keyboard in a key that moves the focus away). + + When suggestion options are provided, it also displays DropdownMenu and auto-types + them into TextField on click. + */ +@OptIn( + ExperimentalLayoutApi::class, + ExperimentalMaterial3Api::class +) +@Composable +fun ChipsListTextField( + value: List, + modifier: Modifier = Modifier, + chipValidator: (String) -> Boolean = { true }, + onListChanged: (List) -> Unit, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + focusRequester: FocusRequester? = null, + cursorColor: Color = ProtonTheme.colors.brandDarken20, + textStyle: TextStyle = ProtonTheme.typography.defaultSmallNorm, + animateChipsCreation: Boolean = false, + actions: ChipsListTextField.Actions, + contactSuggestionState: ContactSuggestionState +) { + val state by remember { mutableStateOf(ChipsListState(chipValidator, onListChanged)) } + + state.updateItems(value) + + val focusManager = LocalFocusManager.current + val localDensity = LocalDensity.current + var textMaxWidth by remember { mutableStateOf(Dp.Unspecified) } + FlowRow( + modifier = modifier + .defaultMinSize(minWidth = 50.dp) + .onSizeChanged { size -> + if (textMaxWidth == Dp.Unspecified) { + textMaxWidth = with(localDensity) { size.width.toDp() } + } + }, + verticalArrangement = Arrangement.Center + ) { + + when (val items = state.getItems()) { + ChipItemsList.Empty -> Unit + is ChipItemsList.Focused -> FocusedChipsList( + items.items, + animateChipsCreation, + textMaxWidth + ) { state.onDelete(it) } + + is ChipItemsList.Unfocused.Multiple -> UnFocusedChipsList( + items.item, + items.counter + ) { focusRequester?.requestFocus() } + + is ChipItemsList.Unfocused.Single -> UnFocusedChipsList(items.item) { focusRequester?.requestFocus() } + } + + ExposedDropdownMenuBox( + expanded = contactSuggestionState.areSuggestionsExpanded, + onExpandedChange = {} + ) { + BasicTextField( + modifier = Modifier + .testTag(ChipsTestTags.BasicTextField) + .thenIf(focusRequester != null) { + focusRequester(focusRequester!!) + } + .thenIf(!state.isFocused()) { + height(0.dp) + } + .padding(vertical = 16.dp) + .onKeyEvent { keyEvent -> + if (keyEvent.key == Key.Backspace) { + state.onDelete() + true + } else { + false + } + } + .onFocusChanged { focusChange -> + state.typeWord(state.getTypedText()) + state.setFocusState(focusChange.isFocused) + } + .menuAnchor(), + value = state.getTypedText(), + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions( + onNext = { + state.typeWord(state.getTypedText()) + focusManager.moveFocus(FocusDirection.Next) + }, + onDone = { + state.typeWord(state.getTypedText()) + focusManager.clearFocus() + }, + onPrevious = { + state.typeWord(state.getTypedText()) + focusManager.moveFocus(FocusDirection.Previous) + } + ), + onValueChange = { newText -> + state.type(newText) + actions.onSuggestionTermTyped(newText) + }, + cursorBrush = SolidColor(cursorColor), + textStyle = textStyle + ) + + if (contactSuggestionState.suggestionItems.isNotEmpty()) { + DropdownMenu( + modifier = Modifier + .background(ProtonTheme.colors.backgroundNorm) + .width(textMaxWidth) + .exposedDropdownSize(false), + properties = PopupProperties(focusable = false), + expanded = contactSuggestionState.areSuggestionsExpanded, + onDismissRequest = { + actions.onSuggestionsDismissed() + } + ) { + contactSuggestionState.suggestionItems.forEach { selectionOption -> + DropdownMenuItem( + text = { + Column(modifier = Modifier.padding(vertical = ProtonDimens.SmallSpacing)) { + Text( + text = selectionOption.header, + maxLines = 1, + color = ProtonTheme.colors.textNorm, + style = ProtonTheme.typography.defaultNorm, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.size(ProtonDimens.ExtraSmallSpacing)) + Text( + text = selectionOption.subheader, + maxLines = 1, + color = ProtonTheme.colors.textWeak, + style = ProtonTheme.typography.defaultSmallWeak, + overflow = TextOverflow.Ellipsis + ) + } + + }, + onClick = { + state.typeWord(selectionOption.subheader) + actions.onSuggestionsDismissed() + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + } + } + } + + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FocusedChipsList( + chipItems: List, + animateChipsCreation: Boolean = false, + textMaxWidth: Dp, + onDeleteItem: (Int) -> Unit +) { + chipItems.forEachIndexed { index, chipItem -> + val scale by remember { mutableStateOf(Animatable(0F)) } + val alpha by remember { mutableStateOf(Animatable(0F)) } + InputChip( + modifier = Modifier + .testTag("${ChipsTestTags.InputChip}$index") + .semantics { isValidField = chipItem !is ChipItem.Invalid } + .padding(horizontal = 4.dp) + .thenIf(animateChipsCreation) { + scale(scale.value) + alpha(alpha.value) + }, + selected = false, + onClick = { onDeleteItem(index) }, + label = { + Text( + modifier = Modifier + .testTag(ChipsTestTags.InputChipText) + .widthIn(max = textMaxWidth - 64.dp), + text = chipItem.value, + color = when (chipItem) { + is ChipItem.Invalid -> Color.Red + else -> Color.Unspecified + } + ) + }, + shape = chipShape, + trailingIcon = { + Icon( + Icons.Default.Clear, + modifier = Modifier + .testTag(ChipsTestTags.InputChipIcon) + .size(16.dp), + contentDescription = "" + ) + } + ) + LaunchedEffect(key1 = index) { + if (animateChipsCreation) { + scale.animateTo(1F) + alpha.animateTo(1F) + } + } + } +} + +@Composable +@Suppress("MagicNumber") +private fun UnFocusedChipsList( + itemChip: ChipItem, + counterChip: ChipItem? = null, + onChipClick: () -> Unit = {} +) { + Row { + SuggestionChip( + modifier = Modifier + .testTag(ChipsTestTags.BaseSuggestionChip) + .semantics { isValidField = itemChip !is ChipItem.Invalid } + .weight(1f, fill = false) + .padding(horizontal = 4.dp), + onClick = onChipClick, + label = { + Text( + modifier = Modifier.testTag(ChipsTestTags.InputChipText), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = itemChip.value, + color = when (itemChip) { + is ChipItem.Invalid -> Color.Red + else -> Color.Unspecified + } + ) + }, + shape = chipShape + ) + if (counterChip != null) { + SuggestionChip( + modifier = Modifier + .testTag(ChipsTestTags.AdditionalSuggestionChip) + .semantics { isValidField = itemChip !is ChipItem.Invalid } + .padding(horizontal = 4.dp), + onClick = onChipClick, + label = { + Text( + modifier = Modifier.testTag(ChipsTestTags.InputChipText), + maxLines = 1, + text = counterChip.value, + color = Color.Unspecified + ) + }, + shape = chipShape + ) + } + } +} + +@Stable +internal class ChipsListState( + private val isValid: (String) -> Boolean, + private val onListChanged: (List) -> Unit +) { + + private val items: SnapshotStateList = mutableStateListOf() + private val typedText: MutableState = mutableStateOf("") + private val focusedState: MutableState = mutableStateOf(false) + + fun updateItems(newItems: List) { + if (newItems != items.toList()) { + items.clear() + items.addAll(newItems) + } + } + + fun getItems(): ChipItemsList = when { + items.isEmpty() -> ChipItemsList.Empty + items.size == 1 && !focusedState.value -> ChipItemsList.Unfocused.Single(items.first()) + items.size > 1 && !focusedState.value -> { + ChipItemsList.Unfocused.Multiple(items.first(), ChipItem.Counter("+${items.size - 1}")) + } + + else -> ChipItemsList.Focused(items) + } + + fun getTypedText(): String = typedText.value + + fun type(newValue: String) { + when { + typedText.value + newValue.trim() == EmptyString -> clearTypedText() + newValue.endsWith(WordSeparator) -> { + + val words = typedText.value.split( + WordSeparator, + NewLineDelimiter, + CarriageReturnNewLineDelimiter, + CommaDelimiter, + SemiColonDelimiter, + TabDelimiter + ).mapNotNull { it.takeIfNotBlank() } + + if (words.isNotEmpty()) { + words.forEach { add(it) } + // added here so we only check for duplicates after pasting all the words + onListChanged(items) + clearTypedText() + } else typedText.value = newValue + } + + else -> typedText.value = newValue + } + } + + fun typeWord(word: String) { + type(word) + type(WordSeparator) + } + + fun isFocused(): Boolean = focusedState.value + + private fun add(item: String) { + val chipContent = if (isValid(item)) { + ChipItem.Valid(item) + } else { + ChipItem.Invalid(item) + } + + items.add(chipContent) + + } + + fun onDelete() { + if (typedText.value.isEmpty()) { + items.removeLastOrNull() + } + onListChanged(items) + } + + fun onDelete(index: Int) { + items.removeAt(index) + onListChanged(items) + } + + fun setFocusState(focused: Boolean) { + focusedState.value = focused + } + + private fun clearTypedText() { + typedText.value = EmptyString + } + + private companion object { + + private const val WordSeparator = " " + private const val EmptyString = "" + private const val NewLineDelimiter = "\n" + private const val CarriageReturnNewLineDelimiter = "\r\n" + private const val SemiColonDelimiter = ";" + private const val CommaDelimiter = "," + private const val TabDelimiter = "\t" + } +} + +private val chipShape = RoundedCornerShape(16.dp) + +@Stable +internal sealed class ChipItemsList { + + object Empty : ChipItemsList() + + data class Focused(val items: List) : ChipItemsList() + + @Stable + sealed class Unfocused : ChipItemsList() { + + data class Single(val item: ChipItem) : Unfocused() + data class Multiple(val item: ChipItem, val counter: ChipItem) : Unfocused() + } +} + +object ChipsListTextField { + data class Actions( + val onSuggestionTermTyped: (String) -> Unit, + val onSuggestionsDismissed: () -> Unit + ) +} diff --git a/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsSemanticsProperties.kt b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsSemanticsProperties.kt new file mode 100644 index 00000000..e4dd7814 --- /dev/null +++ b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsSemanticsProperties.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.presentation.component + +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import me.proton.core.drive.contact.presentation.component.ChipsSemanticsPropertyKeys.isValidField + +/** + * Extension used to specify whether the field is valid or not as a custom [SemanticsPropertyKey]. + */ +var SemanticsPropertyReceiver.isValidField by isValidField + +internal object ChipsSemanticsPropertyKeys { + + val isValidField = SemanticsPropertyKey("IsValidFieldKey") +} diff --git a/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsTestTags.kt b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsTestTags.kt new file mode 100644 index 00000000..65f399d9 --- /dev/null +++ b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/component/ChipsTestTags.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.presentation.component + +object ChipsTestTags { + + const val BasicTextField = "BasicTextField" + const val InputChip = "InputChip" + const val InputChipIcon = "InputChipIcon" + const val InputChipText = "InputChipText" + const val BaseSuggestionChip = "InputChip0" + const val AdditionalSuggestionChip = "InputChip1" +} diff --git a/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/viewstate/ContactSuggestion.kt b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/viewstate/ContactSuggestion.kt new file mode 100644 index 00000000..872b8eda --- /dev/null +++ b/drive/contact/presentation/src/main/kotlin/me/proton/core/drive/contact/presentation/viewstate/ContactSuggestion.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.contact.presentation.viewstate + +data class ContactSuggestion( + val name: String, + val email: String +) diff --git a/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/GenerateNestedPrivateKey.kt b/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/GenerateNestedPrivateKey.kt index aaefa79a..98256220 100644 --- a/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/GenerateNestedPrivateKey.kt +++ b/drive/crypto-base/domain/src/main/kotlin/me/proton/core/drive/cryptobase/domain/usecase/GenerateNestedPrivateKey.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -46,6 +46,18 @@ class GenerateNestedPrivateKey @Inject constructor( .encryptAndSignPassphrase(encryptKey, signKey, cryptoContext) } + @Suppress("UNUSED_PARAMETER") + suspend operator fun invoke( + userId: UserId, + encryptKeys: List, + signKey: KeyHolder, + coroutineContext: CoroutineContext = CryptoScope.EncryptAndDecrypt.coroutineContext, + ): Result = coRunCatching(coroutineContext) { + NestedPrivateKey + .generateNestedPrivateKey(cryptoContext, DEFAULT_USERNAME, DEFAULT_DOMAIN, generatePassphrase::invoke) + .encryptAndSignPassphrase(encryptKeys, signKey, cryptoContext) + } + suspend operator fun invoke( userId: UserId, encryptKey: KeyHolder, diff --git a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/link/CreateRenameInfo.kt b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/link/CreateRenameInfo.kt index 567f1ae0..d1b0e193 100644 --- a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/link/CreateRenameInfo.kt +++ b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/link/CreateRenameInfo.kt @@ -23,12 +23,14 @@ import me.proton.core.drive.crypto.domain.usecase.HmacSha256 import me.proton.core.drive.cryptobase.domain.usecase.ChangeMessage import me.proton.core.drive.key.domain.extension.keyHolder import me.proton.core.drive.key.domain.usecase.GetAddressKeys +import me.proton.core.drive.key.domain.usecase.GetLinkParentKey import me.proton.core.drive.key.domain.usecase.GetNodeHashKey import me.proton.core.drive.key.domain.usecase.GetNodeKey import me.proton.core.drive.link.domain.entity.Link import me.proton.core.drive.link.domain.entity.RenameInfo import me.proton.core.drive.link.domain.extension.userId import me.proton.core.drive.link.domain.usecase.ValidateLinkName +import java.util.UUID import javax.inject.Inject class CreateRenameInfo @Inject constructor( @@ -39,6 +41,7 @@ class CreateRenameInfo @Inject constructor( private val getNodeKey: GetNodeKey, private val getNodeHashKey: GetNodeHashKey, private val hmacSha256: HmacSha256, + private val getLinkParentKey: GetLinkParentKey ) { suspend operator fun invoke( parentFolder: Link.Folder, @@ -63,4 +66,31 @@ class CreateRenameInfo @Inject constructor( signatureAddress = signatureAddress, ) } + + suspend operator fun invoke( + rootFolder: Link.Folder, + name: String, + shouldValidateName: Boolean = true, + ): Result = coRunCatching { + require(rootFolder.parentId == null) { "Use this method only for renaming a root folder" } + val folderName = if (shouldValidateName) { + validateLinkName(name).getOrThrow() + } else { + name + } + val parentKey = getLinkParentKey(rootFolder).getOrThrow() + val signatureAddress = getSignatureAddress(rootFolder.userId) + RenameInfo( + name = changeMessage( + oldMessage = rootFolder.name, + oldMessageDecryptionKey = parentKey.keyHolder, + newMessage = folderName, + signKey = getAddressKeys(rootFolder.userId, signatureAddress).keyHolder, + ).getOrThrow(), + hash = UUID.randomUUID().toString(), // root folder does not have a hash but it's required by endpoint + previousHash = rootFolder.hash, + mimeType = rootFolder.mimeType, + signatureAddress = signatureAddress, + ) + } } diff --git a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareInfo.kt b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareInfo.kt index ab975e48..bc71ece3 100644 --- a/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareInfo.kt +++ b/drive/crypto/domain/src/main/kotlin/me/proton/core/drive/crypto/domain/usecase/share/CreateShareInfo.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Proton AG. + * Copyright (c) 2022-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -40,7 +40,7 @@ class CreateShareInfo @Inject constructor( val userId = linkId.shareId.userId val link = getLink(linkId).toResult().getOrThrow() val addressId = getAddressId(userId) - val shareKey = generateShareKey(userId, addressId).getOrThrow() + val shareKey = generateShareKey(userId, addressId, linkId).getOrThrow() ShareInfo( addressId = addressId, name = name, diff --git a/drive/data/build.gradle.kts b/drive/data/build.gradle.kts new file mode 100644 index 00000000..67d1b764 --- /dev/null +++ b/drive/data/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021-2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +android { + namespace = "me.proton.core.drive.data" +} + +driveModule(includeSubmodules = true) diff --git a/drive/data/data/build.gradle.kts b/drive/data/data/build.gradle.kts new file mode 100644 index 00000000..cbc0a717 --- /dev/null +++ b/drive/data/data/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +android { + namespace = "me.proton.core.drive.data.data" +} + +driveModule( + hilt = true, + serialization = true, +) { + api(project(":drive:base:data")) + api(project(":drive:data:domain")) +} diff --git a/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/api/DataApi.kt b/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/api/DataApi.kt new file mode 100644 index 00000000..152fbd4b --- /dev/null +++ b/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/api/DataApi.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.data.data.api + +import me.proton.core.drive.base.data.api.response.CodeResponse +import me.proton.core.network.data.protonApi.BaseRetrofitApi +import retrofit2.http.GET + +interface DataApi : BaseRetrofitApi { + + @GET(URL_PING_ACTIVE_USER) + suspend fun pingActiveUser(): CodeResponse + + companion object { + const val URL_PING_ACTIVE_USER = "drive/me/active" + } +} diff --git a/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/api/DataApiDataSource.kt b/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/api/DataApiDataSource.kt new file mode 100644 index 00000000..b9a4833e --- /dev/null +++ b/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/api/DataApiDataSource.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.data.data.api + +import me.proton.core.domain.entity.UserId +import me.proton.core.network.data.ApiProvider +import me.proton.core.network.domain.ApiException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DataApiDataSource @Inject constructor( + private val apiProvider: ApiProvider +) { + + @Throws(ApiException::class) + suspend fun pingActiveUser( + userId: UserId, + ) = + apiProvider + .get(userId) + .invoke { + pingActiveUser() + }.valueOrThrow +} diff --git a/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/di/DataBindModule.kt b/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/di/DataBindModule.kt new file mode 100644 index 00000000..13411070 --- /dev/null +++ b/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/di/DataBindModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.data.data.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.core.drive.data.data.repository.DataRepositoryImpl +import me.proton.core.drive.data.domain.repository.DataRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface DataBindModule { + + @Binds + @Singleton + fun bindsRepositoryImpl(impl: DataRepositoryImpl): DataRepository +} diff --git a/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/repository/DataRepositoryImpl.kt b/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/repository/DataRepositoryImpl.kt new file mode 100644 index 00000000..44d58082 --- /dev/null +++ b/drive/data/data/src/main/kotlin/me/proton/core/drive/data/data/repository/DataRepositoryImpl.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.data.data.repository + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.data.data.api.DataApi +import me.proton.core.drive.data.data.api.DataApiDataSource +import me.proton.core.drive.data.domain.repository.DataRepository +import javax.inject.Inject + +class DataRepositoryImpl @Inject constructor( + private val api: DataApiDataSource, +) : DataRepository { + override val pingActiveUserUrl: String + get() = DataApi.URL_PING_ACTIVE_USER + + override suspend fun pingActiveUser(userId: UserId) { + api.pingActiveUser(userId) + } +} diff --git a/drive/data/domain/build.gradle.kts b/drive/data/domain/build.gradle.kts new file mode 100644 index 00000000..6846efad --- /dev/null +++ b/drive/data/domain/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +android { + namespace = "me.proton.core.drive.data.domain" +} + +driveModule( + hilt = true, + socialTest = true, +) { + api(project(":drive:base:domain")) + testImplementation(project(":drive:data:data")) +} diff --git a/drive/data/domain/src/main/kotlin/me/proton/core/drive/data/domain/repository/DataRepository.kt b/drive/data/domain/src/main/kotlin/me/proton/core/drive/data/domain/repository/DataRepository.kt new file mode 100644 index 00000000..321b8a5b --- /dev/null +++ b/drive/data/domain/src/main/kotlin/me/proton/core/drive/data/domain/repository/DataRepository.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.data.domain.repository + +import me.proton.core.domain.entity.UserId + +interface DataRepository { + val pingActiveUserUrl: String + + suspend fun pingActiveUser(userId: UserId) +} diff --git a/drive/data/domain/src/main/kotlin/me/proton/core/drive/data/domain/usecase/PingActiveUser.kt b/drive/data/domain/src/main/kotlin/me/proton/core/drive/data/domain/usecase/PingActiveUser.kt new file mode 100644 index 00000000..d3301d60 --- /dev/null +++ b/drive/data/domain/src/main/kotlin/me/proton/core/drive/data/domain/usecase/PingActiveUser.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.data.domain.usecase + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.entity.TimestampMs +import me.proton.core.drive.base.domain.extension.isOlderThen +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.repository.BaseRepository +import me.proton.core.drive.base.domain.util.coRunCatching +import me.proton.core.drive.data.domain.repository.DataRepository +import javax.inject.Inject + +class PingActiveUser @Inject constructor( + private val dataRepository: DataRepository, + private val baseRepository: BaseRepository, + private val configurationProvider: ConfigurationProvider, +) { + + suspend operator fun invoke(userId: UserId) = coRunCatching { + val pingActiveUserUrl = dataRepository.pingActiveUserUrl + val lastFetch = baseRepository.getLastFetch(userId, pingActiveUserUrl) + if (lastFetch.isOlderThen(configurationProvider.activeUserPingDuration)) { + dataRepository.pingActiveUser(userId) + baseRepository.setLastFetch(userId, pingActiveUserUrl, TimestampMs()) + } + } +} diff --git a/drive/data/domain/src/test/kotlin/me/proton/core/drive/data/domain/usecase/PingActiveUserTest.kt b/drive/data/domain/src/test/kotlin/me/proton/core/drive/data/domain/usecase/PingActiveUserTest.kt new file mode 100644 index 00000000..c1d60ba5 --- /dev/null +++ b/drive/data/domain/src/test/kotlin/me/proton/core/drive/data/domain/usecase/PingActiveUserTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.data.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.base.data.api.response.CodeResponse +import me.proton.core.drive.base.domain.entity.TimestampMs +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.repository.BaseRepository +import me.proton.core.drive.data.domain.repository.DataRepository +import me.proton.core.drive.db.test.user +import me.proton.core.drive.db.test.userId +import me.proton.core.drive.test.DriveRule +import me.proton.core.drive.test.api.get +import me.proton.core.drive.test.api.jsonResponse +import me.proton.core.drive.test.api.routing +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class PingActiveUserTest { + + @get:Rule + var driveRule = DriveRule(this) + + @Inject lateinit var pingActiveUser: PingActiveUser + @Inject lateinit var baseRepository: BaseRepository + @Inject lateinit var dataRepository: DataRepository + @Inject lateinit var configurationProvider: ConfigurationProvider + + @Before + fun before() = runTest { + driveRule.server.routing { + get("/drive/me/active") { + jsonResponse { + CodeResponse(1000) + } + } + } + driveRule.db.user { } + } + + @Test + fun firstPingActiveUser() = runTest { + // When + pingActiveUser(userId).getOrThrow() + + // Then + assertNotNull( + baseRepository.getLastFetch(userId, dataRepository.pingActiveUserUrl) + ) + } + + @Test + fun beforePingActiveUserDurationElapsesPingActiveUserShouldDoNothing() = runTest { + // Given + val currentTimeInMs = TimestampMs().value + val activeUserPingDurationInMs = configurationProvider.activeUserPingDuration.inWholeMilliseconds + val offsetDurationInMs = 10.seconds.inWholeMilliseconds + val lastFetchTimestamp = currentTimeInMs - activeUserPingDurationInMs + offsetDurationInMs + baseRepository.setLastFetch(lastFetchTimestamp) + + // When + pingActiveUser(userId).getOrThrow() + + // Then + assertEquals( + lastFetchTimestamp, + requireNotNull(baseRepository.getLastFetch(userId, dataRepository.pingActiveUserUrl)).value, + ) + } + + @Test + fun afterPingActiveUserDurationElapsesPingActiveUserShouldPingAgain() = runTest { + // Given + val currentTimeInMs = TimestampMs().value + val activeUserPingDurationInMs = configurationProvider.activeUserPingDuration.inWholeMilliseconds + val offsetDurationInMs = 10.seconds.inWholeMilliseconds + val lastFetchTimestamp = currentTimeInMs - activeUserPingDurationInMs - offsetDurationInMs + baseRepository.setLastFetch(lastFetchTimestamp) + + // When + pingActiveUser(userId).getOrThrow() + + // Then + assertTrue( + requireNotNull( + baseRepository.getLastFetch(userId, dataRepository.pingActiveUserUrl) + ).value > lastFetchTimestamp + ) + } + + private suspend fun BaseRepository.setLastFetch(lastFetchTimestamp: Long) { + setLastFetch(userId, dataRepository.pingActiveUserUrl, TimestampMs(lastFetchTimestamp)) + } +} diff --git a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Device.kt b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Device.kt new file mode 100644 index 00000000..8b2b4637 --- /dev/null +++ b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Device.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.db.test + +import me.proton.android.drive.db.DriveDatabase +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.share.data.api.ShareDto +import me.proton.core.drive.share.domain.entity.ShareId + +suspend fun DriveDatabaseRule.device(index: Int = defaultIndex, block: suspend FolderContext.() -> Unit): FolderId { + return db.device(index, block) +} + +fun deviceShareId(index: Int = defaultIndex) = ShareId(userId, "device-share-id-$index") +fun deviceRootId(index: Int = defaultIndex) = FolderId(photoShareId, "device-${index}-root-id") + +suspend fun DriveDatabase.device(index: Int = defaultIndex, block: suspend FolderContext.() -> Unit): FolderId { + user { + volume { + deviceShare(index, block) + } + } + return deviceRootId(index) +} + +suspend fun VolumeContext.deviceShare(index: Int = defaultIndex, block: suspend FolderContext.() -> Unit) { + share( + shareEntity = NullableShareEntity( + id = deviceShareId(index).id, + userId = user.userId, + volumeId = volumeId.id, + linkId = deviceRootId(index).id, + type = ShareDto.TYPE_DEVICE, + ) + ) { + folder(id = share.linkId, block = block) + } +} + +private val defaultIndex = 1 diff --git a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Link.kt b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Link.kt index e66ace72..e1aa130b 100644 --- a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Link.kt +++ b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Link.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -20,15 +20,23 @@ package me.proton.core.drive.db.test import me.proton.android.drive.db.DriveDatabase import me.proton.core.domain.entity.UserId +import me.proton.core.drive.link.data.api.entity.LinkDto import me.proton.core.drive.link.data.db.entity.LinkEntity import me.proton.core.drive.link.data.db.entity.LinkFilePropertiesEntity import me.proton.core.drive.link.data.db.entity.LinkFolderPropertiesEntity +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.photo.data.db.entity.PhotoListingEntity +import me.proton.core.drive.share.data.api.ShareDto import me.proton.core.drive.share.data.db.ShareEntity +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.volume.data.db.VolumeEntity import me.proton.core.user.data.entity.UserEntity data class FolderContext( val db: DriveDatabase, val user: UserEntity, + val volume: VolumeEntity, val share: ShareEntity, val link: LinkEntity, val parent: LinkEntity? = null, @@ -40,13 +48,13 @@ data class FileContext( val share: ShareEntity, val link: LinkEntity, val parent: LinkEntity, - val revisionId: String + val revisionId: String, ) : BaseContext() suspend fun ShareContext.folder( id: String, - block: suspend FolderContext.() -> Unit = {}, -) { + block: suspend FolderContext.() -> Unit, +): FolderId { folder( link = NullableLinkEntity(id = id, type = 1L), properties = LinkFolderPropertiesEntity( @@ -57,6 +65,7 @@ suspend fun ShareContext.folder( ), block = block, ) + return FolderId(ShareId(user.userId, share.id), id) } suspend fun ShareContext.folder( @@ -66,11 +75,14 @@ suspend fun ShareContext.folder( ) { db.driveLinkDao.insertOrUpdate(link) db.driveLinkDao.insertOrUpdate(properties) - FolderContext(db, user, share, link).block() + FolderContext(db, user, volume, share, link).block() } -suspend fun FolderContext.folder(id: String, block: suspend FolderContext.() -> Unit = {}) { +suspend fun FolderContext.folder( + id: String, + block: suspend FolderContext.() -> Unit = {}, +): FolderId { folder( link = NullableLinkEntity(id = id, parentId = link.id, type = 1L), properties = LinkFolderPropertiesEntity( @@ -81,6 +93,7 @@ suspend fun FolderContext.folder(id: String, block: suspend FolderContext.() -> ), block = block ) + return FolderId(ShareId(user.userId, share.id), id) } suspend fun FolderContext.folder( @@ -90,13 +103,13 @@ suspend fun FolderContext.folder( ) { db.driveLinkDao.insertOrUpdate(link) db.driveLinkDao.insertOrUpdate(properties) - FolderContext(db, user, share, link, this.link).block() + FolderContext(db, user, volume, share, link, this.link).block() } suspend fun FolderContext.file( id: String, block: suspend FileContext.() -> Unit = {}, -) { +): FileId { file( link = NullableLinkEntity( id = id, @@ -115,12 +128,27 @@ suspend fun FolderContext.file( ), block = block, ) + if (share.type == ShareDto.TYPE_PHOTO) { + db.photoListingDao.insertOrUpdate( + PhotoListingEntity( + userId = user.userId, + volumeId = volume.id, + shareId = share.id, + linkId = id, + captureTime = 0, + hash = null, + contentHash = null, + mainPhotoLinkId = null, + ) + ) + } + return FileId(ShareId(user.userId, share.id), id) } suspend fun FolderContext.file( link: LinkEntity, properties: LinkFilePropertiesEntity, - block: suspend FileContext.() -> Unit = {} + block: suspend FileContext.() -> Unit = {}, ) { db.driveLinkDao.insertOrUpdate(link) db.driveLinkDao.insertOrUpdate(properties) @@ -144,7 +172,7 @@ private fun ShareContext.NullableLinkEntity( private fun FolderContext.NullableLinkEntity( id: String, parentId: String?, - type: Long + type: Long, ) = NullableLinkEntity( userId = user.userId, shareId = share.id, @@ -159,7 +187,8 @@ private fun NullableLinkEntity( shareId: String, id: String, parentId: String?, - type: Long + type: Long, + state: Long = LinkDto.STATE_ACTIVE, ) = LinkEntity( id = id, shareId = shareId, @@ -169,7 +198,7 @@ private fun NullableLinkEntity( name = id, nameSignatureEmail = "", hash = "", - state = type, + state = state, expirationTime = null, size = type, mimeType = "", @@ -186,6 +215,6 @@ private fun NullableLinkEntity( numberOfAccesses = type, shareUrlExpirationTime = null, xAttr = null, - shareUrlShareId = null, + sharingDetailsShareId = null, shareUrlId = null, ) diff --git a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/MyFiles.kt b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/MyFiles.kt index a3e9e21d..373713c6 100644 --- a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/MyFiles.kt +++ b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/MyFiles.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -20,24 +20,34 @@ package me.proton.core.drive.db.test import me.proton.android.drive.db.DriveDatabase import me.proton.core.drive.link.domain.entity.FolderId -import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.share.data.api.ShareDto -suspend fun DriveDatabaseRule.myDrive(block: suspend FolderContext.() -> Unit): FolderId { - return db.myDrive(block) -} +suspend fun DriveDatabaseRule.myFiles( + block: suspend FolderContext.() -> Unit, +): FolderId = db.myFiles(block) -suspend fun DriveDatabase.myDrive(block: suspend FolderContext.() -> Unit): FolderId { - user { - withKey() - volume { - mainShare(block) - } +val mainRootId = FolderId(mainShareId, "root-id") + +suspend fun DriveDatabase.myFiles( + block: suspend FolderContext.() -> Unit, +): FolderId = user { + withKey() + volume { + mainShare(block) } - return FolderId(ShareId(userId, shareId), "root-id") } -suspend fun VolumeContext.mainShare(block: suspend FolderContext.() -> Unit) { - share { +suspend fun VolumeContext.mainShare(block: suspend FolderContext.() -> Unit): FolderId { + share( + shareEntity = NullableShareEntity( + id = mainShareId.id, + userId = user.userId, + volumeId = volumeId.id, + linkId = mainRootId.id, + type = ShareDto.TYPE_MAIN, + ) + ) { folder(id = share.linkId, block = block) } + return mainRootId } diff --git a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Photo.kt b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Photo.kt index fabdd437..d66e7bdc 100644 --- a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Photo.kt +++ b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Photo.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -28,27 +28,25 @@ suspend fun DriveDatabaseRule.photo(block: suspend FolderContext.() -> Unit): Fo } val photoShareId = ShareId(userId, "photo-share-id") -val photoRootId = FolderId(photoShareId, "photo-root-id") -suspend fun DriveDatabase.photo(block: suspend FolderContext.() -> Unit): FolderId { - user { - volume { - photoShare(block) - } - } - return photoRootId -} - -suspend fun VolumeContext.photoShare(block: suspend FolderContext.() -> Unit) { - share( - shareEntity = NullableShareEntity( - id = photoShareId.id, - userId = user.userId, - volumeId = volumeId, - linkId = photoRootId.id, - type = ShareDto.TYPE_PHOTO, - ) - ) { - folder(id = share.linkId, block = block) +suspend fun DriveDatabase.photo( + block: suspend FolderContext.() -> Unit, +): FolderId = user { + volume { + photoShare(block) } } + +suspend fun VolumeContext.photoShare( + block: suspend FolderContext.() -> Unit, +) : FolderId = share( + shareEntity = NullableShareEntity( + id = photoShareId.id, + userId = user.userId, + volumeId = volumeId.id, + linkId = "photo-root-id", + type = ShareDto.TYPE_PHOTO, + ) +) { + folder(id = share.linkId, block = block) +} diff --git a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Share.kt b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Share.kt index ebc64981..771c4dad 100644 --- a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Share.kt +++ b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Share.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -15,36 +15,37 @@ * You should have received a copy of the GNU General Public License * along with Proton Core. If not, see . */ - +@file:Suppress("MatchingDeclarationName") package me.proton.core.drive.db.test import me.proton.android.drive.db.DriveDatabase import me.proton.core.domain.entity.UserId import me.proton.core.drive.share.data.api.ShareDto import me.proton.core.drive.share.data.db.ShareEntity +import me.proton.core.drive.volume.data.db.VolumeEntity import me.proton.core.user.data.entity.UserEntity +import me.proton.core.user.domain.entity.AddressId data class ShareContext( val db: DriveDatabase, val user: UserEntity, + val volume: VolumeEntity, val share: ShareEntity, ) : BaseContext() -const val shareId = "share-id" - -suspend fun VolumeContext.share( +suspend fun VolumeContext.share( shareEntity: ShareEntity = NullableShareEntity( - id = shareId, + id = volume.shareId, userId = user.userId, volumeId = volume.id, - linkId = "root-id", + linkId = "main-root-id", type = ShareDto.TYPE_MAIN, ), - block: suspend ShareContext.() -> Unit -) { + block: suspend ShareContext.() -> T, +): T { db.shareDao.insertOrUpdate(shareEntity) - ShareContext(db, user, shareEntity).block() + return ShareContext(db, user, volume, shareEntity).block() } @Suppress("FunctionName") @@ -54,18 +55,22 @@ fun NullableShareEntity( volumeId: String, linkId: String, type: Long = ShareDto.TYPE_STANDARD, + addressId: AddressId? = AddressId("address-id-$id"), + key : String = "key-$id", + passphrase : String = "passphrase-$id", + passphraseSignature : String = "passphrase-signature-$id", ): ShareEntity { return ShareEntity( id = id, userId = userId, volumeId = volumeId, - addressId = null, - flags = 0, + addressId = addressId, + flags = if (type == ShareDto.TYPE_MAIN) 1 else 0, linkId = linkId, isLocked = false, - key = "", - passphrase = "", - passphraseSignature = "", + key = key, + passphrase = passphrase, + passphraseSignature = passphraseSignature, creationTime = null, type = type, ) diff --git a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/User.kt b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/User.kt index 3739bc74..5bc72ab5 100644 --- a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/User.kt +++ b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/User.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with Proton Core. If not, see . */ - +@file:Suppress("MatchingDeclarationName") package me.proton.core.drive.db.test import me.proton.android.drive.db.DriveDatabase @@ -25,16 +25,17 @@ import me.proton.core.crypto.common.keystore.EncryptedByteArray import me.proton.core.domain.entity.UserId import me.proton.core.user.data.entity.UserEntity + data class UserContext( val db: DriveDatabase, val user: UserEntity, val account: AccountEntity, ) : BaseContext() -suspend fun DriveDatabase.user( +suspend fun DriveDatabase.user( user: UserEntity = NullableUserEntity(), - block: suspend UserContext.() -> Unit, -) { + block: suspend UserContext.() -> T, +): T { val account = AccountEntity( userId = user.userId, username = user.userId.id, @@ -47,7 +48,7 @@ suspend fun DriveDatabase.user( account ) userDao().insertOrUpdate(user) - UserContext(this, user, account).block() + return UserContext(this, user, account).block() } val userId = UserId("user-id") @@ -56,12 +57,14 @@ val userId = UserId("user-id") fun NullableUserEntity( userId: UserId = me.proton.core.drive.db.test.userId, maxSpace: Long = 0, + subscribed: Int = 0, ) = UserEntity( userId = userId, email = null, name = null, displayName = null, currency = "EUR", + type = 0, credit = 0, createdAtUtc = 0, usedSpace = 0, @@ -69,7 +72,7 @@ fun NullableUserEntity( maxUpload = 0, role = null, isPrivate = false, - subscribed = 0, + subscribed = subscribed, services = 0, delinquent = null, passphrase = EncryptedByteArray("user-passphrase".toByteArray()), diff --git a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Volume.kt b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Volume.kt index 228051df..22fef928 100644 --- a/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Volume.kt +++ b/drive/db-test/src/main/kotlin/me/proton/core/drive/db/test/Volume.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -16,10 +16,13 @@ * along with Proton Core. If not, see . */ +@file:Suppress("MatchingDeclarationName") package me.proton.core.drive.db.test import me.proton.android.drive.db.DriveDatabase +import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.volume.data.db.VolumeEntity +import me.proton.core.drive.volume.domain.entity.VolumeId import me.proton.core.user.data.entity.UserEntity data class VolumeContext( @@ -28,25 +31,33 @@ data class VolumeContext( val volume: VolumeEntity, ) : BaseContext() -const val volumeId = "volume-id" +val volumeId = VolumeId("volume-id") +val mainShareId = ShareId(userId, "share-id") suspend fun UserContext.volume( volume: VolumeEntity = NullableVolumeEntity( - id = volumeId, + id = volumeId.id, ), - block: suspend VolumeContext.() -> Unit = {}, -) { +) = volume(volume) {} + +suspend fun UserContext.volume( + volume: VolumeEntity = NullableVolumeEntity( + id = volumeId.id, + ), + block: suspend VolumeContext.() -> T, +): T { db.volumeDao.insertOrUpdate(volume) - VolumeContext(db, user, volume).block() + return VolumeContext(db, user, volume).block() } @Suppress("FunctionName") -fun NullableVolumeEntity(id: String = volumeId, state: Long = 1, creationTime: Long = 0) = VolumeEntity( - id = id, - userId = userId, - shareId = shareId, - creationTime = creationTime, - maxSpace = 0, - usedSpace = 0, - state = state, -) +fun NullableVolumeEntity(id: String = volumeId.id, state: Long = 1, creationTime: Long = 0) = + VolumeEntity( + id = id, + userId = userId, + shareId = mainShareId.id, + creationTime = creationTime, + maxSpace = 0, + usedSpace = 0, + state = state, + ) diff --git a/drive/db/build.gradle.kts b/drive/db/build.gradle.kts index 871dd1d5..e48ae276 100644 --- a/drive/db/build.gradle.kts +++ b/drive/db/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -32,6 +32,7 @@ driveModule( ) { api(libs.core.account.data) api(libs.core.challenge.data) + api(libs.core.contact.data) api(libs.core.crypto.android) api(libs.core.eventManager.data) { exclude("me.proton.core", "presentation") diff --git a/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/47.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/47.json new file mode 100644 index 00000000..d7b62a1c --- /dev/null +++ b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/47.json @@ -0,0 +1,6651 @@ +{ + "formatVersion": 1, + "database": { + "version": 47, + "identityHash": "b7112a4e7bb69f09d98cb5e33e226c67", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "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 NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "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, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `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": "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": "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, `recoverySecret` 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": "recoverySecret", + "columnName": "recoverySecret", + "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": "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": "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, `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, 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": "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 + } + ], + "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": "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, 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 + } + ], + "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": "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": "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": "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": "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": "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": "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, 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 + } + ], + "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": "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": "VolumeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `max_space` INTEGER, `used_space` INTEGER NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "used_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_VolumeEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_VolumeEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_VolumeEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `address_id` TEXT, `flags` INTEGER NOT NULL, `link_id` TEXT NOT NULL, `locked` INTEGER NOT NULL, `key` TEXT NOT NULL, `passphrase` TEXT NOT NULL, `passphrase_signature` TEXT NOT NULL, `creation_time` INTEGER, `type` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "address_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphraseSignature", + "columnName": "passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_ShareEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_ShareEntity_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ShareEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareUrlEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `name` TEXT, `token` TEXT NOT NULL, `creator_email` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `expiration_time` INTEGER, `last_access_time` INTEGER, `max_accesses` INTEGER, `number_of_accesses` INTEGER NOT NULL, `url_password_salt` TEXT NOT NULL, `share_password_salt` TEXT NOT NULL, `srp_verifier` TEXT NOT NULL, `srp_modulus_id` TEXT NOT NULL, `password` TEXT NOT NULL, `share_passphrase_key_packet` TEXT NOT NULL, `public_url` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`user_id`, `volume_id`, `share_id`, `id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorEmail", + "columnName": "creator_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastAccessTime", + "columnName": "last_access_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAccesses", + "columnName": "max_accesses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urlPasswordSalt", + "columnName": "url_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePasswordSalt", + "columnName": "share_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpVerifier", + "columnName": "srp_verifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpModulusId", + "columnName": "srp_modulus_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedUrlPassword", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePassphraseKeyPacket", + "columnName": "share_passphrase_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicUrl", + "columnName": "public_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_ShareUrlEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareUrlEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareUrlEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_ShareUrlEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `parent_id` TEXT, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_signature_email` TEXT, `hash` TEXT NOT NULL, `state` INTEGER NOT NULL, `expiration_time` INTEGER, `size` INTEGER NOT NULL, `mime_type` TEXT NOT NULL, `attributes` INTEGER NOT NULL, `permissions` INTEGER NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `signature_address` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `trashed_time` INTEGER, `is_shared` INTEGER NOT NULL, `number_of_accesses` INTEGER NOT NULL, `share_url_expiration_time` INTEGER, `x_attr` TEXT, `share_url_share_id` TEXT DEFAULT NULL, `share_url_id` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameSignatureEmail", + "columnName": "name_signature_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashedTime", + "columnName": "trashed_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shared", + "columnName": "is_shared", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareUrlExpirationTime", + "columnName": "share_url_expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "xAttr", + "columnName": "x_attr", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrlShareId", + "columnName": "share_url_share_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shareUrlId", + "columnName": "share_url_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_LinkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_LinkEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_LinkEntity_user_id_id", + "unique": false, + "columnNames": [ + "user_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_id` ON `${TABLE_NAME}` (`user_id`, `id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFilePropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`file_user_id` TEXT NOT NULL, `file_share_id` TEXT NOT NULL, `file_link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT, `file_signature_address` TEXT, `capture_time` INTEGER DEFAULT NULL, `content_hash` TEXT DEFAULT NULL, `main_photo_link_id` TEXT DEFAULT NULL, `thumbnail_id_default` TEXT, `thumbnail_id_photo` TEXT, PRIMARY KEY(`file_user_id`, `file_share_id`, `file_link_id`), FOREIGN KEY(`file_user_id`, `file_share_id`, `file_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "file_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "file_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "file_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeRevisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasThumbnail", + "columnName": "has_thumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activeRevisionSignatureAddress", + "columnName": "file_signature_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photoCaptureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "photoContentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mainPhotoLinkId", + "columnName": "main_photo_link_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "defaultThumbnailId", + "columnName": "thumbnail_id_default", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photoThumbnailId", + "columnName": "thumbnail_id_photo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ] + }, + "indices": [ + { + "name": "index_LinkFilePropertiesEntity_file_share_id", + "unique": false, + "columnNames": [ + "file_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_share_id` ON `${TABLE_NAME}` (`file_share_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_link_id", + "unique": false, + "columnNames": [ + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_link_id` ON `${TABLE_NAME}` (`file_link_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id", + "unique": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id` ON `${TABLE_NAME}` (`file_user_id`, `file_share_id`, `file_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFolderPropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`folder_user_id` TEXT NOT NULL, `folder_share_id` TEXT NOT NULL, `folder_link_id` TEXT NOT NULL, `node_hash_key` TEXT NOT NULL, PRIMARY KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`), FOREIGN KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "folder_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "folder_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "folder_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeHashKey", + "columnName": "node_hash_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ] + }, + "indices": [ + { + "name": "index_LinkFolderPropertiesEntity_folder_share_id", + "unique": false, + "columnNames": [ + "folder_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_share_id` ON `${TABLE_NAME}` (`folder_share_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_link_id", + "unique": false, + "columnNames": [ + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_link_id` ON `${TABLE_NAME}` (`folder_link_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id", + "unique": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id` ON `${TABLE_NAME}` (`folder_user_id`, `folder_share_id`, `folder_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkOfflineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_LinkOfflineEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkOfflineEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkOfflineEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkOfflineEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkDownloadStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `state` TEXT NOT NULL, `manifest_signature` TEXT DEFAULT NULL, `signature_address` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + }, + "indices": [ + { + "name": "index_LinkDownloadStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkDownloadStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DownloadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `index` INTEGER NOT NULL, `uri` TEXT NOT NULL, `encrypted_signature` TEXT, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`, `index`), FOREIGN KEY(`user_id`, `share_id`, `link_id`, `revision_id`) REFERENCES `LinkDownloadStateEntity`(`user_id`, `share_id`, `link_id`, `revision_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id", + "index" + ] + }, + "indices": [ + { + "name": "index_DownloadBlockEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DownloadBlockEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DownloadBlockEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DownloadBlockEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_DownloadBlockEntity_user_id_share_id_link_id_revision_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id_share_id_link_id_revision_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`, `revision_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkDownloadStateEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + } + ] + }, + { + "tableName": "LinkTrashStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_LinkTrashStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkTrashStateEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkTrashStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkTrashStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkTrashStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkTrashStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashWorkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `work_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "work_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_TrashWorkEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_TrashWorkEntity_work_id", + "unique": false, + "columnNames": [ + "work_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_work_id` ON `${TABLE_NAME}` (`work_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `content` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UiSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `layout_type` TEXT NOT NULL, `theme_style` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutType", + "columnName": "layout_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "themeStyle", + "columnName": "theme_style", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DriveLinkRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `previous_key` INTEGER, `next_key` INTEGER, PRIMARY KEY(`key`, `user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key", + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "SortingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `sorting_by` TEXT NOT NULL, `sorting_direction` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingBy", + "columnName": "sorting_by", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingDirection", + "columnName": "sorting_direction", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LinkUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT NOT NULL, `manifest_signature` TEXT NOT NULL, `state` TEXT NOT NULL, `size` INTEGER DEFAULT NULL, `last_modified` INTEGER, `uri` TEXT DEFAULT NULL, `should_delete_source_uri` INTEGER NOT NULL DEFAULT false, `media_resolution_width` INTEGER DEFAULT NULL, `media_resolution_height` INTEGER DEFAULT NULL, `digests` TEXT DEFAULT NULL, `network_type_provider_type` TEXT NOT NULL DEFAULT 'DEFAULT', `duration` INTEGER DEFAULT NULL, `latitude` REAL DEFAULT NULL, `longitude` REAL DEFAULT NULL, `creation_time` INTEGER DEFAULT NULL, `model` TEXT DEFAULT NULL, `orientation` INTEGER DEFAULT NULL, `subject_area` TEXT DEFAULT NULL, `should_announce_event` INTEGER NOT NULL DEFAULT true, `cache_option` TEXT NOT NULL DEFAULT 'ALL', `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `upload_creation_time` INTEGER, `should_broadcast_error_message` INTEGER NOT NULL DEFAULT true, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "mediaResolutionWidth", + "columnName": "media_resolution_width", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mediaResolutionHeight", + "columnName": "media_resolution_height", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "digests", + "columnName": "digests", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "networkTypeProviderType", + "columnName": "network_type_provider_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DEFAULT'" + }, + { + "fieldPath": "mediaDuration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraCreationDateTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraModel", + "columnName": "model", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraOrientation", + "columnName": "orientation", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraSubjectArea", + "columnName": "subject_area", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldAnnounceEvent", + "columnName": "should_announce_event", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "cacheOption", + "columnName": "cache_option", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ALL'" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "uploadCreationDateTime", + "columnName": "upload_creation_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shouldBroadcastErrorMessage", + "columnName": "should_broadcast_error_message", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LinkUploadEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkUploadEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkUploadEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkUploadEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkUploadEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkUploadEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_link_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `size` INTEGER NOT NULL, `encrypted_signature` TEXT NOT NULL, `hash` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, `raw_size` INTEGER NOT NULL DEFAULT 0, `type` TEXT NOT NULL DEFAULT 'FILE', `verifier_token` TEXT DEFAULT NULL, PRIMARY KEY(`upload_link_id`, `index`), FOREIGN KEY(`upload_link_id`) REFERENCES `LinkUploadEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uploadLinkId", + "columnName": "upload_link_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadToken", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawSize", + "columnName": "raw_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'FILE'" + }, + { + "fieldPath": "verifierToken", + "columnName": "verifier_token", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "upload_link_id", + "index" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkUploadEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_link_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UploadBulkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `should_delete_source_uri` INTEGER NOT NULL, `network_type_provider_type` TEXT NOT NULL DEFAULT 'DEFAULT', `should_announce_event` INTEGER NOT NULL DEFAULT true, `cache_option` TEXT NOT NULL DEFAULT 'ALL', `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `should_broadcast_error_message` INTEGER NOT NULL DEFAULT true, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkTypeProviderType", + "columnName": "network_type_provider_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DEFAULT'" + }, + { + "fieldPath": "shouldAnnounceEvent", + "columnName": "should_announce_event", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "cacheOption", + "columnName": "cache_option", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ALL'" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "shouldBroadcastErrorMessage", + "columnName": "should_broadcast_error_message", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UploadBulkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_UploadBulkEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_UploadBulkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_UploadBulkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBulkUriStringEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `upload_bulk_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `name` TEXT, `mime_type` TEXT, `size` INTEGER, `last_modified` INTEGER, FOREIGN KEY(`upload_bulk_id`) REFERENCES `UploadBulkEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadBulkId", + "columnName": "upload_bulk_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "key" + ] + }, + "indices": [ + { + "name": "index_UploadBulkUriStringEntity_upload_bulk_id", + "unique": false, + "columnNames": [ + "upload_bulk_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_upload_bulk_id` ON `${TABLE_NAME}` (`upload_bulk_id`)" + }, + { + "name": "index_UploadBulkUriStringEntity_uri", + "unique": false, + "columnNames": [ + "uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_uri` ON `${TABLE_NAME}` (`uri`)" + } + ], + "foreignKeys": [ + { + "table": "UploadBulkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_bulk_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FolderMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `last_fetch_children_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchChildrenTimestamp", + "columnName": "last_fetch_children_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `last_fetch_trash_timestamp` INTEGER, PRIMARY KEY(`user_id`, `volume_id`), FOREIGN KEY(`user_id`, `volume_id`) REFERENCES `VolumeEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTrashTimestamp", + "columnName": "last_fetch_trash_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "VolumeEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "volume_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupConfigurationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `network_type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "network_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupDuplicateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `hash` TEXT NOT NULL, `content_hash` TEXT, `link_id` TEXT, `state` TEXT, `revision_id` TEXT, `client_uid` TEXT, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "linkState", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientUid", + "columnName": "client_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupErrorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `error` TEXT NOT NULL, `retryable` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `error`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "retryable", + "columnName": "retryable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "error" + ] + }, + "indices": [ + { + "name": "index_BackupErrorEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupErrorEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `bucket_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `name` TEXT NOT NULL, `hash` TEXT NOT NULL, `size` INTEGER NOT NULL, `state` TEXT NOT NULL DEFAULT 'IDLE', `creation_time` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `attempts` INTEGER NOT NULL DEFAULT 0, `last_modified` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `uri`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`, `bucket_id`) REFERENCES `BackupFolderEntity`(`user_id`, `share_id`, `parent_id`, `bucket_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucketId", + "columnName": "bucket_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uriString", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "createTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadPriority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "uri" + ] + }, + "indices": [ + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_bucket_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_bucket_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `bucket_id`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_uri", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_uri` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `uri`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_state", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_state` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `state`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_bucket_id_state", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_bucket_id_state` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `bucket_id`, `state`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + }, + { + "table": "BackupFolderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ] + } + ] + }, + { + "tableName": "BackupFolderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `bucket_id` INTEGER NOT NULL, `update_time` INTEGER, `sync_time` INTEGER DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `bucket_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucketId", + "columnName": "bucket_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateTime", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncTime", + "columnName": "sync_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "InitialBackupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UploadStatsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `count` INTEGER NOT NULL, `size` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `capture_time` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumUploadCreationDateTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumFileCreationDateTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DismissedQuotaEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `level` TEXT NOT NULL, `max_space` INTEGER NOT NULL, `update_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `level`, `max_space`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestampS", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "level", + "max_space" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationChannelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `type`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "type" + ] + }, + "indices": [ + { + "name": "index_NotificationChannelEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationChannelEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_tag` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_tag`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationTag", + "columnName": "notification_tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id", + "notification_event_id" + ] + }, + "indices": [ + { + "name": "index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_tag`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "TaglessNotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_id", + "notification_event_id" + ] + }, + "indices": [ + { + "name": "index_TaglessNotificationEventEntity_user_id_channel_type_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TaglessNotificationEventEntity_user_id_channel_type_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "LinkSelectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `selection_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `selection_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectionId", + "columnName": "selection_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "selection_id" + ] + }, + "indices": [ + { + "name": "index_LinkSelectionEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_LinkSelectionEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkSelectionEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkSelectionEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkSelectionEntity_selection_id", + "unique": false, + "columnNames": [ + "selection_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_selection_id` ON `${TABLE_NAME}` (`selection_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "WorkerRunEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `worker_id` TEXT NOT NULL, `run_at` INTEGER NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "worker_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "runAt", + "columnName": "run_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_WorkerRunEntity_worker_id", + "unique": false, + "columnNames": [ + "worker_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_WorkerRunEntity_worker_id` ON `${TABLE_NAME}` (`worker_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PhotoListingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `id` TEXT NOT NULL, `capture_time` INTEGER NOT NULL, `hash` TEXT, `content_hash` TEXT, `main_photo_link_id` TEXT, PRIMARY KEY(`user_id`, `volume_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mainPhotoLinkId", + "columnName": "main_photo_link_id", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_PhotoListingEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_PhotoListingEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_PhotoListingEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_PhotoListingEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_PhotoListingEntity_user_id_volume_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_user_id_volume_id_share_id` ON `${TABLE_NAME}` (`user_id`, `volume_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "PhotoListingRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `capture_time` INTEGER NOT NULL, `previous_key` TEXT, `next_key` TEXT, PRIMARY KEY(`key`, `user_id`, `volume_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `volume_id`, `share_id`, `link_id`) REFERENCES `PhotoListingEntity`(`user_id`, `volume_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key", + "user_id", + "volume_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_PhotoListingRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "PhotoListingEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "volume_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DriveFeatureFlagRefreshEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `id` TEXT NOT NULL, `last_fetch_timestamp` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTimestamp", + "columnName": "last_fetch_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_DriveFeatureFlagRefreshEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveFeatureFlagRefreshEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MediaStoreVersionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `media_store_volume_name` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`user_id`, `media_store_volume_name`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeName", + "columnName": "media_store_volume_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "media_store_volume_name" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `id` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `sync_state` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER, `last_synced` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncState", + "columnName": "sync_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSynced", + "columnName": "last_synced", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_DeviceEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DeviceEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_DeviceEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DeviceEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DeviceEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_DeviceEntity_user_id_volume_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_volume_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `volume_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DeviceEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_DeviceEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b7112a4e7bb69f09d98cb5e33e226c67')" + ] + } +} \ No newline at end of file diff --git a/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/48.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/48.json new file mode 100644 index 00000000..7dd21663 --- /dev/null +++ b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/48.json @@ -0,0 +1,6657 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "1dc03620eb6b3c405ea9c6789643e805", + "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 NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "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, `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": "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, `recoverySecret` 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": "recoverySecret", + "columnName": "recoverySecret", + "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": "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": "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, `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, 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": "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 + } + ], + "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": "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, 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 + } + ], + "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": "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": "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": "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": "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": "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": "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, 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 + } + ], + "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": "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": "VolumeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `max_space` INTEGER, `used_space` INTEGER NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "used_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_VolumeEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_VolumeEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_VolumeEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `address_id` TEXT, `flags` INTEGER NOT NULL, `link_id` TEXT NOT NULL, `locked` INTEGER NOT NULL, `key` TEXT NOT NULL, `passphrase` TEXT NOT NULL, `passphrase_signature` TEXT NOT NULL, `creation_time` INTEGER, `type` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "address_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphraseSignature", + "columnName": "passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_ShareEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_ShareEntity_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ShareEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareUrlEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `name` TEXT, `token` TEXT NOT NULL, `creator_email` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `expiration_time` INTEGER, `last_access_time` INTEGER, `max_accesses` INTEGER, `number_of_accesses` INTEGER NOT NULL, `url_password_salt` TEXT NOT NULL, `share_password_salt` TEXT NOT NULL, `srp_verifier` TEXT NOT NULL, `srp_modulus_id` TEXT NOT NULL, `password` TEXT NOT NULL, `share_passphrase_key_packet` TEXT NOT NULL, `public_url` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`user_id`, `volume_id`, `share_id`, `id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorEmail", + "columnName": "creator_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastAccessTime", + "columnName": "last_access_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAccesses", + "columnName": "max_accesses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urlPasswordSalt", + "columnName": "url_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePasswordSalt", + "columnName": "share_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpVerifier", + "columnName": "srp_verifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpModulusId", + "columnName": "srp_modulus_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedUrlPassword", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePassphraseKeyPacket", + "columnName": "share_passphrase_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicUrl", + "columnName": "public_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_ShareUrlEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareUrlEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareUrlEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_ShareUrlEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `parent_id` TEXT, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_signature_email` TEXT, `hash` TEXT NOT NULL, `state` INTEGER NOT NULL, `expiration_time` INTEGER, `size` INTEGER NOT NULL, `mime_type` TEXT NOT NULL, `attributes` INTEGER NOT NULL, `permissions` INTEGER NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `signature_address` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `trashed_time` INTEGER, `is_shared` INTEGER NOT NULL, `number_of_accesses` INTEGER NOT NULL, `share_url_expiration_time` INTEGER, `x_attr` TEXT, `share_url_share_id` TEXT DEFAULT NULL, `share_url_id` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameSignatureEmail", + "columnName": "name_signature_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashedTime", + "columnName": "trashed_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shared", + "columnName": "is_shared", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareUrlExpirationTime", + "columnName": "share_url_expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "xAttr", + "columnName": "x_attr", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingDetailsShareId", + "columnName": "share_url_share_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shareUrlId", + "columnName": "share_url_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_LinkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_LinkEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_LinkEntity_user_id_id", + "unique": false, + "columnNames": [ + "user_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_id` ON `${TABLE_NAME}` (`user_id`, `id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFilePropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`file_user_id` TEXT NOT NULL, `file_share_id` TEXT NOT NULL, `file_link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT, `file_signature_address` TEXT, `capture_time` INTEGER DEFAULT NULL, `content_hash` TEXT DEFAULT NULL, `main_photo_link_id` TEXT DEFAULT NULL, `thumbnail_id_default` TEXT, `thumbnail_id_photo` TEXT, PRIMARY KEY(`file_user_id`, `file_share_id`, `file_link_id`), FOREIGN KEY(`file_user_id`, `file_share_id`, `file_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "file_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "file_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "file_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeRevisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasThumbnail", + "columnName": "has_thumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activeRevisionSignatureAddress", + "columnName": "file_signature_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photoCaptureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "photoContentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mainPhotoLinkId", + "columnName": "main_photo_link_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "defaultThumbnailId", + "columnName": "thumbnail_id_default", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photoThumbnailId", + "columnName": "thumbnail_id_photo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ] + }, + "indices": [ + { + "name": "index_LinkFilePropertiesEntity_file_share_id", + "unique": false, + "columnNames": [ + "file_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_share_id` ON `${TABLE_NAME}` (`file_share_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_link_id", + "unique": false, + "columnNames": [ + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_link_id` ON `${TABLE_NAME}` (`file_link_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id", + "unique": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id` ON `${TABLE_NAME}` (`file_user_id`, `file_share_id`, `file_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFolderPropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`folder_user_id` TEXT NOT NULL, `folder_share_id` TEXT NOT NULL, `folder_link_id` TEXT NOT NULL, `node_hash_key` TEXT NOT NULL, PRIMARY KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`), FOREIGN KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "folder_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "folder_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "folder_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeHashKey", + "columnName": "node_hash_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ] + }, + "indices": [ + { + "name": "index_LinkFolderPropertiesEntity_folder_share_id", + "unique": false, + "columnNames": [ + "folder_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_share_id` ON `${TABLE_NAME}` (`folder_share_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_link_id", + "unique": false, + "columnNames": [ + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_link_id` ON `${TABLE_NAME}` (`folder_link_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id", + "unique": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id` ON `${TABLE_NAME}` (`folder_user_id`, `folder_share_id`, `folder_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkOfflineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_LinkOfflineEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkOfflineEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkOfflineEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkOfflineEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkDownloadStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `state` TEXT NOT NULL, `manifest_signature` TEXT DEFAULT NULL, `signature_address` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + }, + "indices": [ + { + "name": "index_LinkDownloadStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkDownloadStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DownloadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `index` INTEGER NOT NULL, `uri` TEXT NOT NULL, `encrypted_signature` TEXT, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`, `index`), FOREIGN KEY(`user_id`, `share_id`, `link_id`, `revision_id`) REFERENCES `LinkDownloadStateEntity`(`user_id`, `share_id`, `link_id`, `revision_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id", + "index" + ] + }, + "indices": [ + { + "name": "index_DownloadBlockEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DownloadBlockEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DownloadBlockEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DownloadBlockEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_DownloadBlockEntity_user_id_share_id_link_id_revision_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id_share_id_link_id_revision_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`, `revision_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkDownloadStateEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + } + ] + }, + { + "tableName": "LinkTrashStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_LinkTrashStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkTrashStateEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkTrashStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkTrashStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkTrashStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkTrashStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashWorkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `work_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "work_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_TrashWorkEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_TrashWorkEntity_work_id", + "unique": false, + "columnNames": [ + "work_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_work_id` ON `${TABLE_NAME}` (`work_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `content` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UiSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `layout_type` TEXT NOT NULL, `theme_style` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutType", + "columnName": "layout_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "themeStyle", + "columnName": "theme_style", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DriveLinkRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `previous_key` INTEGER, `next_key` INTEGER, PRIMARY KEY(`key`, `user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key", + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "SortingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `sorting_by` TEXT NOT NULL, `sorting_direction` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingBy", + "columnName": "sorting_by", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingDirection", + "columnName": "sorting_direction", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LinkUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT NOT NULL, `manifest_signature` TEXT NOT NULL, `state` TEXT NOT NULL, `size` INTEGER DEFAULT NULL, `last_modified` INTEGER, `uri` TEXT DEFAULT NULL, `should_delete_source_uri` INTEGER NOT NULL DEFAULT false, `media_resolution_width` INTEGER DEFAULT NULL, `media_resolution_height` INTEGER DEFAULT NULL, `digests` TEXT DEFAULT NULL, `network_type_provider_type` TEXT NOT NULL DEFAULT 'DEFAULT', `duration` INTEGER DEFAULT NULL, `latitude` REAL DEFAULT NULL, `longitude` REAL DEFAULT NULL, `creation_time` INTEGER DEFAULT NULL, `model` TEXT DEFAULT NULL, `orientation` INTEGER DEFAULT NULL, `subject_area` TEXT DEFAULT NULL, `should_announce_event` INTEGER NOT NULL DEFAULT true, `cache_option` TEXT NOT NULL DEFAULT 'ALL', `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `upload_creation_time` INTEGER, `should_broadcast_error_message` INTEGER NOT NULL DEFAULT true, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "mediaResolutionWidth", + "columnName": "media_resolution_width", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mediaResolutionHeight", + "columnName": "media_resolution_height", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "digests", + "columnName": "digests", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "networkTypeProviderType", + "columnName": "network_type_provider_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DEFAULT'" + }, + { + "fieldPath": "mediaDuration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraCreationDateTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraModel", + "columnName": "model", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraOrientation", + "columnName": "orientation", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraSubjectArea", + "columnName": "subject_area", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldAnnounceEvent", + "columnName": "should_announce_event", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "cacheOption", + "columnName": "cache_option", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ALL'" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "uploadCreationDateTime", + "columnName": "upload_creation_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shouldBroadcastErrorMessage", + "columnName": "should_broadcast_error_message", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LinkUploadEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkUploadEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkUploadEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkUploadEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkUploadEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkUploadEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_link_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `size` INTEGER NOT NULL, `encrypted_signature` TEXT NOT NULL, `hash` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, `raw_size` INTEGER NOT NULL DEFAULT 0, `type` TEXT NOT NULL DEFAULT 'FILE', `verifier_token` TEXT DEFAULT NULL, PRIMARY KEY(`upload_link_id`, `index`), FOREIGN KEY(`upload_link_id`) REFERENCES `LinkUploadEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uploadLinkId", + "columnName": "upload_link_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadToken", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawSize", + "columnName": "raw_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'FILE'" + }, + { + "fieldPath": "verifierToken", + "columnName": "verifier_token", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "upload_link_id", + "index" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkUploadEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_link_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UploadBulkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `should_delete_source_uri` INTEGER NOT NULL, `network_type_provider_type` TEXT NOT NULL DEFAULT 'DEFAULT', `should_announce_event` INTEGER NOT NULL DEFAULT true, `cache_option` TEXT NOT NULL DEFAULT 'ALL', `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `should_broadcast_error_message` INTEGER NOT NULL DEFAULT true, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkTypeProviderType", + "columnName": "network_type_provider_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DEFAULT'" + }, + { + "fieldPath": "shouldAnnounceEvent", + "columnName": "should_announce_event", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "cacheOption", + "columnName": "cache_option", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ALL'" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "shouldBroadcastErrorMessage", + "columnName": "should_broadcast_error_message", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UploadBulkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_UploadBulkEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_UploadBulkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_UploadBulkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBulkUriStringEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `upload_bulk_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `name` TEXT, `mime_type` TEXT, `size` INTEGER, `last_modified` INTEGER, FOREIGN KEY(`upload_bulk_id`) REFERENCES `UploadBulkEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadBulkId", + "columnName": "upload_bulk_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "key" + ] + }, + "indices": [ + { + "name": "index_UploadBulkUriStringEntity_upload_bulk_id", + "unique": false, + "columnNames": [ + "upload_bulk_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_upload_bulk_id` ON `${TABLE_NAME}` (`upload_bulk_id`)" + }, + { + "name": "index_UploadBulkUriStringEntity_uri", + "unique": false, + "columnNames": [ + "uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_uri` ON `${TABLE_NAME}` (`uri`)" + } + ], + "foreignKeys": [ + { + "table": "UploadBulkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_bulk_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FolderMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `last_fetch_children_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchChildrenTimestamp", + "columnName": "last_fetch_children_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `last_fetch_trash_timestamp` INTEGER, PRIMARY KEY(`user_id`, `volume_id`), FOREIGN KEY(`user_id`, `volume_id`) REFERENCES `VolumeEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTrashTimestamp", + "columnName": "last_fetch_trash_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "VolumeEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "volume_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupConfigurationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `network_type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "network_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupDuplicateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `hash` TEXT NOT NULL, `content_hash` TEXT, `link_id` TEXT, `state` TEXT, `revision_id` TEXT, `client_uid` TEXT, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "linkState", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientUid", + "columnName": "client_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupErrorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `error` TEXT NOT NULL, `retryable` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `error`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "retryable", + "columnName": "retryable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "error" + ] + }, + "indices": [ + { + "name": "index_BackupErrorEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupErrorEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `bucket_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `name` TEXT NOT NULL, `hash` TEXT NOT NULL, `size` INTEGER NOT NULL, `state` TEXT NOT NULL DEFAULT 'IDLE', `creation_time` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `attempts` INTEGER NOT NULL DEFAULT 0, `last_modified` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `uri`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`, `bucket_id`) REFERENCES `BackupFolderEntity`(`user_id`, `share_id`, `parent_id`, `bucket_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucketId", + "columnName": "bucket_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uriString", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "createTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadPriority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "uri" + ] + }, + "indices": [ + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_bucket_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_bucket_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `bucket_id`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_uri", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_uri` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `uri`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_state", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_state` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `state`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_bucket_id_state", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_bucket_id_state` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `bucket_id`, `state`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + }, + { + "table": "BackupFolderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ] + } + ] + }, + { + "tableName": "BackupFolderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `bucket_id` INTEGER NOT NULL, `update_time` INTEGER, `sync_time` INTEGER DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `bucket_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucketId", + "columnName": "bucket_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateTime", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncTime", + "columnName": "sync_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "InitialBackupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UploadStatsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `count` INTEGER NOT NULL, `size` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `capture_time` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumUploadCreationDateTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumFileCreationDateTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DismissedQuotaEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `level` TEXT NOT NULL, `max_space` INTEGER NOT NULL, `update_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `level`, `max_space`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestampS", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "level", + "max_space" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationChannelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `type`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "type" + ] + }, + "indices": [ + { + "name": "index_NotificationChannelEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationChannelEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_tag` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_tag`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationTag", + "columnName": "notification_tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id", + "notification_event_id" + ] + }, + "indices": [ + { + "name": "index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_tag`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "TaglessNotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_id", + "notification_event_id" + ] + }, + "indices": [ + { + "name": "index_TaglessNotificationEventEntity_user_id_channel_type_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TaglessNotificationEventEntity_user_id_channel_type_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "LinkSelectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `selection_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `selection_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectionId", + "columnName": "selection_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "selection_id" + ] + }, + "indices": [ + { + "name": "index_LinkSelectionEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_LinkSelectionEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkSelectionEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkSelectionEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkSelectionEntity_selection_id", + "unique": false, + "columnNames": [ + "selection_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_selection_id` ON `${TABLE_NAME}` (`selection_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "WorkerRunEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `worker_id` TEXT NOT NULL, `run_at` INTEGER NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "worker_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "runAt", + "columnName": "run_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_WorkerRunEntity_worker_id", + "unique": false, + "columnNames": [ + "worker_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_WorkerRunEntity_worker_id` ON `${TABLE_NAME}` (`worker_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PhotoListingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `id` TEXT NOT NULL, `capture_time` INTEGER NOT NULL, `hash` TEXT, `content_hash` TEXT, `main_photo_link_id` TEXT, PRIMARY KEY(`user_id`, `volume_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mainPhotoLinkId", + "columnName": "main_photo_link_id", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_PhotoListingEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_PhotoListingEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_PhotoListingEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_PhotoListingEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_PhotoListingEntity_user_id_volume_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_user_id_volume_id_share_id` ON `${TABLE_NAME}` (`user_id`, `volume_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "PhotoListingRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `capture_time` INTEGER NOT NULL, `previous_key` TEXT, `next_key` TEXT, PRIMARY KEY(`key`, `user_id`, `volume_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `volume_id`, `share_id`, `link_id`) REFERENCES `PhotoListingEntity`(`user_id`, `volume_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key", + "user_id", + "volume_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_PhotoListingRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "PhotoListingEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "volume_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DriveFeatureFlagRefreshEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `id` TEXT NOT NULL, `last_fetch_timestamp` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTimestamp", + "columnName": "last_fetch_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_DriveFeatureFlagRefreshEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveFeatureFlagRefreshEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MediaStoreVersionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `media_store_volume_name` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`user_id`, `media_store_volume_name`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeName", + "columnName": "media_store_volume_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "media_store_volume_name" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `id` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `sync_state` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER, `last_synced` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncState", + "columnName": "sync_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSynced", + "columnName": "last_synced", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_DeviceEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DeviceEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_DeviceEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DeviceEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DeviceEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_DeviceEntity_user_id_volume_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_volume_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `volume_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DeviceEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_DeviceEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1dc03620eb6b3c405ea9c6789643e805')" + ] + } +} \ No newline at end of file diff --git a/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/49.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/49.json new file mode 100644 index 00000000..9f264694 --- /dev/null +++ b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/49.json @@ -0,0 +1,6702 @@ +{ + "formatVersion": 1, + "database": { + "version": 49, + "identityHash": "2d17f20b7edeafe69e3e5763bdb01295", + "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 NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "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, `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": "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, `recoverySecret` 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": "recoverySecret", + "columnName": "recoverySecret", + "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": "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": "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, `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, 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": "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 + } + ], + "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": "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, 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 + } + ], + "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": "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": "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": "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": "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": "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": "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, 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 + } + ], + "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": "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": "VolumeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `max_space` INTEGER, `used_space` INTEGER NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "used_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_VolumeEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_VolumeEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_VolumeEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `address_id` TEXT, `flags` INTEGER NOT NULL, `link_id` TEXT NOT NULL, `locked` INTEGER NOT NULL, `key` TEXT NOT NULL, `passphrase` TEXT NOT NULL, `passphrase_signature` TEXT NOT NULL, `creation_time` INTEGER, `type` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "address_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphraseSignature", + "columnName": "passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_ShareEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_ShareEntity_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ShareEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareUrlEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `name` TEXT, `token` TEXT NOT NULL, `creator_email` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `expiration_time` INTEGER, `last_access_time` INTEGER, `max_accesses` INTEGER, `number_of_accesses` INTEGER NOT NULL, `url_password_salt` TEXT NOT NULL, `share_password_salt` TEXT NOT NULL, `srp_verifier` TEXT NOT NULL, `srp_modulus_id` TEXT NOT NULL, `password` TEXT NOT NULL, `share_passphrase_key_packet` TEXT NOT NULL, `public_url` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`user_id`, `volume_id`, `share_id`, `id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorEmail", + "columnName": "creator_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastAccessTime", + "columnName": "last_access_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAccesses", + "columnName": "max_accesses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urlPasswordSalt", + "columnName": "url_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePasswordSalt", + "columnName": "share_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpVerifier", + "columnName": "srp_verifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpModulusId", + "columnName": "srp_modulus_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedUrlPassword", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePassphraseKeyPacket", + "columnName": "share_passphrase_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicUrl", + "columnName": "public_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_ShareUrlEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareUrlEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareUrlEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_ShareUrlEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `parent_id` TEXT, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_signature_email` TEXT, `hash` TEXT NOT NULL, `state` INTEGER NOT NULL, `expiration_time` INTEGER, `size` INTEGER NOT NULL, `mime_type` TEXT NOT NULL, `attributes` INTEGER NOT NULL, `permissions` INTEGER NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `signature_address` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `trashed_time` INTEGER, `is_shared` INTEGER NOT NULL, `number_of_accesses` INTEGER NOT NULL, `share_url_expiration_time` INTEGER, `x_attr` TEXT, `share_url_share_id` TEXT DEFAULT NULL, `share_url_id` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameSignatureEmail", + "columnName": "name_signature_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashedTime", + "columnName": "trashed_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shared", + "columnName": "is_shared", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareUrlExpirationTime", + "columnName": "share_url_expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "xAttr", + "columnName": "x_attr", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingDetailsShareId", + "columnName": "share_url_share_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shareUrlId", + "columnName": "share_url_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_LinkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_LinkEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_LinkEntity_user_id_id", + "unique": false, + "columnNames": [ + "user_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_id` ON `${TABLE_NAME}` (`user_id`, `id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFilePropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`file_user_id` TEXT NOT NULL, `file_share_id` TEXT NOT NULL, `file_link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT, `file_signature_address` TEXT, `capture_time` INTEGER DEFAULT NULL, `content_hash` TEXT DEFAULT NULL, `main_photo_link_id` TEXT DEFAULT NULL, `thumbnail_id_default` TEXT, `thumbnail_id_photo` TEXT, PRIMARY KEY(`file_user_id`, `file_share_id`, `file_link_id`), FOREIGN KEY(`file_user_id`, `file_share_id`, `file_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "file_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "file_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "file_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeRevisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasThumbnail", + "columnName": "has_thumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activeRevisionSignatureAddress", + "columnName": "file_signature_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photoCaptureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "photoContentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mainPhotoLinkId", + "columnName": "main_photo_link_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "defaultThumbnailId", + "columnName": "thumbnail_id_default", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photoThumbnailId", + "columnName": "thumbnail_id_photo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ] + }, + "indices": [ + { + "name": "index_LinkFilePropertiesEntity_file_share_id", + "unique": false, + "columnNames": [ + "file_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_share_id` ON `${TABLE_NAME}` (`file_share_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_link_id", + "unique": false, + "columnNames": [ + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_link_id` ON `${TABLE_NAME}` (`file_link_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id", + "unique": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id` ON `${TABLE_NAME}` (`file_user_id`, `file_share_id`, `file_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFolderPropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`folder_user_id` TEXT NOT NULL, `folder_share_id` TEXT NOT NULL, `folder_link_id` TEXT NOT NULL, `node_hash_key` TEXT NOT NULL, PRIMARY KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`), FOREIGN KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "folder_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "folder_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "folder_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeHashKey", + "columnName": "node_hash_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ] + }, + "indices": [ + { + "name": "index_LinkFolderPropertiesEntity_folder_share_id", + "unique": false, + "columnNames": [ + "folder_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_share_id` ON `${TABLE_NAME}` (`folder_share_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_link_id", + "unique": false, + "columnNames": [ + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_link_id` ON `${TABLE_NAME}` (`folder_link_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id", + "unique": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id` ON `${TABLE_NAME}` (`folder_user_id`, `folder_share_id`, `folder_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkOfflineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_LinkOfflineEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkOfflineEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkOfflineEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkOfflineEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkDownloadStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `state` TEXT NOT NULL, `manifest_signature` TEXT DEFAULT NULL, `signature_address` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + }, + "indices": [ + { + "name": "index_LinkDownloadStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkDownloadStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DownloadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `index` INTEGER NOT NULL, `uri` TEXT NOT NULL, `encrypted_signature` TEXT, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`, `index`), FOREIGN KEY(`user_id`, `share_id`, `link_id`, `revision_id`) REFERENCES `LinkDownloadStateEntity`(`user_id`, `share_id`, `link_id`, `revision_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id", + "index" + ] + }, + "indices": [ + { + "name": "index_DownloadBlockEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DownloadBlockEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DownloadBlockEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DownloadBlockEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_DownloadBlockEntity_user_id_share_id_link_id_revision_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id_share_id_link_id_revision_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`, `revision_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkDownloadStateEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + } + ] + }, + { + "tableName": "LinkTrashStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_LinkTrashStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkTrashStateEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkTrashStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkTrashStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkTrashStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkTrashStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashWorkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `work_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "work_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_TrashWorkEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_TrashWorkEntity_work_id", + "unique": false, + "columnNames": [ + "work_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_work_id` ON `${TABLE_NAME}` (`work_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `content` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UiSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `layout_type` TEXT NOT NULL, `theme_style` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutType", + "columnName": "layout_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "themeStyle", + "columnName": "theme_style", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DriveLinkRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `previous_key` INTEGER, `next_key` INTEGER, PRIMARY KEY(`key`, `user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key", + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "SortingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `sorting_by` TEXT NOT NULL, `sorting_direction` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingBy", + "columnName": "sorting_by", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingDirection", + "columnName": "sorting_direction", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LinkUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT NOT NULL, `manifest_signature` TEXT NOT NULL, `state` TEXT NOT NULL, `size` INTEGER DEFAULT NULL, `last_modified` INTEGER, `uri` TEXT DEFAULT NULL, `should_delete_source_uri` INTEGER NOT NULL DEFAULT false, `media_resolution_width` INTEGER DEFAULT NULL, `media_resolution_height` INTEGER DEFAULT NULL, `digests` TEXT DEFAULT NULL, `network_type_provider_type` TEXT NOT NULL DEFAULT 'DEFAULT', `duration` INTEGER DEFAULT NULL, `latitude` REAL DEFAULT NULL, `longitude` REAL DEFAULT NULL, `creation_time` INTEGER DEFAULT NULL, `model` TEXT DEFAULT NULL, `orientation` INTEGER DEFAULT NULL, `subject_area` TEXT DEFAULT NULL, `should_announce_event` INTEGER NOT NULL DEFAULT true, `cache_option` TEXT NOT NULL DEFAULT 'ALL', `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `upload_creation_time` INTEGER, `should_broadcast_error_message` INTEGER NOT NULL DEFAULT true, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "mediaResolutionWidth", + "columnName": "media_resolution_width", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mediaResolutionHeight", + "columnName": "media_resolution_height", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "digests", + "columnName": "digests", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "networkTypeProviderType", + "columnName": "network_type_provider_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DEFAULT'" + }, + { + "fieldPath": "mediaDuration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraCreationDateTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraModel", + "columnName": "model", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraOrientation", + "columnName": "orientation", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraSubjectArea", + "columnName": "subject_area", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldAnnounceEvent", + "columnName": "should_announce_event", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "cacheOption", + "columnName": "cache_option", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ALL'" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "uploadCreationDateTime", + "columnName": "upload_creation_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shouldBroadcastErrorMessage", + "columnName": "should_broadcast_error_message", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LinkUploadEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkUploadEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkUploadEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkUploadEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkUploadEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkUploadEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_link_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `size` INTEGER NOT NULL, `encrypted_signature` TEXT NOT NULL, `hash` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, `raw_size` INTEGER NOT NULL DEFAULT 0, `type` TEXT NOT NULL DEFAULT 'FILE', `verifier_token` TEXT DEFAULT NULL, PRIMARY KEY(`upload_link_id`, `index`), FOREIGN KEY(`upload_link_id`) REFERENCES `LinkUploadEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uploadLinkId", + "columnName": "upload_link_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadToken", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawSize", + "columnName": "raw_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'FILE'" + }, + { + "fieldPath": "verifierToken", + "columnName": "verifier_token", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "upload_link_id", + "index" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkUploadEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_link_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UploadBulkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `should_delete_source_uri` INTEGER NOT NULL, `network_type_provider_type` TEXT NOT NULL DEFAULT 'DEFAULT', `should_announce_event` INTEGER NOT NULL DEFAULT true, `cache_option` TEXT NOT NULL DEFAULT 'ALL', `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `should_broadcast_error_message` INTEGER NOT NULL DEFAULT true, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkTypeProviderType", + "columnName": "network_type_provider_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DEFAULT'" + }, + { + "fieldPath": "shouldAnnounceEvent", + "columnName": "should_announce_event", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "cacheOption", + "columnName": "cache_option", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ALL'" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "shouldBroadcastErrorMessage", + "columnName": "should_broadcast_error_message", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UploadBulkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_UploadBulkEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_UploadBulkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_UploadBulkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBulkUriStringEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `upload_bulk_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `name` TEXT, `mime_type` TEXT, `size` INTEGER, `last_modified` INTEGER, FOREIGN KEY(`upload_bulk_id`) REFERENCES `UploadBulkEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadBulkId", + "columnName": "upload_bulk_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "key" + ] + }, + "indices": [ + { + "name": "index_UploadBulkUriStringEntity_upload_bulk_id", + "unique": false, + "columnNames": [ + "upload_bulk_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_upload_bulk_id` ON `${TABLE_NAME}` (`upload_bulk_id`)" + }, + { + "name": "index_UploadBulkUriStringEntity_uri", + "unique": false, + "columnNames": [ + "uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_uri` ON `${TABLE_NAME}` (`uri`)" + } + ], + "foreignKeys": [ + { + "table": "UploadBulkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_bulk_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FolderMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `last_fetch_children_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchChildrenTimestamp", + "columnName": "last_fetch_children_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `last_fetch_trash_timestamp` INTEGER, PRIMARY KEY(`user_id`, `volume_id`), FOREIGN KEY(`user_id`, `volume_id`) REFERENCES `VolumeEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTrashTimestamp", + "columnName": "last_fetch_trash_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "VolumeEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "volume_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupConfigurationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `network_type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "network_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupDuplicateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `hash` TEXT NOT NULL, `content_hash` TEXT, `link_id` TEXT, `state` TEXT, `revision_id` TEXT, `client_uid` TEXT, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "linkState", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientUid", + "columnName": "client_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupErrorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `error` TEXT NOT NULL, `retryable` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `error`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "retryable", + "columnName": "retryable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "error" + ] + }, + "indices": [ + { + "name": "index_BackupErrorEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupErrorEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `bucket_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `name` TEXT NOT NULL, `hash` TEXT NOT NULL, `size` INTEGER NOT NULL, `state` TEXT NOT NULL DEFAULT 'IDLE', `creation_time` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `attempts` INTEGER NOT NULL DEFAULT 0, `last_modified` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `uri`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`, `bucket_id`) REFERENCES `BackupFolderEntity`(`user_id`, `share_id`, `parent_id`, `bucket_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucketId", + "columnName": "bucket_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uriString", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "createTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadPriority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "uri" + ] + }, + "indices": [ + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_bucket_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_bucket_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `bucket_id`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_uri", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_uri` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `uri`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_state", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_state` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `state`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_bucket_id_state", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_bucket_id_state` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `bucket_id`, `state`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + }, + { + "table": "BackupFolderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ] + } + ] + }, + { + "tableName": "BackupFolderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `bucket_id` INTEGER NOT NULL, `update_time` INTEGER, `sync_time` INTEGER DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `bucket_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucketId", + "columnName": "bucket_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateTime", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncTime", + "columnName": "sync_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "InitialBackupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UploadStatsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `count` INTEGER NOT NULL, `size` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `capture_time` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumUploadCreationDateTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumFileCreationDateTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DismissedQuotaEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `level` TEXT NOT NULL, `max_space` INTEGER NOT NULL, `update_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `level`, `max_space`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestampS", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "level", + "max_space" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DismissedUserMessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `user_message` TEXT NOT NULL, `update_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `user_message`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userMessage", + "columnName": "user_message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestampS", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "user_message" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationChannelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `type`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "type" + ] + }, + "indices": [ + { + "name": "index_NotificationChannelEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationChannelEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_tag` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_tag`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationTag", + "columnName": "notification_tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id", + "notification_event_id" + ] + }, + "indices": [ + { + "name": "index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_tag`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "TaglessNotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_id", + "notification_event_id" + ] + }, + "indices": [ + { + "name": "index_TaglessNotificationEventEntity_user_id_channel_type_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TaglessNotificationEventEntity_user_id_channel_type_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "LinkSelectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `selection_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `selection_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectionId", + "columnName": "selection_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "selection_id" + ] + }, + "indices": [ + { + "name": "index_LinkSelectionEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_LinkSelectionEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkSelectionEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkSelectionEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkSelectionEntity_selection_id", + "unique": false, + "columnNames": [ + "selection_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_selection_id` ON `${TABLE_NAME}` (`selection_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "WorkerRunEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `worker_id` TEXT NOT NULL, `run_at` INTEGER NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "worker_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "runAt", + "columnName": "run_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_WorkerRunEntity_worker_id", + "unique": false, + "columnNames": [ + "worker_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_WorkerRunEntity_worker_id` ON `${TABLE_NAME}` (`worker_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PhotoListingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `id` TEXT NOT NULL, `capture_time` INTEGER NOT NULL, `hash` TEXT, `content_hash` TEXT, `main_photo_link_id` TEXT, PRIMARY KEY(`user_id`, `volume_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mainPhotoLinkId", + "columnName": "main_photo_link_id", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_PhotoListingEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_PhotoListingEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_PhotoListingEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_PhotoListingEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_PhotoListingEntity_user_id_volume_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_user_id_volume_id_share_id` ON `${TABLE_NAME}` (`user_id`, `volume_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "PhotoListingRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `capture_time` INTEGER NOT NULL, `previous_key` TEXT, `next_key` TEXT, PRIMARY KEY(`key`, `user_id`, `volume_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `volume_id`, `share_id`, `link_id`) REFERENCES `PhotoListingEntity`(`user_id`, `volume_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key", + "user_id", + "volume_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_PhotoListingRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "PhotoListingEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "volume_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DriveFeatureFlagRefreshEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `id` TEXT NOT NULL, `last_fetch_timestamp` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTimestamp", + "columnName": "last_fetch_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_DriveFeatureFlagRefreshEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveFeatureFlagRefreshEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MediaStoreVersionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `media_store_volume_name` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`user_id`, `media_store_volume_name`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeName", + "columnName": "media_store_volume_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "media_store_volume_name" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `id` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `sync_state` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER, `last_synced` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncState", + "columnName": "sync_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSynced", + "columnName": "last_synced", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_DeviceEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DeviceEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_DeviceEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DeviceEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DeviceEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_DeviceEntity_user_id_volume_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_volume_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `volume_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DeviceEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_DeviceEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2d17f20b7edeafe69e3e5763bdb01295')" + ] + } +} \ No newline at end of file diff --git a/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/50.json b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/50.json new file mode 100644 index 00000000..79f5fd4a --- /dev/null +++ b/drive/db/schemas/me.proton.android.drive.db.DriveDatabase/50.json @@ -0,0 +1,6766 @@ +{ + "formatVersion": 1, + "database": { + "version": 50, + "identityHash": "922d42ac0a4e8d131e0e85a3a81876b6", + "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 NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "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, `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": "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, `recoverySecret` 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": "recoverySecret", + "columnName": "recoverySecret", + "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": "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": "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, `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, 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": "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 + } + ], + "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": "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, 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 + } + ], + "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": "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": "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": "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": "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": "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": "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, 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 + } + ], + "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": "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": "VolumeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `max_space` INTEGER, `used_space` INTEGER NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "used_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_VolumeEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_VolumeEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_VolumeEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_VolumeEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `address_id` TEXT, `flags` INTEGER NOT NULL, `link_id` TEXT NOT NULL, `locked` INTEGER NOT NULL, `key` TEXT NOT NULL, `passphrase` TEXT NOT NULL, `passphrase_signature` TEXT NOT NULL, `creation_time` INTEGER, `type` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "address_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passphraseSignature", + "columnName": "passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_ShareEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_ShareEntity_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ShareEntity_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ShareUrlEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `flags` INTEGER NOT NULL, `name` TEXT, `token` TEXT NOT NULL, `creator_email` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `expiration_time` INTEGER, `last_access_time` INTEGER, `max_accesses` INTEGER, `number_of_accesses` INTEGER NOT NULL, `url_password_salt` TEXT NOT NULL, `share_password_salt` TEXT NOT NULL, `srp_verifier` TEXT NOT NULL, `srp_modulus_id` TEXT NOT NULL, `password` TEXT NOT NULL, `share_passphrase_key_packet` TEXT NOT NULL, `public_url` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`user_id`, `volume_id`, `share_id`, `id`), FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorEmail", + "columnName": "creator_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastAccessTime", + "columnName": "last_access_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAccesses", + "columnName": "max_accesses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urlPasswordSalt", + "columnName": "url_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePasswordSalt", + "columnName": "share_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpVerifier", + "columnName": "srp_verifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "srpModulusId", + "columnName": "srp_modulus_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedUrlPassword", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharePassphraseKeyPacket", + "columnName": "share_passphrase_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicUrl", + "columnName": "public_url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_ShareUrlEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_ShareUrlEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_ShareUrlEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_ShareUrlEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ShareUrlEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `parent_id` TEXT, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `name_signature_email` TEXT, `hash` TEXT NOT NULL, `state` INTEGER NOT NULL, `expiration_time` INTEGER, `size` INTEGER NOT NULL, `mime_type` TEXT NOT NULL, `attributes` INTEGER NOT NULL, `permissions` INTEGER NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `signature_address` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `trashed_time` INTEGER, `is_shared` INTEGER NOT NULL, `number_of_accesses` INTEGER NOT NULL, `share_url_expiration_time` INTEGER, `x_attr` TEXT, `share_url_share_id` TEXT DEFAULT NULL, `share_url_id` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameSignatureEmail", + "columnName": "name_signature_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashedTime", + "columnName": "trashed_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shared", + "columnName": "is_shared", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numberOfAccesses", + "columnName": "number_of_accesses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareUrlExpirationTime", + "columnName": "share_url_expiration_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "xAttr", + "columnName": "x_attr", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharingDetailsShareId", + "columnName": "share_url_share_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shareUrlId", + "columnName": "share_url_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_LinkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_LinkEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_LinkEntity_user_id_id", + "unique": false, + "columnNames": [ + "user_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_id` ON `${TABLE_NAME}` (`user_id`, `id`)" + }, + { + "name": "index_LinkEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFilePropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`file_user_id` TEXT NOT NULL, `file_share_id` TEXT NOT NULL, `file_link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `has_thumbnail` INTEGER NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT, `file_signature_address` TEXT, `capture_time` INTEGER DEFAULT NULL, `content_hash` TEXT DEFAULT NULL, `main_photo_link_id` TEXT DEFAULT NULL, `thumbnail_id_default` TEXT, `thumbnail_id_photo` TEXT, PRIMARY KEY(`file_user_id`, `file_share_id`, `file_link_id`), FOREIGN KEY(`file_user_id`, `file_share_id`, `file_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "file_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "file_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "file_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeRevisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasThumbnail", + "columnName": "has_thumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activeRevisionSignatureAddress", + "columnName": "file_signature_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photoCaptureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "photoContentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mainPhotoLinkId", + "columnName": "main_photo_link_id", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "defaultThumbnailId", + "columnName": "thumbnail_id_default", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "photoThumbnailId", + "columnName": "thumbnail_id_photo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ] + }, + "indices": [ + { + "name": "index_LinkFilePropertiesEntity_file_share_id", + "unique": false, + "columnNames": [ + "file_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_share_id` ON `${TABLE_NAME}` (`file_share_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_link_id", + "unique": false, + "columnNames": [ + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_link_id` ON `${TABLE_NAME}` (`file_link_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id", + "unique": false, + "columnNames": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFilePropertiesEntity_file_user_id_file_share_id_file_link_id` ON `${TABLE_NAME}` (`file_user_id`, `file_share_id`, `file_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "file_user_id", + "file_share_id", + "file_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkFolderPropertiesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`folder_user_id` TEXT NOT NULL, `folder_share_id` TEXT NOT NULL, `folder_link_id` TEXT NOT NULL, `node_hash_key` TEXT NOT NULL, PRIMARY KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`), FOREIGN KEY(`folder_user_id`, `folder_share_id`, `folder_link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "folder_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "folder_share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "folder_link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeHashKey", + "columnName": "node_hash_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ] + }, + "indices": [ + { + "name": "index_LinkFolderPropertiesEntity_folder_share_id", + "unique": false, + "columnNames": [ + "folder_share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_share_id` ON `${TABLE_NAME}` (`folder_share_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_link_id", + "unique": false, + "columnNames": [ + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_link_id` ON `${TABLE_NAME}` (`folder_link_id`)" + }, + { + "name": "index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id", + "unique": false, + "columnNames": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkFolderPropertiesEntity_folder_user_id_folder_share_id_folder_link_id` ON `${TABLE_NAME}` (`folder_user_id`, `folder_share_id`, `folder_link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder_user_id", + "folder_share_id", + "folder_link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkOfflineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_LinkOfflineEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkOfflineEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkOfflineEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkOfflineEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkOfflineEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LinkOfflineEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "LinkDownloadStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `state` TEXT NOT NULL, `manifest_signature` TEXT DEFAULT NULL, `signature_address` TEXT DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "signatureAddress", + "columnName": "signature_address", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + }, + "indices": [ + { + "name": "index_LinkDownloadStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkDownloadStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkDownloadStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkDownloadStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DownloadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `index` INTEGER NOT NULL, `uri` TEXT NOT NULL, `encrypted_signature` TEXT, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `revision_id`, `index`), FOREIGN KEY(`user_id`, `share_id`, `link_id`, `revision_id`) REFERENCES `LinkDownloadStateEntity`(`user_id`, `share_id`, `link_id`, `revision_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id", + "index" + ] + }, + "indices": [ + { + "name": "index_DownloadBlockEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DownloadBlockEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DownloadBlockEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DownloadBlockEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_DownloadBlockEntity_user_id_share_id_link_id_revision_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DownloadBlockEntity_user_id_share_id_link_id_revision_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`, `revision_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkDownloadStateEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "link_id", + "revision_id" + ] + } + ] + }, + { + "tableName": "LinkTrashStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_LinkTrashStateEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkTrashStateEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkTrashStateEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkTrashStateEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkTrashStateEntity_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_LinkTrashStateEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkTrashStateEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashWorkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `work_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "work_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_TrashWorkEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_TrashWorkEntity_work_id", + "unique": false, + "columnNames": [ + "work_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TrashWorkEntity_work_id` ON `${TABLE_NAME}` (`work_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `content` TEXT NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UiSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `layout_type` TEXT NOT NULL, `theme_style` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutType", + "columnName": "layout_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "themeStyle", + "columnName": "theme_style", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DriveLinkRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `previous_key` INTEGER, `next_key` INTEGER, PRIMARY KEY(`key`, `user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key", + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id", + "unique": true, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DriveLinkRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveLinkRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "SortingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `sorting_by` TEXT NOT NULL, `sorting_direction` TEXT NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingBy", + "columnName": "sorting_by", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortingDirection", + "columnName": "sorting_direction", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LinkUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `revision_id` TEXT NOT NULL, `name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `node_key` TEXT NOT NULL, `node_passphrase` TEXT NOT NULL, `node_passphrase_signature` TEXT NOT NULL, `content_key_packet` TEXT NOT NULL, `content_key_packet_signature` TEXT NOT NULL, `manifest_signature` TEXT NOT NULL, `state` TEXT NOT NULL, `size` INTEGER DEFAULT NULL, `last_modified` INTEGER, `uri` TEXT DEFAULT NULL, `should_delete_source_uri` INTEGER NOT NULL DEFAULT false, `media_resolution_width` INTEGER DEFAULT NULL, `media_resolution_height` INTEGER DEFAULT NULL, `digests` TEXT DEFAULT NULL, `network_type_provider_type` TEXT NOT NULL DEFAULT 'DEFAULT', `duration` INTEGER DEFAULT NULL, `latitude` REAL DEFAULT NULL, `longitude` REAL DEFAULT NULL, `creation_time` INTEGER DEFAULT NULL, `model` TEXT DEFAULT NULL, `orientation` INTEGER DEFAULT NULL, `subject_area` TEXT DEFAULT NULL, `should_announce_event` INTEGER NOT NULL DEFAULT true, `cache_option` TEXT NOT NULL DEFAULT 'ALL', `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `upload_creation_time` INTEGER, `should_broadcast_error_message` INTEGER NOT NULL DEFAULT true, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodeKey", + "columnName": "node_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphrase", + "columnName": "node_passphrase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nodePassphraseSignature", + "columnName": "node_passphrase_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacket", + "columnName": "content_key_packet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentKeyPacketSignature", + "columnName": "content_key_packet_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifestSignature", + "columnName": "manifest_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "mediaResolutionWidth", + "columnName": "media_resolution_width", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "mediaResolutionHeight", + "columnName": "media_resolution_height", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "digests", + "columnName": "digests", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "networkTypeProviderType", + "columnName": "network_type_provider_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DEFAULT'" + }, + { + "fieldPath": "mediaDuration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraCreationDateTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraModel", + "columnName": "model", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraOrientation", + "columnName": "orientation", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "cameraSubjectArea", + "columnName": "subject_area", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "shouldAnnounceEvent", + "columnName": "should_announce_event", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "cacheOption", + "columnName": "cache_option", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ALL'" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "uploadCreationDateTime", + "columnName": "upload_creation_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shouldBroadcastErrorMessage", + "columnName": "should_broadcast_error_message", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LinkUploadEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkUploadEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_LinkUploadEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkUploadEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkUploadEntity_revision_id", + "unique": false, + "columnNames": [ + "revision_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_revision_id` ON `${TABLE_NAME}` (`revision_id`)" + }, + { + "name": "index_LinkUploadEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkUploadEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBlockEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`upload_link_id` INTEGER NOT NULL, `index` INTEGER NOT NULL, `size` INTEGER NOT NULL, `encrypted_signature` TEXT NOT NULL, `hash` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, `raw_size` INTEGER NOT NULL DEFAULT 0, `type` TEXT NOT NULL DEFAULT 'FILE', `verifier_token` TEXT DEFAULT NULL, PRIMARY KEY(`upload_link_id`, `index`), FOREIGN KEY(`upload_link_id`) REFERENCES `LinkUploadEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uploadLinkId", + "columnName": "upload_link_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedSignature", + "columnName": "encrypted_signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadToken", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawSize", + "columnName": "raw_size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'FILE'" + }, + { + "fieldPath": "verifierToken", + "columnName": "verifier_token", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "upload_link_id", + "index" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkUploadEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_link_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UploadBulkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `should_delete_source_uri` INTEGER NOT NULL, `network_type_provider_type` TEXT NOT NULL DEFAULT 'DEFAULT', `should_announce_event` INTEGER NOT NULL DEFAULT true, `cache_option` TEXT NOT NULL DEFAULT 'ALL', `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `should_broadcast_error_message` INTEGER NOT NULL DEFAULT true, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shouldDeleteSourceUri", + "columnName": "should_delete_source_uri", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkTypeProviderType", + "columnName": "network_type_provider_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DEFAULT'" + }, + { + "fieldPath": "shouldAnnounceEvent", + "columnName": "should_announce_event", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "cacheOption", + "columnName": "cache_option", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ALL'" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "shouldBroadcastErrorMessage", + "columnName": "should_broadcast_error_message", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UploadBulkEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_UploadBulkEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_UploadBulkEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_UploadBulkEntity_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkEntity_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UploadBulkUriStringEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `upload_bulk_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `name` TEXT, `mime_type` TEXT, `size` INTEGER, `last_modified` INTEGER, FOREIGN KEY(`upload_bulk_id`) REFERENCES `UploadBulkEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadBulkId", + "columnName": "upload_bulk_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "key" + ] + }, + "indices": [ + { + "name": "index_UploadBulkUriStringEntity_upload_bulk_id", + "unique": false, + "columnNames": [ + "upload_bulk_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_upload_bulk_id` ON `${TABLE_NAME}` (`upload_bulk_id`)" + }, + { + "name": "index_UploadBulkUriStringEntity_uri", + "unique": false, + "columnNames": [ + "uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UploadBulkUriStringEntity_uri` ON `${TABLE_NAME}` (`uri`)" + } + ], + "foreignKeys": [ + { + "table": "UploadBulkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "upload_bulk_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FolderMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `last_fetch_children_timestamp` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchChildrenTimestamp", + "columnName": "last_fetch_children_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "TrashMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `last_fetch_trash_timestamp` INTEGER, PRIMARY KEY(`user_id`, `volume_id`), FOREIGN KEY(`user_id`, `volume_id`) REFERENCES `VolumeEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTrashTimestamp", + "columnName": "last_fetch_trash_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "VolumeEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "volume_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupConfigurationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `network_type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "network_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupDuplicateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `hash` TEXT NOT NULL, `content_hash` TEXT, `link_id` TEXT, `state` TEXT, `revision_id` TEXT, `client_uid` TEXT, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "linkState", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "revisionId", + "columnName": "revision_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientUid", + "columnName": "client_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupErrorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `error` TEXT NOT NULL, `retryable` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `error`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "retryable", + "columnName": "retryable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "error" + ] + }, + "indices": [ + { + "name": "index_BackupErrorEntity_user_id_share_id_parent_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupErrorEntity_user_id_share_id_parent_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "BackupFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `bucket_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `name` TEXT NOT NULL, `hash` TEXT NOT NULL, `size` INTEGER NOT NULL, `state` TEXT NOT NULL DEFAULT 'IDLE', `creation_time` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 9223372036854775807, `attempts` INTEGER NOT NULL DEFAULT 0, `last_modified` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `uri`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`, `bucket_id`) REFERENCES `BackupFolderEntity`(`user_id`, `share_id`, `parent_id`, `bucket_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucketId", + "columnName": "bucket_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uriString", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "createTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadPriority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "9223372036854775807" + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "uri" + ] + }, + "indices": [ + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_bucket_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_bucket_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `bucket_id`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_uri", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_uri` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `uri`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_state", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_state` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `state`)" + }, + { + "name": "index_BackupFileEntity_user_id_share_id_parent_id_bucket_id_state", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BackupFileEntity_user_id_share_id_parent_id_bucket_id_state` ON `${TABLE_NAME}` (`user_id`, `share_id`, `parent_id`, `bucket_id`, `state`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + }, + { + "table": "BackupFolderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ] + } + ] + }, + { + "tableName": "BackupFolderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `bucket_id` INTEGER NOT NULL, `update_time` INTEGER, `sync_time` INTEGER DEFAULT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`, `bucket_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `parent_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucketId", + "columnName": "bucket_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateTime", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncTime", + "columnName": "sync_time", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id", + "bucket_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "parent_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "InitialBackupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `parent_id` TEXT NOT NULL, `creation_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `parent_id`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "parent_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UploadStatsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `count` INTEGER NOT NULL, `size` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `capture_time` INTEGER, PRIMARY KEY(`user_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumUploadCreationDateTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumFileCreationDateTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DismissedQuotaEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `level` TEXT NOT NULL, `max_space` INTEGER NOT NULL, `update_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `level`, `max_space`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "max_space", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestampS", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "level", + "max_space" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DismissedUserMessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `user_message` TEXT NOT NULL, `update_time` INTEGER NOT NULL, PRIMARY KEY(`user_id`, `user_message`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userMessage", + "columnName": "user_message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestampS", + "columnName": "update_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "user_message" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationChannelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`user_id`, `type`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "type" + ] + }, + "indices": [ + { + "name": "index_NotificationChannelEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationChannelEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "NotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_tag` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_tag`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationTag", + "columnName": "notification_tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id", + "notification_event_id" + ] + }, + "indices": [ + { + "name": "index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_tag", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEventEntity_user_id_channel_type_notification_tag_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_tag`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "TaglessNotificationEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `channel_type` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, `notification_event_id` TEXT NOT NULL, `notification_event` TEXT NOT NULL, PRIMARY KEY(`user_id`, `channel_type`, `notification_id`, `notification_event_id`), FOREIGN KEY(`user_id`, `channel_type`) REFERENCES `NotificationChannelEntity`(`user_id`, `type`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channel_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationEventId", + "columnName": "notification_event_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationEvent", + "columnName": "notification_event", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_id", + "notification_event_id" + ] + }, + "indices": [ + { + "name": "index_TaglessNotificationEventEntity_user_id_channel_type_notification_id", + "unique": false, + "columnNames": [ + "user_id", + "channel_type", + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TaglessNotificationEventEntity_user_id_channel_type_notification_id` ON `${TABLE_NAME}` (`user_id`, `channel_type`, `notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "NotificationChannelEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "channel_type" + ], + "referencedColumns": [ + "user_id", + "type" + ] + } + ] + }, + { + "tableName": "LinkSelectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `selection_id` TEXT NOT NULL, PRIMARY KEY(`user_id`, `share_id`, `link_id`, `selection_id`), FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectionId", + "columnName": "selection_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "share_id", + "link_id", + "selection_id" + ] + }, + "indices": [ + { + "name": "index_LinkSelectionEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + }, + { + "name": "index_LinkSelectionEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_LinkSelectionEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_LinkSelectionEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_LinkSelectionEntity_selection_id", + "unique": false, + "columnNames": [ + "selection_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LinkSelectionEntity_selection_id` ON `${TABLE_NAME}` (`selection_id`)" + } + ], + "foreignKeys": [ + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "WorkerRunEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `worker_id` TEXT NOT NULL, `run_at` INTEGER NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "worker_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "runAt", + "columnName": "run_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_WorkerRunEntity_worker_id", + "unique": false, + "columnNames": [ + "worker_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_WorkerRunEntity_worker_id` ON `${TABLE_NAME}` (`worker_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PhotoListingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `id` TEXT NOT NULL, `capture_time` INTEGER NOT NULL, `hash` TEXT, `content_hash` TEXT, `main_photo_link_id` TEXT, PRIMARY KEY(`user_id`, `volume_id`, `share_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mainPhotoLinkId", + "columnName": "main_photo_link_id", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + }, + "indices": [ + { + "name": "index_PhotoListingEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_PhotoListingEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_PhotoListingEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_PhotoListingEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_PhotoListingEntity_user_id_volume_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingEntity_user_id_volume_id_share_id` ON `${TABLE_NAME}` (`user_id`, `volume_id`, `share_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + } + ] + }, + { + "tableName": "PhotoListingRemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `capture_time` INTEGER NOT NULL, `previous_key` TEXT, `next_key` TEXT, PRIMARY KEY(`key`, `user_id`, `volume_id`, `share_id`, `link_id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `volume_id`, `share_id`, `link_id`) REFERENCES `PhotoListingEntity`(`user_id`, `volume_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "captureTime", + "columnName": "capture_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prevKey", + "columnName": "previous_key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextKey", + "columnName": "next_key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key", + "user_id", + "volume_id", + "share_id", + "link_id" + ] + }, + "indices": [ + { + "name": "index_PhotoListingRemoteKeyEntity_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PhotoListingRemoteKeyEntity_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "PhotoListingEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "volume_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "volume_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "DriveFeatureFlagRefreshEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `id` TEXT NOT NULL, `last_fetch_timestamp` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTimestamp", + "columnName": "last_fetch_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_DriveFeatureFlagRefreshEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DriveFeatureFlagRefreshEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MediaStoreVersionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `media_store_volume_name` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`user_id`, `media_store_volume_name`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeName", + "columnName": "media_store_volume_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "media_store_volume_name" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `volume_id` TEXT NOT NULL, `share_id` TEXT NOT NULL, `link_id` TEXT NOT NULL, `id` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `sync_state` INTEGER NOT NULL, `creation_time` INTEGER NOT NULL, `last_modified` INTEGER, `last_synced` INTEGER, PRIMARY KEY(`user_id`, `id`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`) REFERENCES `ShareEntity`(`user_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`, `share_id`, `link_id`) REFERENCES `LinkEntity`(`user_id`, `share_id`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volumeId", + "columnName": "volume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareId", + "columnName": "share_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkId", + "columnName": "link_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncState", + "columnName": "sync_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creation_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSynced", + "columnName": "last_synced", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "id" + ] + }, + "indices": [ + { + "name": "index_DeviceEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_DeviceEntity_volume_id", + "unique": false, + "columnNames": [ + "volume_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_volume_id` ON `${TABLE_NAME}` (`volume_id`)" + }, + { + "name": "index_DeviceEntity_share_id", + "unique": false, + "columnNames": [ + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_share_id` ON `${TABLE_NAME}` (`share_id`)" + }, + { + "name": "index_DeviceEntity_link_id", + "unique": false, + "columnNames": [ + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_link_id` ON `${TABLE_NAME}` (`link_id`)" + }, + { + "name": "index_DeviceEntity_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_DeviceEntity_user_id_volume_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "volume_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_volume_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `volume_id`, `share_id`, `link_id`)" + }, + { + "name": "index_DeviceEntity_user_id_share_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_share_id` ON `${TABLE_NAME}` (`user_id`, `share_id`)" + }, + { + "name": "index_DeviceEntity_user_id_share_id_link_id", + "unique": false, + "columnNames": [ + "user_id", + "share_id", + "link_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceEntity_user_id_share_id_link_id` ON `${TABLE_NAME}` (`user_id`, `share_id`, `link_id`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ShareEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id" + ], + "referencedColumns": [ + "user_id", + "id" + ] + }, + { + "table": "LinkEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id", + "share_id", + "link_id" + ], + "referencedColumns": [ + "user_id", + "share_id", + "id" + ] + } + ] + }, + { + "tableName": "UrlLastFetchEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `url` TEXT NOT NULL, `last_fetch_timestamp` INTEGER, PRIMARY KEY(`user_id`, `url`), FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFetchTimestamp", + "columnName": "last_fetch_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "url" + ] + }, + "indices": [ + { + "name": "index_UrlLastFetchEntity_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UrlLastFetchEntity_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_UrlLastFetchEntity_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UrlLastFetchEntity_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "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, '922d42ac0a4e8d131e0e85a3a81876b6')" + ] + } +} \ No newline at end of file diff --git a/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabase.kt b/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabase.kt index e05b1234..4659580e 100644 --- a/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabase.kt +++ b/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabase.kt @@ -33,6 +33,12 @@ import me.proton.core.account.data.entity.SessionEntity import me.proton.core.challenge.data.db.ChallengeConverters import me.proton.core.challenge.data.db.ChallengeDatabase import me.proton.core.challenge.data.entity.ChallengeFrameEntity +import me.proton.core.contact.data.local.db.ContactConverters +import me.proton.core.contact.data.local.db.ContactDatabase +import me.proton.core.contact.data.local.db.entity.ContactCardEntity +import me.proton.core.contact.data.local.db.entity.ContactEmailEntity +import me.proton.core.contact.data.local.db.entity.ContactEmailLabelEntity +import me.proton.core.contact.data.local.db.entity.ContactEntity import me.proton.core.crypto.android.keystore.CryptoConverters import me.proton.core.data.room.db.BaseDatabase import me.proton.core.data.room.db.CommonConverters @@ -43,6 +49,7 @@ import me.proton.core.drive.backup.data.db.entity.BackupDuplicateEntity import me.proton.core.drive.backup.data.db.entity.BackupErrorEntity import me.proton.core.drive.backup.data.db.entity.BackupFileEntity import me.proton.core.drive.backup.data.db.entity.BackupFolderEntity +import me.proton.core.drive.base.data.db.entity.UrlLastFetchEntity import me.proton.core.drive.device.data.db.DeviceDatabase import me.proton.core.drive.device.data.db.entity.DeviceEntity import me.proton.core.drive.drivelink.data.db.DriveLinkDatabase @@ -98,8 +105,9 @@ import me.proton.core.drive.sorting.data.db.entity.SortingEntity import me.proton.core.drive.stats.data.db.StatsDatabase import me.proton.core.drive.stats.data.db.entity.InitialBackupEntity import me.proton.core.drive.stats.data.db.entity.UploadStatsEntity -import me.proton.core.drive.user.data.db.QuotaDatabase +import me.proton.core.drive.user.data.db.UserMessageDatabase import me.proton.core.drive.user.data.db.entity.DismissedQuotaEntity +import me.proton.core.drive.user.data.db.entity.DismissedUserMessageEntity import me.proton.core.drive.volume.data.db.VolumeDatabase import me.proton.core.drive.volume.data.db.VolumeEntity import me.proton.core.drive.worker.data.db.WorkerDatabase @@ -145,6 +153,7 @@ import me.proton.core.usersettings.data.entity.OrganizationKeysEntity import me.proton.core.usersettings.data.entity.UserSettingsEntity import me.proton.drive.android.settings.data.db.AppUiSettingsDatabase import me.proton.drive.android.settings.data.db.entity.UiSettingsEntity +import me.proton.core.drive.base.data.db.BaseDatabase as DriveBaseDatabase import me.proton.core.notification.data.local.db.NotificationConverters as CoreNotificationConverters import me.proton.core.notification.data.local.db.NotificationDatabase as CoreNotificationDatabase @@ -176,6 +185,10 @@ import me.proton.core.notification.data.local.db.NotificationDatabase as CoreNot NotificationEntity::class, PushEntity::class, TelemetryEventEntity::class, + ContactCardEntity::class, + ContactEmailEntity::class, + ContactEmailLabelEntity::class, + ContactEntity::class, // Drive VolumeEntity::class, ShareEntity::class, @@ -213,8 +226,9 @@ import me.proton.core.notification.data.local.db.NotificationDatabase as CoreNot // Stats InitialBackupEntity::class, UploadStatsEntity::class, - // Quota + // UserMessage DismissedQuotaEntity::class, + DismissedUserMessageEntity::class, // Notification NotificationChannelEntity::class, NotificationEventEntity::class, @@ -231,6 +245,8 @@ import me.proton.core.notification.data.local.db.NotificationDatabase as CoreNot MediaStoreVersionEntity::class, // Device DeviceEntity::class, + // Base + UrlLastFetchEntity::class, ], version = DriveDatabase.VERSION, autoMigrations = [ @@ -261,6 +277,7 @@ import me.proton.core.notification.data.local.db.NotificationDatabase as CoreNot ChallengeConverters::class, CoreNotificationConverters::class, PushConverters::class, + ContactConverters::class, // Drive EventConverters::class, LinkSelectionConverters::class @@ -270,6 +287,7 @@ abstract class DriveDatabase : AccountDatabase, UserDatabase, AddressDatabase, + ContactDatabase, KeySaltDatabase, HumanVerificationDatabase, PublicAddressDatabase, @@ -303,7 +321,7 @@ abstract class DriveDatabase : NotificationDatabase, PaymentDatabase, BackupDatabase, - QuotaDatabase, + UserMessageDatabase, ObservabilityDatabase, KeyTransparencyDatabase, WorkerDatabase, @@ -314,10 +332,11 @@ abstract class DriveDatabase : DriveLinkPhotoDatabase, DriveFeatureFlagDatabase, MediaStoreVersionDatabase, - DeviceDatabase { + DeviceDatabase, + DriveBaseDatabase { companion object { - const val VERSION = 46 + const val VERSION = 50 private val migrations = listOf( DriveDatabaseMigrations.MIGRATION_1_2, @@ -365,6 +384,10 @@ abstract class DriveDatabase : DriveDatabaseMigrations.MIGRATION_43_44, DriveDatabaseMigrations.MIGRATION_44_45, DriveDatabaseMigrations.MIGRATION_45_46, + DriveDatabaseMigrations.MIGRATION_46_47, + DriveDatabaseMigrations.MIGRATION_47_48, + DriveDatabaseMigrations.MIGRATION_48_49, + DriveDatabaseMigrations.MIGRATION_49_50, ) fun buildDatabase(context: Context): DriveDatabase = diff --git a/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabaseMigrations.kt b/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabaseMigrations.kt index 79adb67e..ee5327e3 100644 --- a/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabaseMigrations.kt +++ b/drive/db/src/main/kotlin/me/proton/android/drive/db/DriveDatabaseMigrations.kt @@ -23,7 +23,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase import me.proton.android.drive.photos.data.db.MediaStoreVersionDatabase import me.proton.core.account.data.db.AccountDatabase import me.proton.core.challenge.data.db.ChallengeDatabase +import me.proton.core.contact.data.local.db.ContactDatabase import me.proton.core.drive.backup.data.db.BackupDatabase +import me.proton.core.drive.base.data.db.BaseDatabase import me.proton.core.drive.device.data.db.DeviceDatabase import me.proton.core.drive.drivelink.photo.data.db.DriveLinkPhotoDatabase import me.proton.core.drive.feature.flag.data.db.DriveFeatureFlagDatabase @@ -36,7 +38,7 @@ import me.proton.core.drive.photo.data.db.PhotoDatabase import me.proton.core.drive.share.data.db.ShareDatabase import me.proton.core.drive.shareurl.base.data.db.ShareUrlDatabase import me.proton.core.drive.stats.data.db.StatsDatabase -import me.proton.core.drive.user.data.db.QuotaDatabase +import me.proton.core.drive.user.data.db.UserMessageDatabase import me.proton.core.eventmanager.data.db.EventMetadataDatabase import me.proton.core.featureflag.data.db.FeatureFlagDatabase import me.proton.core.humanverification.data.db.HumanVerificationDatabase @@ -164,7 +166,7 @@ object DriveDatabaseMigrations { LinkDatabase.MIGRATION_1.migrate(database) LinkUploadDatabase.MIGRATION_1.migrate(database) BackupDatabase.MIGRATION_0.migrate(database) - QuotaDatabase.MIGRATION_0.migrate(database) + UserMessageDatabase.MIGRATION_0.migrate(database) NotificationDatabase.MIGRATION_1.migrate(database) PhotoDatabase.MIGRATION_0.migrate(database) DriveLinkPhotoDatabase.MIGRATION_0.migrate(database) @@ -261,7 +263,7 @@ object DriveDatabaseMigrations { } } - val MIGRATION_42_43 = object: Migration(42, 43) { + val MIGRATION_42_43 = object : Migration(42, 43) { override fun migrate(database: SupportSQLiteDatabase) { UserSettingsDatabase.MIGRATION_5.migrate(database) UserKeyDatabase.MIGRATION_0.migrate(database) @@ -275,15 +277,41 @@ object DriveDatabaseMigrations { } } - val MIGRATION_44_45 = object: Migration(44, 45) { + val MIGRATION_44_45 = object : Migration(44, 45) { override fun migrate(database: SupportSQLiteDatabase) { DeviceDatabase.MIGRATION_0.migrate(database) } } - val MIGRATION_45_46 = object: Migration(45, 46) { + val MIGRATION_45_46 = object : Migration(45, 46) { override fun migrate(database: SupportSQLiteDatabase) { LinkTrashDatabase.MIGRATION_1.migrate(database) } } + + val MIGRATION_46_47 = object : Migration(46, 47) { + override fun migrate(database: SupportSQLiteDatabase) { + ContactDatabase.MIGRATION_0.migrate(database) + ContactDatabase.MIGRATION_1.migrate(database) + } + } + + val MIGRATION_47_48 = object : Migration(47, 48) { + override fun migrate(database: SupportSQLiteDatabase) { + UserDatabase.MIGRATION_5.migrate(database) + AccountDatabase.MIGRATION_7.migrate(database) + } + } + + val MIGRATION_48_49 = object : Migration(48, 49) { + override fun migrate(database: SupportSQLiteDatabase) { + UserMessageDatabase.MIGRATION_1.migrate(database) + } + } + + val MIGRATION_49_50 = object : Migration(49, 50) { + override fun migrate(database: SupportSQLiteDatabase) { + BaseDatabase.MIGRATION_0.migrate(database) + } + } } diff --git a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/DeviceApi.kt b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/DeviceApi.kt index 51f8e0a0..6c82f7d7 100644 --- a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/DeviceApi.kt +++ b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/DeviceApi.kt @@ -18,10 +18,14 @@ package me.proton.core.drive.device.data.api +import me.proton.core.drive.base.data.api.response.CodeResponse +import me.proton.core.drive.device.data.api.request.UpdateDeviceRequest import me.proton.core.drive.device.data.api.response.GetDevicesResponse import me.proton.core.network.data.protonApi.BaseRetrofitApi +import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.PUT import retrofit2.http.Path interface DeviceApi : BaseRetrofitApi { @@ -29,8 +33,14 @@ interface DeviceApi : BaseRetrofitApi { @GET("drive/devices") suspend fun getDevices(): GetDevicesResponse + @PUT("drive/devices/{deviceID}") + suspend fun updateDevice( + @Path("deviceID") deviceId: String, + @Body request: UpdateDeviceRequest, + ): CodeResponse + @DELETE("drive/devices/{deviceID}") suspend fun deleteDevice( @Path("deviceID") deviceId: String, - ) + ): CodeResponse } diff --git a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/DeviceApiDataSource.kt b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/DeviceApiDataSource.kt index 70552b3b..6f4e9b0a 100644 --- a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/DeviceApiDataSource.kt +++ b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/DeviceApiDataSource.kt @@ -19,6 +19,8 @@ package me.proton.core.drive.device.data.api import me.proton.core.domain.entity.UserId +import me.proton.core.drive.device.data.api.request.ShareDataDto +import me.proton.core.drive.device.data.api.request.UpdateDeviceRequest import me.proton.core.drive.device.domain.entity.DeviceId import me.proton.core.network.data.ApiProvider import me.proton.core.network.domain.ApiException @@ -40,6 +42,24 @@ class DeviceApiDataSource @Inject constructor( getDevices() }.valueOrThrow.devices + @Throws(ApiException::class) + suspend fun updateDeviceName( + userId: UserId, + deviceId: DeviceId, + name: String, + ) = apiProvider + .get(userId) + .invoke { + updateDevice( + deviceId = deviceId.id, + request = UpdateDeviceRequest( + shareDto = ShareDataDto( + name = name, + ) + ) + ) + }.valueOrThrow + @Throws(ApiException::class) suspend fun deleteDevice( userId: UserId, diff --git a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/request/UpdateDeviceRequest.kt b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/request/UpdateDeviceRequest.kt new file mode 100644 index 00000000..495817eb --- /dev/null +++ b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/api/request/UpdateDeviceRequest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.device.data.api.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import me.proton.core.drive.base.data.api.Dto.NAME +import me.proton.core.drive.base.data.api.Dto.SHARE + +@Serializable +data class UpdateDeviceRequest( + @SerialName(SHARE) + val shareDto: ShareDataDto? +) + +@Serializable +data class ShareDataDto( + @SerialName(NAME) + val name: String? = null, +) diff --git a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/db/dao/DeviceDao.kt b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/db/dao/DeviceDao.kt index af093661..6c10fd07 100644 --- a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/db/dao/DeviceDao.kt +++ b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/db/dao/DeviceDao.kt @@ -68,4 +68,17 @@ abstract class DeviceDao : BaseDao() { @Query("DELETE FROM DeviceEntity WHERE user_id = :userId") abstract suspend fun deleteAll(userId: UserId) + + @Query( + """ + SELECT DeviceEntity.* FROM DeviceEntity + LEFT JOIN LinkEntity ON + DeviceEntity.user_id = LinkEntity.user_id AND + DeviceEntity.share_id = LinkEntity.share_id AND + DeviceEntity.link_id = LinkEntity.id + WHERE + DeviceEntity.user_id = :userId AND DeviceEntity.id = :deviceId + """ + ) + abstract fun getFlow(userId: UserId, deviceId: String): Flow } diff --git a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/repository/DeviceRepositoryImpl.kt b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/repository/DeviceRepositoryImpl.kt index 2d5cd658..64d545a4 100644 --- a/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/repository/DeviceRepositoryImpl.kt +++ b/drive/device/data/src/main/kotlin/me/proton/core/drive/device/data/repository/DeviceRepositoryImpl.kt @@ -27,6 +27,7 @@ import me.proton.core.drive.device.data.db.DeviceDatabase import me.proton.core.drive.device.data.extension.toDevice import me.proton.core.drive.device.data.extension.toDeviceEntity import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId import me.proton.core.drive.device.domain.repository.DeviceRepository import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.usecase.GetLink @@ -68,6 +69,15 @@ class DeviceRepositoryImpl @Inject constructor( return deviceEntities.map { deviceEntity -> deviceEntity.toDevice() } } + override fun getDeviceFlow(userId: UserId, deviceId: DeviceId): Flow = + db.deviceDao.getFlow(userId, deviceId.id).map { deviceEntity -> + deviceEntity?.toDevice() + } + + override suspend fun renameDevice(userId: UserId, deviceId: DeviceId, name: String) { + api.updateDeviceName(userId, deviceId, name) + } + private suspend fun fetchDevicesDtos(userId: UserId) = api.getDevices(userId) private suspend fun fetchShareAndFolder(folderId: FolderId) { diff --git a/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/entity/Device.kt b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/entity/Device.kt index 87a7ece8..cc54e8ee 100644 --- a/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/entity/Device.kt +++ b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/entity/Device.kt @@ -36,8 +36,6 @@ data class Device( val lastModified: TimestampS? = null, val lastSynced: TimestampS? = null, ) { - val name: String get() = cryptoName.value - enum class Type { UNKNOWN, WINDOWS, diff --git a/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/extension/Device.kt b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/extension/Device.kt new file mode 100644 index 00000000..2a2cd210 --- /dev/null +++ b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/extension/Device.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.device.domain.extension + +import me.proton.core.drive.base.domain.entity.CryptoProperty +import me.proton.core.drive.device.domain.entity.Device + +val Device.name: String get() = cryptoName.value + +val Device.isNameEncrypted: Boolean get() = cryptoName is CryptoProperty.Encrypted diff --git a/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/repository/DeviceRepository.kt b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/repository/DeviceRepository.kt index 38d72106..006e57c5 100644 --- a/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/repository/DeviceRepository.kt +++ b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/repository/DeviceRepository.kt @@ -21,6 +21,7 @@ package me.proton.core.drive.device.domain.repository import kotlinx.coroutines.flow.Flow import me.proton.core.domain.entity.UserId import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId interface DeviceRepository { @@ -31,4 +32,8 @@ interface DeviceRepository { ): Flow> suspend fun fetchAndStoreDevices(userId: UserId): List + + fun getDeviceFlow(userId: UserId, deviceId: DeviceId): Flow + + suspend fun renameDevice(userId: UserId, deviceId: DeviceId, name: String) } diff --git a/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/usecase/GetDevice.kt b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/usecase/GetDevice.kt new file mode 100644 index 00000000..b47870b2 --- /dev/null +++ b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/usecase/GetDevice.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.device.domain.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.asSuccessOrNullAsError +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.repository.DeviceRepository +import javax.inject.Inject + +class GetDevice @Inject constructor( + private val repository: DeviceRepository, +) { + + operator fun invoke(userId: UserId, deviceId: DeviceId): Flow> = + repository.getDeviceFlow(userId, deviceId).map { device -> device.asSuccessOrNullAsError() } +} diff --git a/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/usecase/RenameDevice.kt b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/usecase/RenameDevice.kt new file mode 100644 index 00000000..0337fd63 --- /dev/null +++ b/drive/device/domain/src/main/kotlin/me/proton/core/drive/device/domain/usecase/RenameDevice.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.device.domain.usecase + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.util.coRunCatching +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.repository.DeviceRepository +import javax.inject.Inject + +class RenameDevice @Inject constructor( + private val repository: DeviceRepository, +) { + + suspend operator fun invoke(userId: UserId, deviceId: DeviceId, name: String): Result = coRunCatching { + repository.renameDevice(userId, deviceId, name) + } +} diff --git a/drive/drivelink-device/domain/build.gradle.kts b/drive/drivelink-device/domain/build.gradle.kts index d072655d..17940396 100644 --- a/drive/drivelink-device/domain/build.gradle.kts +++ b/drive/drivelink-device/domain/build.gradle.kts @@ -24,7 +24,8 @@ android { } driveModule(hilt = true) { + api(project(":drive:device:domain")) api(project(":drive:drivelink:domain")) api(project(":drive:drivelink-crypto:domain")) - api(project(":drive:device:domain")) + api(project(":drive:drivelink-rename:domain")) } diff --git a/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/DecryptNameOrKeepEncrypted.kt b/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/DecryptNameOrKeepEncrypted.kt new file mode 100644 index 00000000..d9e95983 --- /dev/null +++ b/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/DecryptNameOrKeepEncrypted.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.device.domain.usecase + +import me.proton.core.drive.base.domain.entity.CryptoProperty +import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.base.domain.util.coRunCatching +import me.proton.core.drive.crypto.domain.usecase.DecryptLinkName +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.link.domain.usecase.GetLink +import javax.inject.Inject + +class DecryptNameOrKeepEncrypted @Inject constructor( + private val decryptLinkName: DecryptLinkName, + private val getLink: GetLink, +) { + + suspend operator fun invoke(device: Device) = device.decryptNameOrKeepEncrypted() + + private suspend fun Device.decryptNameOrKeepEncrypted(): Device = + decryptDeviceName(this) + .fold( + onSuccess = { cryptoName -> + copy(cryptoName = cryptoName) + }, + onFailure = { + this + } + ) + private suspend fun decryptDeviceName(device: Device): Result> = coRunCatching { + with ( + decryptLinkName( + getLink(device.rootLinkId).toResult().getOrThrow() + ).getOrThrow() + ) { + CryptoProperty.Decrypted( + value = text, + status = status, + ) + } + } +} diff --git a/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevice.kt b/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevice.kt new file mode 100644 index 00000000..8b45aab4 --- /dev/null +++ b/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevice.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.device.domain.usecase + +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.mapSuccess +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.asSuccess +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.usecase.GetDevice +import javax.inject.Inject + +class GetDecryptedDevice @Inject constructor( + private val getDevice: GetDevice, + private val decryptNameOrKeepEncrypted: DecryptNameOrKeepEncrypted, +) { + + operator fun invoke(userId: UserId, deviceId: DeviceId): Flow> = getDevice(userId, deviceId) + .mapSuccess { device -> + decryptNameOrKeepEncrypted(device.value).asSuccess + } +} diff --git a/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevices.kt b/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevices.kt index c73f3703..b3934496 100644 --- a/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevices.kt +++ b/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevices.kt @@ -24,18 +24,13 @@ import me.proton.core.domain.arch.mapSuccess import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.domain.entity.CryptoProperty import me.proton.core.drive.base.domain.extension.asSuccess -import me.proton.core.drive.base.domain.extension.toResult -import me.proton.core.drive.base.domain.util.coRunCatching -import me.proton.core.drive.crypto.domain.usecase.DecryptLinkName import me.proton.core.drive.device.domain.entity.Device import me.proton.core.drive.device.domain.usecase.GetDevices -import me.proton.core.drive.link.domain.usecase.GetLink import javax.inject.Inject class GetDecryptedDevices @Inject constructor( private val getDevices: GetDevices, - private val decryptLinkName: DecryptLinkName, - private val getLink: GetLink, + private val decryptNameOrKeepEncrypted: DecryptNameOrKeepEncrypted, ) { operator fun invoke(userId: UserId): Flow>> = @@ -45,31 +40,8 @@ class GetDecryptedDevices @Inject constructor( if (device.cryptoName is CryptoProperty.Decrypted) { device } else { - device.decryptNameOrKeepEncrypted() + decryptNameOrKeepEncrypted(device) } }.asSuccess } - - private suspend fun Device.decryptNameOrKeepEncrypted(): Device = - decryptName() - .fold( - onSuccess = { cryptoName -> - copy(cryptoName = cryptoName) - }, - onFailure = { - this - } - ) - private suspend fun Device.decryptName(): Result> = coRunCatching { - with ( - decryptLinkName( - getLink(rootLinkId).toResult().getOrThrow() - ).getOrThrow() - ) { - CryptoProperty.Decrypted( - value = text, - status = status, - ) - } - } } diff --git a/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/RenameDevice.kt b/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/RenameDevice.kt new file mode 100644 index 00000000..888035b0 --- /dev/null +++ b/drive/drivelink-device/domain/src/main/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/RenameDevice.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.device.domain.usecase + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.util.coRunCatching +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.extension.name +import me.proton.core.drive.device.domain.usecase.GetDevice +import me.proton.core.drive.device.domain.usecase.RenameDevice +import me.proton.core.drive.drivelink.rename.domain.usecase.RenameLink +import me.proton.core.drive.link.domain.usecase.ValidateLinkName +import javax.inject.Inject + +class RenameDevice @Inject constructor( + private val getDevice: GetDevice, + private val renameDevice: RenameDevice, + private val renameLink: RenameLink, + private val configurationProvider: ConfigurationProvider, +) { + + suspend operator fun invoke(userId: UserId, deviceId: DeviceId, name: String): Result = coRunCatching { + val deviceName = validateDeviceName(name) + val device = getDevice(userId, deviceId).toResult().getOrThrow() + renameLink( + rootFolderId = device.rootLinkId, + folderName = deviceName, + shouldValidateName = false, + ).getOrThrow() + if (device.name.isNotEmpty()) { + // Non-empty device name should be replaced by empty one, as real device name is part of device share + // root folder + renameDevice(userId, deviceId, DEFAULT_DEVICE_NAME).getOrThrow() + } + } + + private fun validateDeviceName(name: String): String { + val trimmedName = name.trim() + val maxLength = configurationProvider.linkMaxNameLength + when { + trimmedName.isEmpty() -> throw ValidateLinkName.Invalid.Empty + trimmedName.length > maxLength -> throw ValidateLinkName.Invalid.ExceedsMaxLength(maxLength) + } + return trimmedName + } + + companion object { + private const val DEFAULT_DEVICE_NAME = "" + } +} diff --git a/drive/drivelink-device/domain/src/test/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevicesSortedByNameTest.kt b/drive/drivelink-device/domain/src/test/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevicesSortedByNameTest.kt index eee00d77..42d7e202 100644 --- a/drive/drivelink-device/domain/src/test/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevicesSortedByNameTest.kt +++ b/drive/drivelink-device/domain/src/test/kotlin/me/proton/core/drive/drivelink/device/domain/usecase/GetDecryptedDevicesSortedByNameTest.kt @@ -32,6 +32,7 @@ import me.proton.core.drive.base.domain.entity.CryptoProperty import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.device.domain.entity.Device import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.extension.name import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.volume.domain.entity.VolumeId diff --git a/drive/drivelink-device/presentation/build.gradle.kts b/drive/drivelink-device/presentation/build.gradle.kts index e61087ce..0edd0371 100644 --- a/drive/drivelink-device/presentation/build.gradle.kts +++ b/drive/drivelink-device/presentation/build.gradle.kts @@ -28,9 +28,10 @@ driveModule( compose = true, i18n = true, ) { - implementation(project(":drive:base:data")) api(project(":drive:base:presentation")) api(project(":drive:drivelink-device:domain")) + api(project(":drive:drivelink-rename:presentation")) api(project(":drive:files-list")) api(libs.core.presentation.compose) + implementation(project(":drive:base:data")) } diff --git a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DeviceListItem.kt b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DeviceListItem.kt index 7ce82878..0516cf0a 100644 --- a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DeviceListItem.kt +++ b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DeviceListItem.kt @@ -30,8 +30,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing @@ -46,12 +46,14 @@ import me.proton.core.drive.base.presentation.component.EncryptedItem import me.proton.core.drive.base.presentation.component.text.TextWithMiddleEllipsis import me.proton.core.drive.device.domain.entity.Device import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.extension.name import me.proton.core.drive.drivelink.device.presentation.extension.getTypeName import me.proton.core.drive.drivelink.device.presentation.extension.icon import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.volume.domain.entity.VolumeId -import me.proton.core.presentation.R +import me.proton.core.drive.i18n.R as I18N +import me.proton.core.presentation.R as CorePresentation @Composable fun DeviceListItem( @@ -81,10 +83,10 @@ fun DeviceListItem( modifier = Modifier .weight(1f) .padding(horizontal = DefaultSpacing), - )/* + ) MoreOptions(device) { onMoreOptionsClick(device) - }*/ + } } } @@ -136,13 +138,17 @@ fun MoreOptions( modifier: Modifier = Modifier, onClick: (Device) -> Unit, ) { + val moreOptionsContentDescription = stringResource( + id = I18N.string.computers_content_description_list_more_options, + device.name, + ) IconButton( - modifier = modifier.testTag(DeviceTestTag.moreButton), + modifier = modifier, onClick = { onClick(device) } ) { Icon( - painter = painterResource(id = R.drawable.ic_proton_three_dots_vertical), - contentDescription = null, + painter = painterResource(id = CorePresentation.drawable.ic_proton_three_dots_vertical), + contentDescription = moreOptionsContentDescription, tint = ProtonTheme.colors.interactionStrongNorm ) } @@ -195,9 +201,5 @@ private val device = Device( cryptoName = CryptoProperty.Encrypted("e_n_c_r_y_p_t_e_d"), ) -object DeviceTestTag { - const val moreButton = "three dots button" -} - private val VerticalSpacing = 10.dp private val LargeIconSize = 32.dp diff --git a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DeviceOptions.kt b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DeviceOptions.kt new file mode 100644 index 00000000..9985595c --- /dev/null +++ b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DeviceOptions.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.device.presentation.component + +import androidx.annotation.DrawableRes +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.proton.core.compose.component.bottomsheet.BottomSheetContent +import me.proton.core.compose.component.bottomsheet.BottomSheetEntry +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultSmallStrong +import me.proton.core.crypto.common.pgp.VerificationStatus +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.entity.CryptoProperty +import me.proton.core.drive.base.domain.entity.TimestampS +import me.proton.core.drive.base.presentation.component.EncryptedItem +import me.proton.core.drive.base.presentation.component.text.TextWithMiddleEllipsis +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.extension.isNameEncrypted +import me.proton.core.drive.device.domain.extension.name +import me.proton.core.drive.drivelink.device.presentation.options.DeviceOptionEntry +import me.proton.core.drive.drivelink.device.presentation.options.RenameDeviceOption +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.volume.domain.entity.VolumeId +import me.proton.core.presentation.R as CorePresentation + +@Composable +fun DeviceOptions( + device: Device, + entries: List, + modifier: Modifier = Modifier, +) { + BottomSheetContent( + modifier = modifier, + header = { + DeviceOptionsHeader( + device = device, + iconResId = CorePresentation.drawable.ic_proton_tv, + ) + }, + content = { + entries.forEach { entry -> + BottomSheetEntry( + icon = entry.icon, + title = stringResource(id = entry.label), + onClick = { entry.onClick(device) } + ) + } + } + ) +} + +@Composable +fun DeviceOptionsHeader( + device: Device, + @DrawableRes iconResId: Int, + modifier: Modifier = Modifier, +) { + OptionsHeader( + painter = painterResource(id = iconResId), + title = device.name, + isTitleEncrypted = device.isNameEncrypted, + modifier = modifier + ) +} + +@Composable +internal fun OptionsHeader( + painter: Painter, + title: String, + isTitleEncrypted: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier + .size(HeaderIconSize) + .clip(RoundedCornerShape(ProtonDimens.DefaultCornerRadius)), + painter = painter, + contentDescription = null + ) + Column( + modifier = Modifier + .defaultMinSize(minHeight = ProtonDimens.DefaultBottomSheetHeaderMinHeight) + .padding(start = HeaderSpacing), + verticalArrangement = Arrangement.Center + ) { + Crossfade(targetState = isTitleEncrypted) { isEncrypted -> + if (isEncrypted) { + EncryptedItem() + } else { + TextWithMiddleEllipsis( + text = title, + style = ProtonTheme.typography.defaultSmallStrong, + maxLines = 1, + ) + } + } + } + } +} + +private val HeaderIconSize = 24.dp +private val HeaderSpacing = 12.dp + +@Preview +@Composable +private fun PreviewDeviceOptions() { + ProtonTheme { + DeviceOptions( + device = Device( + id = DeviceId("device-id"), + volumeId = VolumeId("volume-id"), + rootLinkId = FolderId(ShareId(UserId("user-id"), "share-id"), "folder-id"), + type = Device.Type.WINDOWS, + syncState = Device.SyncState.ON, + cryptoName = CryptoProperty.Decrypted("DESKTOP", VerificationStatus.Unknown), + creationTime = TimestampS(), + ), + entries = listOf( + RenameDeviceOption {}, + ), + ) + } +} diff --git a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DevicesContent.kt b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DevicesContent.kt index 1479c16f..4b769ccc 100644 --- a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DevicesContent.kt +++ b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DevicesContent.kt @@ -39,6 +39,7 @@ fun DevicesContent( devices: List, modifier: Modifier = Modifier, onClick: (Device) -> Unit, + onMoreOptions: (Device) -> Unit, ) { LazyColumn( modifier = modifier @@ -52,7 +53,7 @@ fun DevicesContent( DeviceListItem( device = device, onClick = onClick, - onMoreOptionsClick = {}, + onMoreOptionsClick = onMoreOptions, ) } } @@ -92,6 +93,8 @@ fun PreviewDevicesContent() { cryptoName = CryptoProperty.Decrypted("QNAP", VerificationStatus.Unknown), ) ), - ) {} + onClick = {}, + onMoreOptions = {}, + ) } } diff --git a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DevicesEmpty.kt b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DevicesEmpty.kt index 27e76c72..0d83f374 100644 --- a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DevicesEmpty.kt +++ b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/DevicesEmpty.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import me.proton.core.compose.theme.ProtonTheme -import me.proton.core.drive.base.presentation.component.ListEmpty +import me.proton.core.drive.base.presentation.component.IllustratedMessage import me.proton.core.drive.drivelink.device.presentation.R import me.proton.core.drive.i18n.R as I18N @@ -36,7 +36,7 @@ fun DevicesEmpty( @StringRes descriptionResId: Int, modifier: Modifier = Modifier, ) { - ListEmpty( + IllustratedMessage( imageResId = imageResId, titleResId = titleResId, descriptionResId = descriptionResId, diff --git a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/RenameDevice.kt b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/RenameDevice.kt new file mode 100644 index 00000000..9e5dc139 --- /dev/null +++ b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/component/RenameDevice.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.device.presentation.component + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import me.proton.core.drive.drivelink.device.presentation.viewmodel.RenameDeviceViewModel +import me.proton.core.drive.drivelink.rename.presentation.Rename + +@Composable +fun RenameDevice( + onDismiss: () -> Unit, +) { + val viewModel = hiltViewModel() + Rename( + viewModel = viewModel, + onDismiss = onDismiss, + ) +} diff --git a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/options/DeviceEntries.kt b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/options/DeviceEntries.kt new file mode 100644 index 00000000..78f8bcde --- /dev/null +++ b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/options/DeviceEntries.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.device.presentation.options + +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.files.presentation.entry.OptionEntry +import me.proton.core.presentation.R as CorePresentation +import me.proton.core.drive.i18n.R as I18N + +interface DeviceOptionEntry : OptionEntry + +class RenameDeviceOption( + override val onClick: (Device) -> Unit +) : DeviceOptionEntry { + + override val icon: Int get() = CorePresentation.drawable.ic_proton_pen + + override val label: Int get() = I18N.string.common_rename_action +} diff --git a/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/viewmodel/RenameDeviceViewModel.kt b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/viewmodel/RenameDeviceViewModel.kt new file mode 100644 index 00000000..01d4fedf --- /dev/null +++ b/drive/drivelink-device/presentation/src/main/kotlin/me/proton/core/drive/drivelink/device/presentation/viewmodel/RenameDeviceViewModel.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.device.presentation.viewmodel + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import me.proton.core.domain.arch.DataResult +import me.proton.core.drive.base.data.extension.log +import me.proton.core.drive.base.domain.extension.filterSuccessOrError +import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.base.domain.log.LogTag +import me.proton.core.drive.base.domain.log.logId +import me.proton.core.drive.base.domain.usecase.BroadcastMessages +import me.proton.core.drive.base.presentation.extension.require +import me.proton.core.drive.device.domain.entity.Device +import me.proton.core.drive.device.domain.entity.DeviceId +import me.proton.core.drive.device.domain.extension.isNameEncrypted +import me.proton.core.drive.device.domain.extension.name +import me.proton.core.drive.drivelink.device.domain.usecase.GetDecryptedDevice +import me.proton.core.drive.drivelink.device.domain.usecase.RenameDevice +import me.proton.core.drive.drivelink.rename.presentation.RenameEffect +import me.proton.core.drive.drivelink.rename.presentation.viewmodel.RenameViewModel +import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage +import javax.inject.Inject +import me.proton.core.drive.i18n.R as I18N + +@HiltViewModel +class RenameDeviceViewModel @Inject constructor( + @ApplicationContext appContext: Context, + savedStateHandle: SavedStateHandle, + getDevice: GetDecryptedDevice, + private val renameDevice: RenameDevice, + private val broadcastMessages: BroadcastMessages, +) : RenameViewModel(appContext, savedStateHandle) { + private val deviceId = DeviceId(savedStateHandle.require(KEY_DEVICE_ID)) + override val titleResId: Int get() = I18N.string.computers_rename_title + + private val unused = getDevice(userId, deviceId) + .filterSuccessOrError() + .map { deviceResult -> + name.emit( + savedStateHandle.get(KEY_FILENAME) + ?: deviceResult.name + ) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + override suspend fun doRenameFile(name: String) { + renameDevice( + userId = userId, + deviceId = deviceId, + name, + ) + .onFailure { error -> + error.log(LogTag.RENAME, "Cannot rename device: ${deviceId.id.logId()}") + error.handle() + } + .onSuccess { + _renameEffect.emit(RenameEffect.Dismiss) + broadcastMessages( + userId = userId, + message = appContext.getString(I18N.string.computers_rename_success), + type = BroadcastMessage.Type.INFO, + ) + } + } + + private val DataResult.name get() = + toResult() + .getOrNull() + ?.takeUnless { device -> device.isNameEncrypted } + ?.name + ?: "" + + companion object { + const val KEY_DEVICE_ID = "deviceId" + } +} diff --git a/drive/drivelink-download/data-test/build.gradle.kts b/drive/drivelink-download/data-test/build.gradle.kts new file mode 100644 index 00000000..59206b9f --- /dev/null +++ b/drive/drivelink-download/data-test/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +android { + namespace = "me.proton.core.drive.drivelink.download.data.test" +} + +driveModule( + hilt = true, +) { + api(project(":drive:drivelink-download:domain")) + implementation(project(":drive:drivelink-download:data")) + + implementation(project(":drive:base:data")) + implementation(project(":drive:drivelink:data")) + implementation(project(":drive:file:base:domain")) + implementation(project(":drive:link-node:domain")) + implementation(project(":drive:worker:data")) + implementation(libs.androidx.work.testing) + implementation(libs.androidx.test.core.ktx) + implementation(libs.dagger.hilt.android.testing) +} diff --git a/drive/drivelink-download/data-test/src/main/kotlin/me/proton/core/drive/drivelink/download/data/test/di/TestDownloadWorkBindModule.kt b/drive/drivelink-download/data-test/src/main/kotlin/me/proton/core/drive/drivelink/download/data/test/di/TestDownloadWorkBindModule.kt new file mode 100644 index 00000000..0f3c36a2 --- /dev/null +++ b/drive/drivelink-download/data-test/src/main/kotlin/me/proton/core/drive/drivelink/download/data/test/di/TestDownloadWorkBindModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.drivelink.download.data.test.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import me.proton.core.drive.drivelink.download.data.di.DownloadWorkBindModule +import me.proton.core.drive.drivelink.download.data.test.manager.StubbedDownloadWorkManager +import me.proton.core.drive.drivelink.download.domain.manager.DownloadWorkManager +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DownloadWorkBindModule::class], +) +interface TestDownloadWorkBindModule { + + @Binds + @Singleton + fun bindsDownloadWorkManager(impl: StubbedDownloadWorkManager): DownloadWorkManager +} diff --git a/drive/drivelink-download/data-test/src/main/kotlin/me/proton/core/drive/drivelink/download/data/test/manager/StubbedDownloadWorkManager.kt b/drive/drivelink-download/data-test/src/main/kotlin/me/proton/core/drive/drivelink/download/data/test/manager/StubbedDownloadWorkManager.kt new file mode 100644 index 00000000..e827e4f4 --- /dev/null +++ b/drive/drivelink-download/data-test/src/main/kotlin/me/proton/core/drive/drivelink/download/data/test/manager/StubbedDownloadWorkManager.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.download.data.test.manager + +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.entity.Percentage +import me.proton.core.drive.drivelink.domain.entity.DriveLink +import me.proton.core.drive.drivelink.download.domain.manager.DownloadWorkManager +import javax.inject.Inject + +class StubbedDownloadWorkManager @Inject constructor(): DownloadWorkManager { + override suspend fun download(driveLink: DriveLink, retryable: Boolean) { + // do nothing + } + + override fun cancel(driveLink: DriveLink) { + // do nothing + } + + override suspend fun cancelAll(userId: UserId) { + // do nothing + } + + override fun getProgressFlow(driveLink: DriveLink.File): Flow? { + // do nothing + return null + } +} diff --git a/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/di/DownloadBindModule.kt b/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/di/DownloadBindModule.kt index db2aed07..e20a0f41 100644 --- a/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/di/DownloadBindModule.kt +++ b/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/di/DownloadBindModule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -21,9 +21,7 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import me.proton.core.drive.drivelink.download.data.manager.DownloadWorkManagerImpl import me.proton.core.drive.drivelink.download.data.repository.DriveLinkDownloadRepositoryImpl -import me.proton.core.drive.drivelink.download.domain.manager.DownloadWorkManager import me.proton.core.drive.drivelink.download.domain.repository.DriveLinkDownloadRepository import javax.inject.Singleton @@ -31,10 +29,6 @@ import javax.inject.Singleton @Module interface DownloadBindModule { - @Binds - @Singleton - fun bindsDownloadWorkManagerImpl(impl: DownloadWorkManagerImpl): DownloadWorkManager - @Binds @Singleton fun bindsRepositoryImpl(impl: DriveLinkDownloadRepositoryImpl): DriveLinkDownloadRepository diff --git a/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/di/DownloadWorkBindModule.kt b/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/di/DownloadWorkBindModule.kt new file mode 100644 index 00000000..7ff8aded --- /dev/null +++ b/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/di/DownloadWorkBindModule.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2021-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.drivelink.download.data.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.core.drive.drivelink.download.data.manager.DownloadWorkManagerImpl +import me.proton.core.drive.drivelink.download.domain.manager.DownloadWorkManager +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +interface DownloadWorkBindModule { + + @Binds + @Singleton + fun bindsDownloadWorkManagerImpl(impl: DownloadWorkManagerImpl): DownloadWorkManager +} diff --git a/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/manager/DownloadWorkManagerImpl.kt b/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/manager/DownloadWorkManagerImpl.kt index 62661ec1..74d5041b 100644 --- a/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/manager/DownloadWorkManagerImpl.kt +++ b/drive/drivelink-download/data/src/main/kotlin/me/proton/core/drive/drivelink/download/data/manager/DownloadWorkManagerImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -30,6 +30,7 @@ import me.proton.core.drive.base.data.workmanager.getLong import me.proton.core.drive.base.domain.entity.Percentage import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.drivelink.download.data.extension.isNotDownloading +import me.proton.core.drive.drivelink.download.data.extension.logTag import me.proton.core.drive.drivelink.download.data.extension.uniqueWorkName import me.proton.core.drive.drivelink.download.data.worker.DownloadCleanupWorker import me.proton.core.drive.drivelink.download.data.worker.FileDownloadWorker @@ -39,6 +40,7 @@ import me.proton.core.drive.drivelink.download.domain.manager.DownloadWorkManage import me.proton.core.drive.drivelink.download.domain.usecase.GetDownloadingDriveLinks import me.proton.core.drive.file.base.domain.usecase.GetRevision import me.proton.core.drive.link.domain.extension.userId +import me.proton.core.util.kotlin.CoreLogger import javax.inject.Inject class DownloadWorkManagerImpl @Inject constructor( @@ -56,6 +58,8 @@ class DownloadWorkManagerImpl @Inject constructor( is DriveLink.File -> FileDownloadWorker.getWorkRequest(driveLink, retryable) } ) + } else { + CoreLogger.d(driveLink.id.logTag, "Ignore download: link already downloading") } } diff --git a/drive/drivelink-offline/domain/src/main/kotlin/me/proton/core/drive/drivelink/offline/domain/usecase/DeleteLocalContent.kt b/drive/drivelink-offline/domain/src/main/kotlin/me/proton/core/drive/drivelink/offline/domain/usecase/DeleteLocalContent.kt index 1a61391d..96ddeab4 100644 --- a/drive/drivelink-offline/domain/src/main/kotlin/me/proton/core/drive/drivelink/offline/domain/usecase/DeleteLocalContent.kt +++ b/drive/drivelink-offline/domain/src/main/kotlin/me/proton/core/drive/drivelink/offline/domain/usecase/DeleteLocalContent.kt @@ -24,10 +24,10 @@ import me.proton.core.drive.base.domain.log.LogTag import me.proton.core.drive.base.domain.log.logId import me.proton.core.drive.base.domain.usecase.GetCacheFolder import me.proton.core.drive.base.domain.usecase.GetPermanentFolder +import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.link.domain.extension.userId import me.proton.core.util.kotlin.CoreLogger -import java.io.IOException import javax.inject.Inject import kotlin.coroutines.CoroutineContext @@ -39,11 +39,9 @@ class DeleteLocalContent @Inject constructor( suspend operator fun invoke( file: DriveLink.File, coroutineContext: CoroutineContext = Job() + Dispatchers.IO, - ) = try { + ) = coRunCatching { CoreLogger.d(LogTag.EVENTS, "Deleting local folders for: ${file.id.id.logId()}") getCacheFolder(file.userId, file.volumeId.id, file.activeRevisionId, coroutineContext).deleteRecursively() getPermanentFolder(file.userId, file.volumeId.id, file.activeRevisionId, coroutineContext).deleteRecursively() - } catch (ignored: IOException) { - // } } diff --git a/drive/drivelink-rename/domain/src/main/kotlin/me/proton/core/drive/drivelink/rename/domain/usecase/RenameLink.kt b/drive/drivelink-rename/domain/src/main/kotlin/me/proton/core/drive/drivelink/rename/domain/usecase/RenameLink.kt index 4717ac4b..e7795e9c 100644 --- a/drive/drivelink-rename/domain/src/main/kotlin/me/proton/core/drive/drivelink/rename/domain/usecase/RenameLink.kt +++ b/drive/drivelink-rename/domain/src/main/kotlin/me/proton/core/drive/drivelink/rename/domain/usecase/RenameLink.kt @@ -21,6 +21,7 @@ import me.proton.core.drive.base.domain.extension.toResult import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.crypto.domain.usecase.link.CreateRenameInfo import me.proton.core.drive.eventmanager.base.domain.usecase.UpdateEventAction +import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.Link import me.proton.core.drive.link.domain.entity.LinkId import me.proton.core.drive.link.domain.repository.LinkRepository @@ -48,6 +49,22 @@ class RenameLink @Inject constructor( } } + suspend operator fun invoke( + rootFolder: Link.Folder, + folderName: String, + shouldValidateName: Boolean = true, + ): Result = coRunCatching { + require(rootFolder.parentId == null) { "Use this method only for renaming a root folder" } + updateEventAction( + shareId = rootFolder.id.shareId, + ) { + linkRepository.renameLink( + linkId = rootFolder.id, + renameInfo = createRenameInfo(rootFolder, folderName, shouldValidateName).getOrThrow() + ).getOrThrow() + } + } + suspend operator fun invoke( linkId: LinkId, linkName: String, @@ -57,4 +74,16 @@ class RenameLink @Inject constructor( val parentFolder = getLink(parentFolderId).toResult().getOrThrow() invoke(parentFolder, link, linkName).getOrThrow() } + + suspend operator fun invoke( + rootFolderId: FolderId, + folderName: String, + shouldValidateName: Boolean = true, + ): Result = coRunCatching { + invoke( + rootFolder = getLink(rootFolderId).toResult().getOrThrow(), + folderName = folderName, + shouldValidateName = shouldValidateName, + ).getOrThrow() + } } diff --git a/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/Rename.kt b/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/Rename.kt index 1ddda27e..47951961 100644 --- a/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/Rename.kt +++ b/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/Rename.kt @@ -48,13 +48,26 @@ import me.proton.core.compose.flow.rememberFlowWithLifecycle import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing import me.proton.core.drive.base.presentation.component.OutlinedTextFieldWithError import me.proton.core.drive.drivelink.rename.presentation.selection.Selection +import me.proton.core.drive.drivelink.rename.presentation.viewmodel.RenameLinkViewModel +import me.proton.core.drive.drivelink.rename.presentation.viewmodel.RenameViewModel import me.proton.core.drive.i18n.R as I18N @Composable fun Rename( onDismiss: () -> Unit, ) { - val viewModel = hiltViewModel() + val viewModel = hiltViewModel() + Rename( + viewModel = viewModel, + onDismiss = onDismiss, + ) +} + +@Composable +fun Rename( + viewModel: RenameViewModel, + onDismiss: () -> Unit, +) { val renameViewState by rememberFlowWithLifecycle(flow = viewModel.viewState) .collectAsState(initial = null) val viewState = renameViewState @@ -175,4 +188,4 @@ private const val MaxLines = 2 object RenameScreenTestTag { const val screen = "rename screen" const val textField = "rename text field" -} \ No newline at end of file +} diff --git a/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/viewmodel/RenameLinkViewModel.kt b/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/viewmodel/RenameLinkViewModel.kt new file mode 100644 index 00000000..f40816c7 --- /dev/null +++ b/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/viewmodel/RenameLinkViewModel.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.drivelink.rename.presentation.viewmodel + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import me.proton.core.domain.arch.DataResult +import me.proton.core.drive.base.data.extension.log +import me.proton.core.drive.base.domain.extension.ellipsizeMiddle +import me.proton.core.drive.base.domain.extension.filterSuccessOrError +import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.base.domain.log.LogTag +import me.proton.core.drive.base.domain.log.logId +import me.proton.core.drive.base.domain.usecase.BroadcastMessages +import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink +import me.proton.core.drive.drivelink.domain.entity.DriveLink +import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted +import me.proton.core.drive.drivelink.rename.domain.usecase.RenameLink +import me.proton.core.drive.drivelink.rename.presentation.RenameEffect +import me.proton.core.drive.i18n.R +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import javax.inject.Inject + +@HiltViewModel +class RenameLinkViewModel @Inject constructor( + @ApplicationContext appContext: Context, + getDriveLink: GetDecryptedDriveLink, + savedStateHandle: SavedStateHandle, + private val renameLink: RenameLink, + private val broadcastMessages: BroadcastMessages, +) : RenameViewModel(appContext, savedStateHandle) { + private val unused = getDriveLink(linkId) + .filterSuccessOrError() + .map { driveLinkResult -> + name.emit( + savedStateHandle.get(KEY_FILENAME) + ?: driveLinkResult.name + ) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + override val titleResId: Int get() = when (linkId) { + is FileId -> R.string.link_rename_title_file + is FolderId -> R.string.link_rename_title_folder + } + + private val DataResult.name get() = + toResult() + .getOrNull() + ?.takeUnless { link -> link.isNameEncrypted } + ?.name + ?: "" + + override suspend fun doRenameFile(name: String) { + renameLink( + linkId = linkId, + linkName = name, + ) + .onFailure { error -> + error.log(LogTag.RENAME, "Cannot rename link: ${linkId.id.logId()}") + error.handle() + } + .onSuccess { + _renameEffect.emit(RenameEffect.Dismiss) + broadcastMessages( + userId = userId, + message = appContext.getString( + R.string.link_rename_successful, + name.ellipsizeMiddle(MAX_DISPLAY_FILENAME_LENGTH) + ) + ) + } + } +} diff --git a/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/viewmodel/RenameViewModel.kt b/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/viewmodel/RenameViewModel.kt new file mode 100644 index 00000000..ec4b88b6 --- /dev/null +++ b/drive/drivelink-rename/presentation/src/main/kotlin/me/proton/core/drive/drivelink/rename/presentation/viewmodel/RenameViewModel.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.drivelink.rename.presentation.viewmodel + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import me.proton.core.drive.base.data.extension.log +import me.proton.core.drive.base.data.extension.logDefaultMessage +import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL +import me.proton.core.drive.base.presentation.extension.require +import me.proton.core.drive.base.presentation.viewmodel.UserViewModel +import me.proton.core.drive.drivelink.rename.presentation.RenameEffect +import me.proton.core.drive.drivelink.rename.presentation.RenameViewEvent +import me.proton.core.drive.drivelink.rename.presentation.RenameViewState +import me.proton.core.drive.drivelink.rename.presentation.selection.NameWithSelection +import me.proton.core.drive.drivelink.rename.presentation.selection.Selection +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.usecase.ValidateLinkName.Invalid.Empty +import me.proton.core.drive.link.domain.usecase.ValidateLinkName.Invalid.ExceedsMaxLength +import me.proton.core.drive.link.domain.usecase.ValidateLinkName.Invalid.ForbiddenCharacters +import me.proton.core.drive.link.domain.usecase.ValidateLinkName.Invalid.Periods +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.i18n.R as I18N + +@Suppress("StaticFieldLeak") +abstract class RenameViewModel( + @ApplicationContext protected val appContext: Context, + protected val savedStateHandle: SavedStateHandle, +) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) { + protected val shareId = ShareId(userId, savedStateHandle.require(KEY_SHARE_ID)) + protected val fileId: String? = savedStateHandle.get(KEY_FILE_ID) + protected val linkId = if (fileId != null) { + FileId(shareId, fileId) + } else { + FolderId( + shareId = shareId, + id = savedStateHandle.require(KEY_FOLDER_ID), + ) + } + protected val _renameEffect = MutableSharedFlow() + private val isRenaming = MutableStateFlow(false) + protected abstract val titleResId: Int + val viewState: Flow = isRenaming.map { + RenameViewState( + titleResId = titleResId, + isRenaming = isRenaming.value + ) + } + private val reselectionTrigger = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + protected val name: MutableSharedFlow = MutableSharedFlow(1) + val nameWithSelection: Flow = combine( + name, + reselectionTrigger, + ) { filename, _ -> + NameWithSelection( + name = filename, + selection = filename.selection(linkId is FolderId) + ) + } + val viewEvent = object : RenameViewEvent { + override val onRename = { name: String -> onRename(name) } + override val onNameChanged: (String) -> Unit = { name: String -> onChanged(name) } + } + val renameEffect: Flow = _renameEffect.asSharedFlow() + + protected abstract suspend fun doRenameFile(name: String) + + private fun onRename(name: String) { + clearInputError() + renameFile(name) + } + + private fun renameFile(name: String) { + viewModelScope.launch { + isRenaming.emit(true) + doRenameFile(name) + isRenaming.emit(false) + } + } + + private fun onChanged(name: String) { + savedStateHandle.set(KEY_FILENAME, name) + clearInputError() + } + + private fun String.selection(isFolder: Boolean): Selection { + val extensionIndex = lastIndexOf('.') + return if (!isFolder && extensionIndex > 0) Selection(0, extensionIndex) else Selection(0, length) + } + + protected suspend fun Throwable.handle() { + with (_renameEffect) { + emit( + RenameEffect.ShowInputError( + when (this@handle) { + Empty -> appContext.getString(I18N.string.link_rename_error_name_is_blank) + is ExceedsMaxLength -> + appContext.getString( + I18N.string.link_rename_error_name_too_long, + this@handle.maxLength + ) + + ForbiddenCharacters -> appContext.getString( + I18N.string.link_rename_error_name_with_forbidden_characters + ) + + Periods -> appContext.getString(I18N.string.link_rename_error_name_periods) + else -> logDefaultMessage( + context = appContext, + tag = VIEW_MODEL, + unhandled = appContext.getString(I18N.string.link_rename_error_general), + ) + } + ) + ) + reselectionTrigger.tryEmit(Unit) + } + } + + private fun clearInputError() = viewModelScope.launch { + _renameEffect.emit(RenameEffect.ClearInputError) + } + + companion object { + const val KEY_SHARE_ID = "shareId" + const val KEY_FILE_ID = "fileId" + const val KEY_FOLDER_ID = "folderId" + const val KEY_FILENAME = "key.filename" + const val MAX_DISPLAY_FILENAME_LENGTH = 50 + } +} diff --git a/drive/drivelink-shared/domain/src/main/kotlin/me/proton/core/drive/drivelink/shared/domain/usecase/DeleteShareUrl.kt b/drive/drivelink-shared/domain/src/main/kotlin/me/proton/core/drive/drivelink/shared/domain/usecase/DeleteShareUrl.kt index 796efd20..e28027ea 100644 --- a/drive/drivelink-shared/domain/src/main/kotlin/me/proton/core/drive/drivelink/shared/domain/usecase/DeleteShareUrl.kt +++ b/drive/drivelink-shared/domain/src/main/kotlin/me/proton/core/drive/drivelink/shared/domain/usecase/DeleteShareUrl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Proton AG. + * Copyright (c) 2022-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -32,7 +32,7 @@ class DeleteShareUrl @Inject constructor( suspend operator fun invoke(linkId: LinkId): Result = coRunCatching { updateEventAction(linkId.shareId) { deleteShareUrl( - shareUrlId = requireNotNull(getLink(linkId).toResult().getOrThrow().shareUrlId) { + shareUrlId = requireNotNull(getLink(linkId).toResult().getOrThrow().sharingDetails?.shareUrlId) { "ShareUrlId not found" }, ).getOrThrow() diff --git a/drive/drivelink-shared/presentation/src/main/kotlin/me/proton/core/drive/drivelink/shared/presentation/viewmodel/SharedDriveLinkViewModel.kt b/drive/drivelink-shared/presentation/src/main/kotlin/me/proton/core/drive/drivelink/shared/presentation/viewmodel/SharedDriveLinkViewModel.kt index fd9887e7..c69cb7e9 100644 --- a/drive/drivelink-shared/presentation/src/main/kotlin/me/proton/core/drive/drivelink/shared/presentation/viewmodel/SharedDriveLinkViewModel.kt +++ b/drive/drivelink-shared/presentation/src/main/kotlin/me/proton/core/drive/drivelink/shared/presentation/viewmodel/SharedDriveLinkViewModel.kt @@ -116,6 +116,7 @@ class SharedDriveLinkViewModel @Inject constructor( when (sharedDriveLink) { is DataResult.Processing -> LoadingViewState.Loading(driveLink.toLoadingMessage()) is DataResult.Error -> { + sharedDriveLink.cause?.log(SHARE) if (sharedDriveLink.cause is NoSuchElementException) { LoadingViewState.Error.NonRetryable( appContext.getString(I18N.string.shared_link_error_message_not_found), diff --git a/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorter.kt b/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorter.kt index 3f4902d3..eb4acad8 100644 --- a/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorter.kt +++ b/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/LocaleNameSorter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -26,16 +26,9 @@ import java.text.Collator import android.icu.text.Collator as IcuCollator import android.icu.text.RuleBasedCollator as IcuRuleBasedCollator -data object LocaleNameSorter : Sorter() { - - private val comparator: Comparator get() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - (IcuCollator.getInstance() as IcuRuleBasedCollator).apply { - numericCollation = true - } - } else { - Collator.getInstance() - } +data class LocaleNameSorter internal constructor( + private val comparator: Comparator +) : Sorter() { override fun sort(driveLinks: List, direction: Direction): List = driveLinks.sortedWith( @@ -48,4 +41,15 @@ data object LocaleNameSorter : Sorter() { } } ) + + companion object { + operator fun invoke(): LocaleNameSorter = + LocaleNameSorter(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + (IcuCollator.getInstance() as IcuRuleBasedCollator).apply { + numericCollation = true + } + } else { + Collator.getInstance() + }) + } } diff --git a/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Sorter.kt b/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Sorter.kt index 2dab3f6d..fa01db6f 100644 --- a/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Sorter.kt +++ b/drive/drivelink-sorting/domain/src/main/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Sorter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -28,7 +28,7 @@ sealed class Sorter { companion object Factory { operator fun get(by: By): Sorter = when (by) { - By.NAME -> LocaleNameSorter + By.NAME -> LocaleNameSorter() By.LAST_MODIFIED -> LastModifiedSorter By.SIZE -> SizeSorter By.TYPE -> TypeSorter diff --git a/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Files.kt b/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Files.kt index 2e9572dd..b8fdd863 100644 --- a/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Files.kt +++ b/drive/drivelink-sorting/domain/src/test/kotlin/me/proton/core/drive/drivelink/sorting/domain/sorter/Files.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -18,40 +18,166 @@ package me.proton.core.drive.drivelink.sorting.domain.sorter -import io.mockk.every -import io.mockk.mockk import me.proton.core.crypto.common.pgp.VerificationStatus -import me.proton.core.drive.base.domain.entity.Bytes +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.entity.Attributes import me.proton.core.drive.base.domain.entity.CryptoProperty +import me.proton.core.drive.base.domain.entity.Permissions import me.proton.core.drive.base.domain.entity.TimestampS +import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.drivelink.domain.entity.DriveLink +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.entity.Link +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.volume.domain.entity.VolumeId -fun file(name: String, type: String = "", lastModified: Long = 0L, size: Long = 0L) = - mockk() - .apply(name, type, lastModified, size) +fun file( + name: String, + type: String = "", + lastModified: Long = 0L, + size: Long = 0L, +) = driveLinkFile( + name = name, + type = type, + lastModified = lastModified, + size = size, +) -fun cryptedFile(name: String, type: String, lastModified: Long, size: Long) = - mockk() - .apply(name, type, lastModified, size) - .apply { every { cryptoName } returns CryptoProperty.Encrypted(name) } - -fun folder(name: String, lastModified: Long, size: Long) = mockk() - .apply(name, "Folder", lastModified, size) - -fun cryptedFolder(name: String, lastModified: Long, size: Long) = - mockk() - .apply(name, "Folder", lastModified, size) - .apply { every { cryptoName } returns CryptoProperty.Encrypted(name) } - -fun T.apply( +fun cryptedFile( name: String, type: String, - lastModifiedS: Long, - sizeB: Long -) = apply { - every { cryptoName } returns CryptoProperty.Decrypted(name, VerificationStatus.Success) - every { this@apply.name } returns name - every { mimeType } returns type - every { lastModified } returns TimestampS(lastModifiedS) - every { size } returns Bytes(sizeB) -} \ No newline at end of file + lastModified: Long, + size: Long, +) = driveLinkFile( + name = name, + type = type, + lastModified = lastModified, + size = size, + cryptoName = CryptoProperty.Encrypted(name) +) + +fun folder( + name: String, + lastModified: Long, + size: Long, +) = driveLinkFolder( + name = name, + type = "Folder", + lastModified = lastModified, + size = size, +) + +fun cryptedFolder( + name: String, + lastModified: Long, + size: Long, +) = driveLinkFolder( + name = name, + type = "Folder", + lastModified = lastModified, + size = size, + cryptoName = CryptoProperty.Encrypted(name), +) + +private fun driveLinkFile( + name: String, + type: String, + lastModified: Long, + size: Long, + cryptoName: CryptoProperty = CryptoProperty.Decrypted( + name, + VerificationStatus.Success + ), +) = DriveLink.File( + link = Link.File( + id = FileId(ShareId(UserId("USER_ID"), "SHARE_ID"), "ID"), + parentId = FolderId(ShareId(UserId("USER_ID"), "SHARE_ID"), "PARENT_ID"), + activeRevisionId = "revision", + size = size.bytes, + lastModified = TimestampS(lastModified), + mimeType = type, + numberOfAccesses = 2, + isShared = true, + uploadedBy = "m4@proton.black", + hasThumbnail = false, + name = name, + key = "key", + passphrase = "passphrase", + passphraseSignature = "signature", + contentKeyPacket = "contentKeyPacket", + contentKeyPacketSignature = null, + isFavorite = false, + attributes = Attributes(0), + permissions = Permissions(0), + state = Link.State.ACTIVE, + nameSignatureEmail = "", + hash = "", + expirationTime = null, + nodeKey = "", + nodePassphrase = "", + nodePassphraseSignature = "", + signatureAddress = "", + creationTime = TimestampS(0), + trashedTime = null, + shareUrlExpirationTime = null, + xAttr = null, + sharingDetails = null, + ), + volumeId = VolumeId("VOLUME_ID"), + isMarkedAsOffline = false, + isAnyAncestorMarkedAsOffline = false, + downloadState = null, + trashState = null, + cryptoName = cryptoName, +) + + +private fun driveLinkFolder( + name: String, + type: String, + lastModified: Long, + size: Long, + cryptoName: CryptoProperty = CryptoProperty.Decrypted( + name, + VerificationStatus.Success + ), +) = DriveLink.Folder( + link = Link.Folder( + id = FolderId(ShareId(UserId("USER_ID"), "SHARE_ID"), "ID"), + parentId = FolderId(ShareId(UserId("USER_ID"), "SHARE_ID"), "PARENT_ID"), + size = size.bytes, + lastModified = TimestampS(lastModified), + mimeType = type, + numberOfAccesses = 2, + isShared = true, + uploadedBy = "m4@proton.black", + name = name, + key = "key", + passphrase = "passphrase", + passphraseSignature = "signature", + isFavorite = false, + attributes = Attributes(0), + permissions = Permissions(0), + state = Link.State.ACTIVE, + nameSignatureEmail = "", + hash = "", + expirationTime = null, + nodeKey = "", + nodePassphrase = "", + nodePassphraseSignature = "", + signatureAddress = "", + creationTime = TimestampS(0), + trashedTime = null, + shareUrlExpirationTime = null, + xAttr = null, + sharingDetails = null, + nodeHashKey = "", + ), + volumeId = VolumeId("VOLUME_ID"), + isMarkedAsOffline = false, + isAnyAncestorMarkedAsOffline = false, + downloadState = null, + trashState = null, + cryptoName = cryptoName, +) diff --git a/drive/drivelink/data/src/test/kotlin/me/proton/core/drive/drivelink/data/repository/DriveLinkRepositoryImplTest.kt b/drive/drivelink/data/src/test/kotlin/me/proton/core/drive/drivelink/data/repository/DriveLinkRepositoryImplTest.kt index ef35f4cd..74438239 100644 --- a/drive/drivelink/data/src/test/kotlin/me/proton/core/drive/drivelink/data/repository/DriveLinkRepositoryImplTest.kt +++ b/drive/drivelink/data/src/test/kotlin/me/proton/core/drive/drivelink/data/repository/DriveLinkRepositoryImplTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -25,7 +25,7 @@ import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.block import me.proton.core.drive.db.test.download import me.proton.core.drive.db.test.file -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.drivelink.domain.repository.DriveLinkRepository import org.junit.Assert.assertEquals import org.junit.Before @@ -50,14 +50,14 @@ class DriveLinkRepositoryImplTest { @Test fun `Given empty folder when count then should count zero`() = runTest { - val folderId = database.myDrive { } + val folderId = database.myFiles { } val count = repository.getDriveLinksCount(folderId).first() assertEquals(0, count) } @Test fun `Given folder with one file when count then should count one`() = runTest { - val folderId = database.myDrive { + val folderId = database.myFiles { file("file") } val count = repository.getDriveLinksCount(folderId).first() @@ -66,7 +66,7 @@ class DriveLinkRepositoryImplTest { @Test fun `Given folder with a file of two blocks when count then should count one`() = runTest { - val folderId = database.myDrive { + val folderId = database.myFiles { file("file") { download { block(0) @@ -77,4 +77,4 @@ class DriveLinkRepositoryImplTest { val count = repository.getDriveLinksCount(folderId).first() assertEquals(1, count) } -} \ No newline at end of file +} diff --git a/drive/event-manager/data/build.gradle.kts b/drive/event-manager/data/build.gradle.kts index 3c268927..238c511a 100644 --- a/drive/event-manager/data/build.gradle.kts +++ b/drive/event-manager/data/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -34,8 +34,7 @@ driveModule( implementation(project(":drive:link:data")) implementation(project(":drive:share")) implementation(libs.core.dataRoom) - implementation(libs.core.presentation) // AppLifecycleProvider - implementation(libs.core.userSettings) + implementation(libs.core.userSettings.data) implementation(libs.core.user.data) implementation(libs.core.notification.data) implementation(libs.core.push.data) diff --git a/drive/event-manager/data/src/main/kotlin/me/proton/core/drive/eventmanager/LinkEventListener.kt b/drive/event-manager/data/src/main/kotlin/me/proton/core/drive/eventmanager/LinkEventListener.kt index e616b851..0cd9574b 100644 --- a/drive/event-manager/data/src/main/kotlin/me/proton/core/drive/eventmanager/LinkEventListener.kt +++ b/drive/event-manager/data/src/main/kotlin/me/proton/core/drive/eventmanager/LinkEventListener.kt @@ -69,8 +69,8 @@ class LinkEventListener @Inject constructor( private val findLinkIds: FindLinkIds, private val getShare: GetShare, ) : EventListener() { - internal var onFailure: (Throwable) -> Unit = { error -> - error.log(LogTag.EVENTS, "Cannot parse event") + internal var onFailure: (Throwable, String) -> Unit = { error, body -> + error.log(LogTag.EVENTS, "Cannot parse event from response: $body") } // User -> Share -> Link override val order: Int = 3 @@ -115,7 +115,7 @@ class LinkEventListener @Inject constructor( is UnknownLinksEvent -> listOf() } } - }.onFailure(onFailure).getOrNull() + }.onFailure { error -> onFailure(error, response.body) }.getOrNull() } private fun WithLinkDto.getEvent(volumeId: VolumeId, shareId: ShareId): Event { @@ -195,7 +195,7 @@ class LinkEventListener @Inject constructor( subclass(CreateLinksEvent::class) subclass(UpdateLinksEvent::class) subclass(UpdateMetadataLinksEvent::class) - default { UnknownLinksEvent.serializer() } + defaultDeserializer { UnknownLinksEvent.serializer() } } } } diff --git a/drive/event-manager/data/src/test/kotlin/me/proton/core/drive/eventmanager/LinkEventListenerTest.kt b/drive/event-manager/data/src/test/kotlin/me/proton/core/drive/eventmanager/LinkEventListenerTest.kt index f22aca13..8ab9f53b 100644 --- a/drive/event-manager/data/src/test/kotlin/me/proton/core/drive/eventmanager/LinkEventListenerTest.kt +++ b/drive/event-manager/data/src/test/kotlin/me/proton/core/drive/eventmanager/LinkEventListenerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -75,7 +75,7 @@ class LinkEventListenerTest { findLinkIds = findLinkIds, getShare = getShare, ).apply { - onFailure = { error -> throw error } + onFailure = { error, _ -> throw error } } private val userId = UserId("user-id") @@ -93,7 +93,7 @@ class LinkEventListenerTest { ResponseSource.Local, Share( id = shareId, volumeId = volumeId, - rootLinkId = "root-id", + rootLinkId = "main-root-id", addressId = null, isMain = true, isLocked = false, @@ -113,7 +113,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = shareConfig, metadata = shareConfig.eventMetadata, - response = shareConfig.eventsResponse( + response = eventsResponse( CreateLinksEvent( linkDto, shareId.id, null ), @@ -139,7 +139,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = shareConfig, metadata = shareConfig.eventMetadata, - response = shareConfig.eventsResponse( + response = eventsResponse( UpdateLinksEvent( linkDto, shareId.id, null ), @@ -165,7 +165,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = shareConfig, metadata = shareConfig.eventMetadata, - response = shareConfig.eventsResponse( + response = eventsResponse( UpdateMetadataLinksEvent( linkDto, shareId.id, null ), @@ -190,7 +190,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = shareConfig, metadata = shareConfig.eventMetadata, - response = shareConfig.eventsResponse( + response = eventsResponse( DeleteLinksEvent(DeleteLinksEvent.Link("link-id"), null), ), ) @@ -215,7 +215,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = volumeConfig, metadata = volumeConfig.eventMetadata, - response = volumeConfig.eventsResponse( + response = eventsResponse( CreateLinksEvent( linkDto, shareId.id, null ), @@ -241,7 +241,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = volumeConfig, metadata = volumeConfig.eventMetadata, - response = volumeConfig.eventsResponse( + response = eventsResponse( UpdateLinksEvent( linkDto, shareId.id, null ), @@ -267,7 +267,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = volumeConfig, metadata = volumeConfig.eventMetadata, - response = volumeConfig.eventsResponse( + response = eventsResponse( UpdateMetadataLinksEvent( linkDto, shareId.id, null ), @@ -297,7 +297,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = volumeConfig, metadata = volumeConfig.eventMetadata, - response = volumeConfig.eventsResponse( + response = eventsResponse( DeleteLinksEvent(DeleteLinksEvent.Link("link-id"), null), ), ) @@ -315,7 +315,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = volumeConfig, metadata = volumeConfig.eventMetadata, - response = volumeConfig.eventsResponse( + response = eventsResponse( DeleteLinksEvent(DeleteLinksEvent.Link("link-id"), null), ), ) @@ -329,7 +329,7 @@ class LinkEventListenerTest { listener.notifyEvents( config = volumeConfig, metadata = volumeConfig.eventMetadata, - response = volumeConfig.eventsResponse( + response = eventsResponse( DeleteLinksEvent(DeleteLinksEvent.Link("link-id"), shareId.id), ), ) @@ -348,7 +348,7 @@ class LinkEventListenerTest { coVerify { onResetAllEvent(userId, volumeId) } } - private fun EventManagerConfig.Drive.Share.eventsResponse(vararg events: LinksEvent) = + private fun eventsResponse(vararg events: LinksEvent) = EventsResponse( LinkEventListener.json.encodeToString(Events(listOf(*events))) ) @@ -362,11 +362,6 @@ class LinkEventListenerTest { refresh = RefreshType.Mail ) - private fun EventManagerConfig.Drive.Volume.eventsResponse(vararg events: LinksEvent) = - EventsResponse( - LinkEventListener.json.encodeToString(Events(listOf(*events))) - ) - private val EventManagerConfig.Drive.Volume.eventMetadata get() = EventMetadata( userId = userId, diff --git a/drive/event-manager/domain/build.gradle.kts b/drive/event-manager/domain/build.gradle.kts index a9d32332..e62fcea8 100644 --- a/drive/event-manager/domain/build.gradle.kts +++ b/drive/event-manager/domain/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -38,9 +38,5 @@ driveModule( api(project(":drive:share:domain")) api(project(":drive:share-crypto:domain")) api(project(":drive:share-url:base:domain")) - api(libs.core.eventManager) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.core.account) - implementation(libs.core.accountManager) - implementation(libs.core.presentation) // AppLifecycleProvider + api(libs.core.eventManager.domain) } diff --git a/drive/event-manager/domain/src/main/kotlin/me/proton/core/drive/eventmanager/usecase/HandleOnDeleteEvent.kt b/drive/event-manager/domain/src/main/kotlin/me/proton/core/drive/eventmanager/usecase/HandleOnDeleteEvent.kt index 573f43dd..e44b325e 100644 --- a/drive/event-manager/domain/src/main/kotlin/me/proton/core/drive/eventmanager/usecase/HandleOnDeleteEvent.kt +++ b/drive/event-manager/domain/src/main/kotlin/me/proton/core/drive/eventmanager/usecase/HandleOnDeleteEvent.kt @@ -19,6 +19,7 @@ package me.proton.core.drive.eventmanager.usecase import kotlinx.coroutines.flow.first +import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.drivelink.domain.usecase.GetDriveLinks import me.proton.core.drive.drivelink.download.domain.usecase.CancelDownload @@ -44,25 +45,31 @@ class HandleOnDeleteEvent @Inject constructor( private val deleteLocalVolumes: DeleteLocalVolumes, ) { - suspend operator fun invoke(linkIds: List) { + suspend operator fun invoke(linkIds: List, stopOnFailure: Boolean = false): Result = coRunCatching { if (linkIds.isEmpty()) { - return + return@coRunCatching } getDriveLinks(linkIds).first() .forEach { driveLink -> cancelDownload(driveLink) when (driveLink) { - is DriveLink.File -> deleteLocalContent(driveLink) + is DriveLink.File -> deleteLocalContent(driveLink).getOrNullOrThrowIf(stopOnFailure) is DriveLink.Folder -> getChildren(driveLink.id, false) .onSuccess { children -> - invoke(children.ids) + invoke(children.ids, stopOnFailure).getOrNullOrThrowIf(stopOnFailure) } + .getOrNullOrThrowIf(stopOnFailure) } - } - deleteLocalVolumes(linkIds) - deleteLocalShares(linkIds) - deleteLinks(linkIds) - deletePhotoListings(linkIds) + deleteLocalVolumes(linkIds).getOrNullOrThrowIf(stopOnFailure) + deleteLocalShares(linkIds).getOrNullOrThrowIf(stopOnFailure) + deleteLinks(linkIds).getOrNullOrThrowIf(stopOnFailure) + deletePhotoListings(linkIds).getOrNullOrThrowIf(stopOnFailure) + } + + private fun Result.getOrNullOrThrowIf(shouldThrow: Boolean): T? = if (shouldThrow) { + getOrThrow() + } else { + getOrNull() } } diff --git a/drive/event-manager/presentation/build.gradle.kts b/drive/event-manager/presentation/build.gradle.kts new file mode 100644 index 00000000..ea9d3c2f --- /dev/null +++ b/drive/event-manager/presentation/build.gradle.kts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +plugins { + id("com.android.library") +} + +android { + namespace = "me.proton.core.drive.eventmanager.presentation" +} + +driveModule( + hilt = true, +) { + api(project(":drive:event-manager:base:domain")) + api(project(":drive:share:domain")) + api(libs.core.eventManager.domain) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.core.account.domain) + implementation(libs.core.accountManager.domain) + implementation(libs.core.accountManager.presentation) + implementation(libs.core.presentation) // AppLifecycleProvider +} diff --git a/drive/event-manager/presentation/src/main/kotlin/me/proton/core/drive/eventmanager/presentation/DriveEventManager.kt b/drive/event-manager/presentation/src/main/kotlin/me/proton/core/drive/eventmanager/presentation/DriveEventManager.kt new file mode 100644 index 00000000..89630d82 --- /dev/null +++ b/drive/event-manager/presentation/src/main/kotlin/me/proton/core/drive/eventmanager/presentation/DriveEventManager.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2021-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.eventmanager.presentation + +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import me.proton.core.account.domain.entity.Account +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.accountmanager.presentation.observe +import me.proton.core.accountmanager.presentation.onAccountDisabled +import me.proton.core.accountmanager.presentation.onAccountReady +import me.proton.core.domain.arch.mapSuccessValueOrNull +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.filterSuccessOrError +import me.proton.core.drive.share.domain.entity.Share +import me.proton.core.drive.share.domain.usecase.GetShares +import me.proton.core.drive.volume.domain.entity.VolumeId +import me.proton.core.drive.volume.domain.entity.isActive +import me.proton.core.drive.volume.domain.usecase.GetVolumes +import me.proton.core.eventmanager.domain.EventManagerConfig +import me.proton.core.eventmanager.domain.EventManagerProvider +import me.proton.core.presentation.app.AppLifecycleProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DriveEventManager @Inject constructor( + private val appLifecycleProvider: AppLifecycleProvider, + private val eventManagerProvider: EventManagerProvider, + private val accountManager: AccountManager, + private val getShares: GetShares, + private val getVolumes: GetVolumes, +) { + private val scopes = mutableMapOf() + private val startedVolumeIds = mutableSetOf() + + fun start() { + accountManager.observe(appLifecycleProvider.lifecycle, minActiveState = Lifecycle.State.CREATED) + .onAccountReady { account -> + eventManagerProvider.get(EventManagerConfig.Core(account.userId)).start() + account.startListeningToVolumesEvents() + } + .onAccountDisabled { account -> + scopes.remove(account.userId)?.cancel() + eventManagerProvider.get(EventManagerConfig.Core(account.userId)).stop() + account.stopListeningToVolumesEvents() + } + } + @Suppress("unused") + private fun getAllShareIds(userId: UserId) = + getShares(userId, Share.Type.MAIN) + .filterSuccessOrError() + .mapSuccessValueOrNull() + .filterNotNull() + .map { shares -> + shares + .filter { share -> share.isMain && share.isLocked.not() } + .map { share -> share.id } + } + + private fun getAllVolumeIds(userId: UserId) = + getVolumes(userId) + .filterSuccessOrError() + .mapSuccessValueOrNull() + .filterNotNull() + .map { volumes -> + volumes + .filter { volume -> volume.isActive } + .map { volume -> volume.id } + } + + private fun Account.startListeningToVolumesEvents() { + getAllVolumeIds(userId).onEach { volumeIds -> + val newVolumeIds = volumeIds.toSet().subtract(startedVolumeIds) + newVolumeIds.forEach { volumeId -> + eventManagerProvider.get(EventManagerConfig.Drive.Volume(userId, volumeId.id)).start() + startedVolumeIds.add(volumeId) + } + val removedVolumeIds = startedVolumeIds.subtract(volumeIds.toSet()) + removedVolumeIds.forEach { volumeId -> + eventManagerProvider.get(EventManagerConfig.Drive.Volume(userId, volumeId.id)).stop() + startedVolumeIds.remove(volumeId) + } + }.launchIn(scopes.getOrPut(userId) { + CoroutineScope(Dispatchers.IO + Job()) + }) + } + + private suspend fun Account.stopListeningToVolumesEvents() { + startedVolumeIds.forEach { volumeId -> + eventManagerProvider.get(EventManagerConfig.Drive.Volume(userId, volumeId.id)).stop() + } + startedVolumeIds.clear() + } +} diff --git a/drive/feature-flag/domain/src/main/kotlin/me/proton/core/drive/feature/flag/domain/usecase/GetFeatureFlag.kt b/drive/feature-flag/domain/src/main/kotlin/me/proton/core/drive/feature/flag/domain/usecase/GetFeatureFlag.kt index f976b03b..f1088bbd 100644 --- a/drive/feature-flag/domain/src/main/kotlin/me/proton/core/drive/feature/flag/domain/usecase/GetFeatureFlag.kt +++ b/drive/feature-flag/domain/src/main/kotlin/me/proton/core/drive/feature/flag/domain/usecase/GetFeatureFlag.kt @@ -18,12 +18,12 @@ package me.proton.core.drive.feature.flag.domain.usecase +import me.proton.core.drive.base.domain.extension.isOlderThen import me.proton.core.drive.base.domain.log.LogTag import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId -import me.proton.core.drive.feature.flag.domain.extension.isOlderThen import me.proton.core.drive.feature.flag.domain.repository.FeatureFlagRepository import me.proton.core.util.kotlin.CoreLogger import javax.inject.Inject diff --git a/drive/feature-flag/domain/src/main/kotlin/me/proton/core/drive/feature/flag/domain/usecase/RefreshFeatureFlags.kt b/drive/feature-flag/domain/src/main/kotlin/me/proton/core/drive/feature/flag/domain/usecase/RefreshFeatureFlags.kt index 291ea6a4..b749ef49 100644 --- a/drive/feature-flag/domain/src/main/kotlin/me/proton/core/drive/feature/flag/domain/usecase/RefreshFeatureFlags.kt +++ b/drive/feature-flag/domain/src/main/kotlin/me/proton/core/drive/feature/flag/domain/usecase/RefreshFeatureFlags.kt @@ -19,10 +19,10 @@ package me.proton.core.drive.feature.flag.domain.usecase import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.isOlderThen import me.proton.core.drive.base.domain.log.LogTag import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.domain.util.coRunCatching -import me.proton.core.drive.feature.flag.domain.extension.isOlderThen import me.proton.core.drive.feature.flag.domain.repository.FeatureFlagRepository import me.proton.core.drive.feature.flag.domain.repository.FeatureFlagRepository.RefreshId import me.proton.core.util.kotlin.CoreLogger diff --git a/drive/file-info/src/main/java/me/proton/core/drive/file/info/presentation/FileInfoContent.kt b/drive/file-info/src/main/java/me/proton/core/drive/file/info/presentation/FileInfoContent.kt index 9f378352..fcb4acde 100644 --- a/drive/file-info/src/main/java/me/proton/core/drive/file/info/presentation/FileInfoContent.kt +++ b/drive/file-info/src/main/java/me/proton/core/drive/file/info/presentation/FileInfoContent.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -62,9 +62,7 @@ import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.entity.toFileTypeCategory import me.proton.core.drive.base.presentation.component.EncryptedItem import me.proton.core.drive.base.presentation.component.LARGE_HEIGHT -import me.proton.core.drive.base.presentation.extension.asHumanReadableString import me.proton.core.drive.base.presentation.extension.labelResId -import me.proton.core.drive.base.presentation.extension.toReadableDate import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted import me.proton.core.drive.file.info.presentation.entity.Item @@ -237,7 +235,7 @@ private fun PreviewFileInfoContent() { trashedTime = null, shareUrlExpirationTime = null, xAttr = null, - shareUrlId = null, + sharingDetails = null, ), volumeId = VolumeId("VOLUME_ID"), isMarkedAsOffline = false, diff --git a/drive/files-list/src/androidTest/kotlin/me/proton/core/drive/files/presentation/component/FilesTestUtils.kt b/drive/files-list/src/androidTest/kotlin/me/proton/core/drive/files/presentation/component/FilesTestUtils.kt index 481c5835..114d9a23 100644 --- a/drive/files-list/src/androidTest/kotlin/me/proton/core/drive/files/presentation/component/FilesTestUtils.kt +++ b/drive/files-list/src/androidTest/kotlin/me/proton/core/drive/files/presentation/component/FilesTestUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -64,7 +64,7 @@ val BASE_FILE_LINK = Link.File( trashedTime = null, shareUrlExpirationTime = null, xAttr = null, - shareUrlId = null, + sharingDetails = null, ) val BASE_FOLDER_LINK = Link.Folder( @@ -96,7 +96,7 @@ val BASE_FOLDER_LINK = Link.Folder( trashedTime = null, shareUrlExpirationTime = null, xAttr = null, - shareUrlId = null, + sharingDetails = null, ) fun Link.toDriveLink() = when (this) { diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/Files.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/Files.kt index eab5d30b..00cf0c78 100644 --- a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/Files.kt +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/Files.kt @@ -62,9 +62,9 @@ import me.proton.core.drive.files.presentation.component.files.FilesListHeader import me.proton.core.drive.files.presentation.component.files.FilesListItem import me.proton.core.drive.files.presentation.component.files.FilesListLoading import me.proton.core.drive.files.presentation.component.files.FilesSectionHeader +import me.proton.core.drive.files.presentation.event.FilesViewEvent import me.proton.core.drive.files.presentation.extension.LayoutType import me.proton.core.drive.files.presentation.extension.driveLinkSemantics -import me.proton.core.drive.files.presentation.event.FilesViewEvent import me.proton.core.drive.files.presentation.state.FilesViewState import me.proton.core.drive.files.presentation.state.ListContentState import me.proton.core.drive.files.presentation.state.ListEffect @@ -115,6 +115,7 @@ fun TopAppBar( navigationIcon = if (viewState.navigationIconResId != 0) { painterResource(id = viewState.navigationIconResId) } else null, + notificationDotVisible = viewState.notificationDotVisible, onNavigationIcon = viewEvent.onTopAppBarNavigation, title = viewState.title ?: stringResource(id = viewState.titleResId), isTitleEncrypted = viewState.isTitleEncrypted, diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesList.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesList.kt index 0ed17c9b..efe8e874 100644 --- a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesList.kt +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/FilesList.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -20,7 +20,6 @@ package me.proton.core.drive.files.presentation.component.files import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -40,9 +39,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.paging.compose.itemKey @@ -57,9 +54,7 @@ import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing import me.proton.core.compose.theme.ProtonDimens.MediumSpacing import me.proton.core.compose.theme.ProtonDimens.SmallSpacing import me.proton.core.compose.theme.ProtonTheme -import me.proton.core.compose.theme.defaultWeak -import me.proton.core.compose.theme.headline -import me.proton.core.drive.base.presentation.component.ListEmpty +import me.proton.core.drive.base.presentation.component.IllustratedMessage import me.proton.core.drive.base.presentation.extension.conditional import me.proton.core.drive.base.presentation.extension.isLandscape import me.proton.core.drive.base.presentation.extension.isPortrait @@ -93,7 +88,7 @@ fun FilesListEmpty( Column( modifier = modifier.fillMaxSize(), ) { - ListEmpty( + IllustratedMessage( imageResId = imageResId, titleResId = titleResId, descriptionResId = descriptionResId, diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/Previews.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/Previews.kt index c28f165e..27ff7832 100644 --- a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/Previews.kt +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/component/files/Previews.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -62,7 +62,7 @@ internal val PREVIEW_LINK = Link.File( creationTime = TimestampS(0), trashedTime = null, shareUrlExpirationTime = null, - shareUrlId = null, + sharingDetails = null, ) internal val PREVIEW_DRIVELINK = DriveLink.File( link = PREVIEW_LINK, @@ -71,4 +71,4 @@ internal val PREVIEW_DRIVELINK = DriveLink.File( isAnyAncestorMarkedAsOffline = false, downloadState = null, trashState = null, -) \ No newline at end of file +) diff --git a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/state/FilesViewState.kt b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/state/FilesViewState.kt index 3f943d3a..2af7bc1a 100644 --- a/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/state/FilesViewState.kt +++ b/drive/files-list/src/main/kotlin/me/proton/core/drive/files/presentation/state/FilesViewState.kt @@ -49,6 +49,7 @@ data class FilesViewState( val selected: Flow> = emptyFlow(), val topBarActions: Flow> = emptyFlow(), val isDriveLinkMoreOptionsEnabled: Boolean = true, + val notificationDotVisible: Boolean = false, ) { companion object { diff --git a/drive/folder/domain/build.gradle.kts b/drive/folder/domain/build.gradle.kts index a143309b..386e3a72 100644 --- a/drive/folder/domain/build.gradle.kts +++ b/drive/folder/domain/build.gradle.kts @@ -25,6 +25,7 @@ android { driveModule( hilt = true, + socialTest = true, ) { api(project(":drive:link:domain")) } diff --git a/drive/folder/domain/src/main/kotlin/me/proton/core/drive/folder/domain/usecase/HasAnyCachedFolderChildren.kt b/drive/folder/domain/src/main/kotlin/me/proton/core/drive/folder/domain/usecase/HasAnyCachedFolderChildren.kt new file mode 100644 index 00000000..b96017ad --- /dev/null +++ b/drive/folder/domain/src/main/kotlin/me/proton/core/drive/folder/domain/usecase/HasAnyCachedFolderChildren.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.folder.domain.usecase + +import kotlinx.coroutines.flow.flowOf +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.link.domain.repository.LinkRepository +import me.proton.core.drive.share.domain.entity.Share +import me.proton.core.drive.share.domain.usecase.GetShares +import javax.inject.Inject + +class HasAnyCachedFolderChildren @Inject constructor( + private val getShares: GetShares, + private val hasFolderChildren: HasFolderChildren, + private val linkRepository: LinkRepository, +) { + suspend operator fun invoke(userId: UserId, filesOnly: Boolean = false): Boolean { + val shares = listOfNotNull( + getShares(userId, Share.Type.MAIN, flowOf(false)).toResult().getOrNull(), + getShares(userId, Share.Type.PHOTO, flowOf(false)).toResult().getOrNull(), + getShares(userId, Share.Type.DEVICE, flowOf(false)).toResult().getOrNull(), + ) + return if (filesOnly) { + shares + .map { shares -> + shares.map { share -> share.id } + } + .any { shareIds -> shareIds.any { shareId -> linkRepository.hasAnyFileLink(shareId) } } + } else { + shares + .map { shares -> + shares.map { share: Share -> FolderId(share.id, share.rootLinkId) } + }.map { folderIds -> + folderIds.map { folderId -> hasFolderChildren(folderId) } + }.any { hasChildrens -> hasChildrens.any { hasChildren -> hasChildren } } + } + } +} diff --git a/drive/folder/domain/src/main/kotlin/me/proton/core/drive/folder/domain/usecase/HasFolderChildren.kt b/drive/folder/domain/src/main/kotlin/me/proton/core/drive/folder/domain/usecase/HasFolderChildren.kt new file mode 100644 index 00000000..6a9834a2 --- /dev/null +++ b/drive/folder/domain/src/main/kotlin/me/proton/core/drive/folder/domain/usecase/HasFolderChildren.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.folder.domain.usecase + +import me.proton.core.drive.folder.domain.repository.FolderRepository +import me.proton.core.drive.link.domain.entity.FolderId +import javax.inject.Inject + +class HasFolderChildren @Inject constructor( + private val folderRepository: FolderRepository, +) { + suspend operator fun invoke(folderId: FolderId): Boolean = + folderRepository.hasFolderChildren(folderId) +} diff --git a/drive/folder/domain/src/test/kotlin/me/proton/core/drive/folder/domain/usecase/HasAnyCachedFolderChildrenTest.kt b/drive/folder/domain/src/test/kotlin/me/proton/core/drive/folder/domain/usecase/HasAnyCachedFolderChildrenTest.kt new file mode 100644 index 00000000..20dd17e9 --- /dev/null +++ b/drive/folder/domain/src/test/kotlin/me/proton/core/drive/folder/domain/usecase/HasAnyCachedFolderChildrenTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.core.drive.folder.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.db.test.deviceShare +import me.proton.core.drive.db.test.file +import me.proton.core.drive.db.test.folder +import me.proton.core.drive.db.test.mainShare +import me.proton.core.drive.db.test.photoShare +import me.proton.core.drive.db.test.user +import me.proton.core.drive.db.test.userId +import me.proton.core.drive.db.test.volume +import me.proton.core.drive.test.DriveRule +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class HasAnyCachedFolderChildrenTest { + + @get:Rule + var driveRule = DriveRule(this) + + @Inject + lateinit var hasAnyCachedFolderChildren: HasAnyCachedFolderChildren + + @Test + fun `local cache is empty`() = runTest { + // Given + driveRule.db.user { + volume { + mainShare { } + photoShare { } + deviceShare { } + } + } + + // When + val anyFilesOrFolders = hasAnyCachedFolderChildren(userId = userId, filesOnly = false) + val anyFiles = hasAnyCachedFolderChildren(userId = userId, filesOnly = true) + + // Then + assertFalse(anyFilesOrFolders) + assertFalse(anyFiles) + } + + @Test + fun `local cache has only single file in My files`() = runTest { + // Given + driveRule.db.user { + volume { + mainShare { + file("file") + } + } + } + + // When + val anyFilesOrFolders = hasAnyCachedFolderChildren(userId = userId, filesOnly = false) + val anyFiles = hasAnyCachedFolderChildren(userId = userId, filesOnly = true) + + // Then + assertTrue(anyFilesOrFolders) + assertTrue(anyFiles) + } + + @Test + fun `local cache has only single file in Photos`() = runTest { + // Given + driveRule.db.user { + volume { + photoShare { + file("photo-file") + } + } + } + + // When + val anyFilesOrFolders = hasAnyCachedFolderChildren(userId = userId, filesOnly = false) + val anyFiles = hasAnyCachedFolderChildren(userId = userId, filesOnly = true) + + // Then + assertTrue(anyFilesOrFolders) + assertTrue(anyFiles) + } + + @Test + fun `local cache has only single file in Computers`() = runTest { + // Given + driveRule.db.user { + volume { + deviceShare { + file("device-file") + } + } + } + + // When + val anyFilesOrFolders = hasAnyCachedFolderChildren(userId = userId, filesOnly = false) + val anyFiles = hasAnyCachedFolderChildren(userId = userId, filesOnly = true) + + // Then + assertTrue(anyFilesOrFolders) + assertTrue(anyFiles) + } + + @Test + fun `local cache has only folders without any files`() = runTest { + driveRule.db.user { + volume { + mainShare { + folder("child-folder-id") + } + deviceShare(1) { + folder("child-device-1-folder-id") + } + deviceShare(2) { + folder("child-device-2-folder-id") + } + } + } + + // When + val anyFilesOrFolders = hasAnyCachedFolderChildren(userId = userId, filesOnly = false) + val anyFiles = hasAnyCachedFolderChildren(userId = userId, filesOnly = true) + + // Then + assertTrue(anyFilesOrFolders) + assertFalse(anyFiles) + } +} diff --git a/drive/i18n/src/main/res/values/app.xml b/drive/i18n/src/main/res/values/app.xml index acd986b4..cac16008 100644 --- a/drive/i18n/src/main/res/values/app.xml +++ b/drive/i18n/src/main/res/values/app.xml @@ -203,4 +203,38 @@ open a document create a document take a picture + + + + Unlock %1$s of free storage + Complete these steps in your first 30 days for a free storage upgrade. + Upload a file OR enable photo backup + All your files will be encrypted. + Create a sharing link + + Select any file or folder. Open the options menu and press %1$s. + Set a recovery method + + Sign in at %1$s, then go to Settings -> Recovery. + account.proton.me + + https://%1$s + + diff --git a/drive/i18n/src/main/res/values/computers.xml b/drive/i18n/src/main/res/values/computers.xml index 443e2ac8..6aaa96c1 100644 --- a/drive/i18n/src/main/res/values/computers.xml +++ b/drive/i18n/src/main/res/values/computers.xml @@ -27,4 +27,12 @@ No synced folders Folders you sync from computer will\nappear here Computer has been removed + Rename computer + Computer renamed + + More options for %1$s diff --git a/drive/i18n/src/main/res/values/navigation_drawer.xml b/drive/i18n/src/main/res/values/navigation_drawer.xml index 3b6564d0..a9ffcc98 100644 --- a/drive/i18n/src/main/res/values/navigation_drawer.xml +++ b/drive/i18n/src/main/res/values/navigation_drawer.xml @@ -27,4 +27,5 @@ @string/common_sign_out More Storage + Get up to 5 GB for free diff --git a/drive/i18n/src/main/res/values/photos.xml b/drive/i18n/src/main/res/values/photos.xml index 5bf6dff8..79d68d0b 100644 --- a/drive/i18n/src/main/res/values/photos.xml +++ b/drive/i18n/src/main/res/values/photos.xml @@ -117,4 +117,8 @@ %1$s items saved Export device and cloud data + Never run out of storage + Upgrade now and keep all your memories encrypted and safe. + @string/storage_quotas_get_storage_action + Not now diff --git a/drive/i18n/src/main/res/values/user.xml b/drive/i18n/src/main/res/values/user.xml index 75e2f4fc..3faba723 100644 --- a/drive/i18n/src/main/res/values/user.xml +++ b/drive/i18n/src/main/res/values/user.xml @@ -17,6 +17,7 @@ --> + Drive usage Total usage diff --git a/drive/key/domain/src/main/kotlin/me/proton/core/drive/key/domain/usecase/GenerateShareKey.kt b/drive/key/domain/src/main/kotlin/me/proton/core/drive/key/domain/usecase/GenerateShareKey.kt index 8393e4c5..505d33b0 100644 --- a/drive/key/domain/src/main/kotlin/me/proton/core/drive/key/domain/usecase/GenerateShareKey.kt +++ b/drive/key/domain/src/main/kotlin/me/proton/core/drive/key/domain/usecase/GenerateShareKey.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -24,6 +24,7 @@ import me.proton.core.drive.cryptobase.domain.usecase.GenerateNestedPrivateKey import me.proton.core.drive.key.domain.entity.Key import me.proton.core.drive.key.domain.entity.ShareKey import me.proton.core.drive.key.domain.extension.keyHolder +import me.proton.core.drive.link.domain.entity.LinkId import me.proton.core.user.domain.entity.AddressId import javax.inject.Inject @@ -34,6 +35,7 @@ class GenerateShareKey @Inject constructor( private val generateNestedPrivateKey: GenerateNestedPrivateKey, private val getAddressKeys: GetAddressKeys, private val getAddressId: GetAddressId, + private val getNodeKey: GetNodeKey, ) { suspend operator fun invoke( userId: UserId, @@ -49,6 +51,22 @@ class GenerateShareKey @Inject constructor( ) } + suspend operator fun invoke( + userId: UserId, + addressId: AddressId, + linkId: LinkId, + ): Result = coRunCatching { + val addressKey = getAddressKeys(userId, addressId).keyHolder + val nodeKey = getNodeKey(linkId).getOrThrow().keyHolder + ShareKey( + key = generateNestedPrivateKey( + userId = userId, + encryptKeys = listOf(nodeKey, addressKey), + signKey = addressKey, + ).getOrThrow() + ) + } + suspend operator fun invoke(userId: UserId): Result = coRunCatching { invoke( userId = userId, diff --git a/drive/link-download/data/src/main/kotlin/me/proton/core/drive/linkdownload/data/db/LinkDownloadDao.kt b/drive/link-download/data/src/main/kotlin/me/proton/core/drive/linkdownload/data/db/LinkDownloadDao.kt index aaa91a83..0eaf82b4 100644 --- a/drive/link-download/data/src/main/kotlin/me/proton/core/drive/linkdownload/data/db/LinkDownloadDao.kt +++ b/drive/link-download/data/src/main/kotlin/me/proton/core/drive/linkdownload/data/db/LinkDownloadDao.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -28,9 +28,9 @@ import kotlinx.coroutines.flow.Flow import me.proton.core.domain.entity.UserId import me.proton.core.drive.link.data.db.LinkDao import me.proton.core.drive.linkdownload.data.db.entity.DownloadBlockEntity -import me.proton.core.drive.linkdownload.data.db.entity.LinkDownloadStateWithBlockEntity import me.proton.core.drive.linkdownload.data.db.entity.LinkDownloadState import me.proton.core.drive.linkdownload.data.db.entity.LinkDownloadStateEntity +import me.proton.core.drive.linkdownload.data.db.entity.LinkDownloadStateWithBlockEntity import me.proton.core.drive.linkdownload.data.extension.toDownloadBlockEntity import me.proton.core.drive.linkdownload.domain.entity.DownloadState @@ -134,7 +134,7 @@ interface LinkDownloadDao : LinkDao { userId: UserId, shareId: String, folderId: String, - ): List + ): List @Transaction suspend fun insertOrUpdate( diff --git a/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/extension/FolderId.kt b/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/extension/FolderId.kt index 20c5529c..ffbc87f6 100644 --- a/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/extension/FolderId.kt +++ b/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/extension/FolderId.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -23,13 +23,12 @@ import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.linkupload.domain.entity.NetworkTypeProviderType import me.proton.core.drive.linkupload.domain.entity.UploadFileLink -import me.proton.core.drive.volume.domain.entity.VolumeId fun FolderId.uploadFileLink(index: Long = 0, uriString : String = "uri$index") = UploadFileLink( id = index, userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, mimeType = "", name = "upload-$index", networkTypeProviderType = NetworkTypeProviderType.DEFAULT, diff --git a/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/usecase/GetUploadFileLinksPagedTest.kt b/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/usecase/GetUploadFileLinksPagedTest.kt index 4cd7cc31..a0546d21 100644 --- a/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/usecase/GetUploadFileLinksPagedTest.kt +++ b/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/usecase/GetUploadFileLinksPagedTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -23,7 +23,7 @@ import kotlinx.coroutines.test.runTest import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.folder -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.photo import me.proton.core.drive.db.test.photoShareId import me.proton.core.drive.db.test.userId @@ -70,7 +70,7 @@ class GetUploadFileLinksPagedTest { } ) - mainRoot = database.myDrive { + mainRoot = database.myFiles { folder("folderA") folder("folderB") } diff --git a/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/usecase/UploadBulkTest.kt b/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/usecase/UploadBulkTest.kt index ad7dac8f..1df24d1c 100644 --- a/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/usecase/UploadBulkTest.kt +++ b/drive/link-upload/domain/src/test/kotlin/me/proton/core/drive/linkupload/domain/usecase/UploadBulkTest.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.data.extension.toLink import me.proton.core.drive.link.domain.entity.FolderId @@ -32,7 +32,6 @@ import me.proton.core.drive.linkupload.data.repository.LinkUploadRepositoryImpl import me.proton.core.drive.linkupload.domain.entity.NetworkTypeProviderType import me.proton.core.drive.linkupload.domain.entity.UploadFileDescription import me.proton.core.drive.linkupload.domain.entity.UploadFileLink -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -53,7 +52,7 @@ class UploadBulkTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val linkUploadRepository = LinkUploadRepositoryImpl(database.db, UploadBlockFactoryImpl()) createUploadBulk = CreateUploadBulk(linkUploadRepository) deleteUploadBulk = DeleteUploadBulk(linkUploadRepository) @@ -71,7 +70,7 @@ class UploadBulkTest { "content://com.android.providers.downloads.documents/document/001" ).map { uri -> UploadFileDescription(uri) } val id = createUploadBulk( - volumeId = VolumeId(volumeId), + volumeId = volumeId, parent = folderId.folder(), uploadFileDescriptions = uploadFileDescriptions, networkTypeProviderType = NetworkTypeProviderType.BACKUP, diff --git a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/db/LinkDao.kt b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/db/LinkDao.kt index 7fba8366..09b099ca 100644 --- a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/db/LinkDao.kt +++ b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/db/LinkDao.kt @@ -57,6 +57,11 @@ interface LinkDao { ) fun hasLinkEntity(userId: UserId, shareId: String, linkId: String): Flow + @Query( + "SELECT EXISTS(SELECT * FROM LinkFilePropertiesEntity WHERE file_user_id = :userId AND file_share_id = :shareId)" + ) + suspend fun hasAnyFileEntity(userId: UserId, shareId: String): Boolean + @Query( """ SELECT LinkEntity.*, LinkFilePropertiesEntity.*, LinkFolderPropertiesEntity.* diff --git a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/db/entity/LinkEntity.kt b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/db/entity/LinkEntity.kt index 7a47eb4d..9c085ab8 100644 --- a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/db/entity/LinkEntity.kt +++ b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/db/entity/LinkEntity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -134,7 +134,7 @@ data class LinkEntity( @ColumnInfo(name = X_ATTR) val xAttr: String? = null, @ColumnInfo(name = SHARE_URL_SHARE_ID, defaultValue = "NULL") - val shareUrlShareId: String? = null, + val sharingDetailsShareId: String? = null, @ColumnInfo(name = SHARE_URL_ID, defaultValue = "NULL") val shareUrlId: String? = null, ) diff --git a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/Link.kt b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/Link.kt index 317eec3b..21193f29 100644 --- a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/Link.kt +++ b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/Link.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -52,8 +52,8 @@ fun Link.toLinkWithProperties() = LinkWithProperties( shared = if (isShared) 1L else 0L, numberOfAccesses = numberOfAccesses, shareUrlExpirationTime = shareUrlExpirationTime?.value, - shareUrlShareId = shareUrlId?.shareId?.id, - shareUrlId = shareUrlId?.id, + sharingDetailsShareId = sharingDetails?.shareId?.id, + shareUrlId = sharingDetails?.shareUrlId?.id, xAttr = xAttr, ), properties = when (this) { diff --git a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkDto.kt b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkDto.kt index 5f423cc7..af7cf456 100644 --- a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkDto.kt +++ b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkDto.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -49,7 +49,7 @@ private fun LinkDto.toLinkEntity(shareId: ShareId) = numberOfAccesses = sharingDetails?.shareUrl?.numberOfAccesses ?: 0, shareUrlExpirationTime = sharingDetails?.shareUrl?.expirationTime, xAttr = xAttr, - shareUrlShareId = sharingDetails?.shareId, + sharingDetailsShareId = sharingDetails?.shareId, shareUrlId = sharingDetails?.shareUrl?.shareUrlId, ) diff --git a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkEntity.kt b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkEntity.kt index 380c5edd..029638c1 100644 --- a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkEntity.kt +++ b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkEntity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -18,6 +18,7 @@ package me.proton.core.drive.link.data.extension import me.proton.core.drive.link.data.db.entity.LinkEntity +import me.proton.core.drive.link.domain.entity.SharingDetails import me.proton.core.drive.share.domain.entity.ShareId import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId @@ -27,11 +28,15 @@ val LinkEntity.isFolder val LinkEntity.isShared get() = shared == 1L -fun LinkEntity.shareUrlId(): ShareUrlId? = shareUrlShareId?.let { shareId -> - this.shareUrlId?.let { shareUrlId -> - ShareUrlId( - shareId = ShareId(userId = this.userId, shareId), - id = shareUrlId, - ) - } +fun LinkEntity.sharingDetails(): SharingDetails? = sharingDetailsShareId?.let { shareUrlShareId -> + val shareId = ShareId(userId = this.userId, shareUrlShareId) + SharingDetails( + shareId, + shareUrlId?.let {id -> + ShareUrlId( + shareId = shareId, + id = id, + ) + } + ) } diff --git a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkWithProperties.kt b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkWithProperties.kt index 348e7aaa..8e452255 100644 --- a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkWithProperties.kt +++ b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/extension/LinkWithProperties.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -65,7 +65,7 @@ fun LinkWithProperties.toLink(): Link = when (properties) { TimestampS(shareUrlExpirationTime) }, xAttr = link.xAttr, - shareUrlId = link.shareUrlId(), + sharingDetails = link.sharingDetails(), photoCaptureTime = properties.photoCaptureTime?.let { captureTime -> TimestampS(captureTime) }, photoContentHash = properties.photoContentHash, mainPhotoLinkId = properties.mainPhotoLinkId, @@ -103,7 +103,7 @@ fun LinkWithProperties.toLink(): Link = when (properties) { TimestampS(shareUrlExpirationTime) }, xAttr = link.xAttr, - shareUrlId = link.shareUrlId(), + sharingDetails = link.sharingDetails(), ) } diff --git a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/repository/LinkRepositoryImpl.kt b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/repository/LinkRepositoryImpl.kt index 350b231c..2756d716 100644 --- a/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/repository/LinkRepositoryImpl.kt +++ b/drive/link/data/src/main/kotlin/me/proton/core/drive/link/data/repository/LinkRepositoryImpl.kt @@ -60,6 +60,9 @@ class LinkRepositoryImpl @Inject constructor( override fun hasLink(linkId: LinkId): Flow = dao.hasLinkEntity(linkId.userId, linkId.shareId.id, linkId.id) + override suspend fun hasAnyFileLink(shareId: ShareId): Boolean = + dao.hasAnyFileEntity(shareId.userId, shareId.id) + override suspend fun fetchLink(linkId: LinkId) { dao.insertOrUpdate( api.getLink(linkId) diff --git a/drive/link/data/src/test/kotlin/me/proton/core/drive/link/data/repository/LinkRepositoryImplTest.kt b/drive/link/data/src/test/kotlin/me/proton/core/drive/link/data/repository/LinkRepositoryImplTest.kt index 52beed2d..b229bb79 100644 --- a/drive/link/data/src/test/kotlin/me/proton/core/drive/link/data/repository/LinkRepositoryImplTest.kt +++ b/drive/link/data/src/test/kotlin/me/proton/core/drive/link/data/repository/LinkRepositoryImplTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -25,10 +25,10 @@ import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.file import me.proton.core.drive.db.test.folder import me.proton.core.drive.db.test.mainShare -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.mainShareId +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.photoShare import me.proton.core.drive.db.test.photoShareId -import me.proton.core.drive.db.test.shareId import me.proton.core.drive.db.test.user import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volume @@ -36,8 +36,6 @@ import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.LinkId -import me.proton.core.drive.share.domain.entity.ShareId -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -62,9 +60,9 @@ class LinkRepositoryImplTest { @Test fun empty() = runTest { - database.myDrive {} + database.myFiles {} - val linkIds = repository.findLinkIds(userId, VolumeId(volumeId), "link-id") + val linkIds = repository.findLinkIds(userId, volumeId, "link-id") assertEquals(emptyList(), linkIds) } @@ -82,9 +80,9 @@ class LinkRepositoryImplTest { } } - val linkIds = repository.findLinkIds(userId, VolumeId(volumeId), "link-id-1") + val linkIds = repository.findLinkIds(userId, volumeId, "link-id-1") - assertEquals(listOf(FileId(ShareId(userId, shareId), "link-id-1")), linkIds) + assertEquals(listOf(FileId(mainShareId, "link-id-1")), linkIds) } @Test @@ -100,11 +98,11 @@ class LinkRepositoryImplTest { } } - val linkIds = repository.findLinkIds(userId, VolumeId(volumeId), "link-id") + val linkIds = repository.findLinkIds(userId, volumeId, "link-id") assertEquals( listOf( - FolderId(ShareId(userId, shareId), "link-id"), + FolderId(mainShareId, "link-id"), FileId(photoShareId, "link-id"), ), linkIds ) diff --git a/drive/link/domain/build.gradle.kts b/drive/link/domain/build.gradle.kts index 7bf10205..4d3cdff7 100644 --- a/drive/link/domain/build.gradle.kts +++ b/drive/link/domain/build.gradle.kts @@ -27,6 +27,7 @@ android { driveModule( hilt = true, serialization = true, + socialTest = true, ) { api(project(":drive:base:domain")) api(project(":drive:share:domain")) diff --git a/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/entity/Link.kt b/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/entity/Link.kt index ea950aef..1246880f 100644 --- a/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/entity/Link.kt +++ b/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/entity/Link.kt @@ -23,7 +23,6 @@ import me.proton.core.drive.base.domain.entity.Bytes import me.proton.core.drive.base.domain.entity.Permissions import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.share.domain.entity.ShareId -import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId sealed class LinkId { abstract val shareId: ShareId @@ -84,7 +83,7 @@ sealed class Link : BaseLink { abstract val signatureAddress: String abstract val creationTime: TimestampS abstract val trashedTime: TimestampS? - abstract val shareUrlId: ShareUrlId? + abstract val sharingDetails: SharingDetails? data class File( override val id: FileId, @@ -116,7 +115,7 @@ sealed class Link : BaseLink { override val hasThumbnail: Boolean, override val activeRevisionId: String, override val xAttr: String?, - override val shareUrlId: ShareUrlId?, + override val sharingDetails: SharingDetails?, val contentKeyPacket: String, val contentKeyPacketSignature: String?, override val photoCaptureTime: TimestampS? = null, @@ -156,7 +155,7 @@ sealed class Link : BaseLink { override val creationTime: TimestampS, override val trashedTime: TimestampS?, override val xAttr: String?, - override val shareUrlId: ShareUrlId?, + override val sharingDetails: SharingDetails?, val nodeHashKey: String, ) : Link(), me.proton.core.drive.link.domain.entity.Folder diff --git a/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/entity/SharingDetails.kt b/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/entity/SharingDetails.kt new file mode 100644 index 00000000..7bf22eba --- /dev/null +++ b/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/entity/SharingDetails.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.link.domain.entity + +import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId + +data class SharingDetails( + val shareId: ShareId, + val shareUrlId: ShareUrlId? = null, +) + diff --git a/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/repository/LinkRepository.kt b/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/repository/LinkRepository.kt index 0411c18b..48715fe5 100644 --- a/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/repository/LinkRepository.kt +++ b/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/repository/LinkRepository.kt @@ -40,6 +40,11 @@ interface LinkRepository { */ fun hasLink(linkId: LinkId): Flow + /** + * Check if we have cached any file for a given user and share + */ + suspend fun hasAnyFileLink(shareId: ShareId): Boolean + /** * Fetches link from the server and stores it into cache */ diff --git a/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/usecase/DeleteLinks.kt b/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/usecase/DeleteLinks.kt index a2ddaa1b..0cc4cf9f 100644 --- a/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/usecase/DeleteLinks.kt +++ b/drive/link/domain/src/main/kotlin/me/proton/core/drive/link/domain/usecase/DeleteLinks.kt @@ -18,6 +18,7 @@ package me.proton.core.drive.link.domain.usecase +import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.link.domain.entity.LinkId import me.proton.core.drive.link.domain.repository.LinkRepository import javax.inject.Inject @@ -29,6 +30,7 @@ class DeleteLinks @Inject constructor( suspend operator fun invoke(linkId: LinkId) = invoke(listOf(linkId)) - suspend operator fun invoke(linkIds: List) = + suspend operator fun invoke(linkIds: List) = coRunCatching { linkRepository.delete(linkIds) + } } diff --git a/drive/link/domain/src/test/kotlin/me/proton/core/drive/link/domain/repository/LinkRepositoryTest.kt b/drive/link/domain/src/test/kotlin/me/proton/core/drive/link/domain/repository/LinkRepositoryTest.kt new file mode 100644 index 00000000..674f79db --- /dev/null +++ b/drive/link/domain/src/test/kotlin/me/proton/core/drive/link/domain/repository/LinkRepositoryTest.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.link.domain.repository + +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.db.test.deviceShare +import me.proton.core.drive.db.test.deviceShareId +import me.proton.core.drive.db.test.file +import me.proton.core.drive.db.test.folder +import me.proton.core.drive.db.test.mainShare +import me.proton.core.drive.db.test.mainShareId +import me.proton.core.drive.db.test.photoShare +import me.proton.core.drive.db.test.photoShareId +import me.proton.core.drive.db.test.user +import me.proton.core.drive.db.test.volume +import me.proton.core.drive.test.DriveRule +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class LinkRepositoryTest { + + @get:Rule + var driveRule = DriveRule(this) + + @Inject + lateinit var linkRepository: LinkRepository + + @Test + fun `single file in main share`() = runTest { + // Given + driveRule.db.user { + volume { + mainShare { + file("file-id") + } + } + } + + // When + val anyFile = linkRepository.hasAnyFileLink(shareId = mainShareId) + + // Then + assertTrue(anyFile) + } + + @Test + fun `single folder in main share`() = runTest { + // Given + driveRule.db.user { + volume { + mainShare { + folder("folder-id") + } + } + } + + // When + val anyFile = linkRepository.hasAnyFileLink(shareId = mainShareId) + + // Then + assertFalse(anyFile) + } + + @Test + fun `single file in photo share`() = runTest { + // Given + driveRule.db.user { + volume { + photoShare { + file("photo-file-id") + } + } + } + + // When + val anyFile = linkRepository.hasAnyFileLink(shareId = photoShareId) + + // Then + assertTrue(anyFile) + } + + @Test + fun `no file in photo share`() = runTest { + // Given + driveRule.db.user { + volume { + photoShare { } + } + } + + // When + val anyFile = linkRepository.hasAnyFileLink(shareId = photoShareId) + + // Then + assertFalse(anyFile) + } + + @Test + fun `single file in device share`() = runTest { + // Given + driveRule.db.user { + volume { + deviceShare { + file("device-file-id") + } + } + } + + // When + val anyFile = linkRepository.hasAnyFileLink(shareId = deviceShareId()) + + // Then + assertTrue(anyFile) + } + + @Test + fun `single folder in device share`() = runTest { + // Given + driveRule.db.user { + volume { + deviceShare { + folder("device-folder-id") + } + } + } + + // When + val anyFile = linkRepository.hasAnyFileLink(shareId = deviceShareId()) + + // Then + assertFalse(anyFile) + } +} diff --git a/drive/link/domain/src/test/kotlin/me/proton/core/drive/link/domain/usecase/FindLinkIdsTest.kt b/drive/link/domain/src/test/kotlin/me/proton/core/drive/link/domain/usecase/FindLinkIdsTest.kt index 87497b61..04023593 100644 --- a/drive/link/domain/src/test/kotlin/me/proton/core/drive/link/domain/usecase/FindLinkIdsTest.kt +++ b/drive/link/domain/src/test/kotlin/me/proton/core/drive/link/domain/usecase/FindLinkIdsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -24,10 +24,10 @@ import kotlinx.coroutines.test.runTest import me.proton.core.drive.db.test.DriveDatabaseRule import me.proton.core.drive.db.test.file import me.proton.core.drive.db.test.mainShare -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.mainShareId +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.photoShare import me.proton.core.drive.db.test.photoShareId -import me.proton.core.drive.db.test.shareId import me.proton.core.drive.db.test.user import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volume @@ -35,8 +35,6 @@ import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.data.repository.LinkRepositoryImpl import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.LinkId -import me.proton.core.drive.share.domain.entity.ShareId -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -60,9 +58,9 @@ class FindLinkIdsTest { @Test fun empty() = runTest { - database.myDrive {} + database.myFiles {} - val linkIds = findLinkIds(userId, VolumeId(volumeId), "link-id").getOrThrow() + val linkIds = findLinkIds(userId, volumeId, "link-id").getOrThrow() assertEquals(emptyList(), linkIds) } @@ -80,10 +78,10 @@ class FindLinkIdsTest { } } - val linkIds = findLinkIds(userId, VolumeId(volumeId), "link-id").getOrThrow() + val linkIds = findLinkIds(userId, volumeId, "link-id").getOrThrow() assertEquals(listOf( - FileId(ShareId(userId, shareId), "link-id"), + FileId(mainShareId, "link-id"), FileId(photoShareId, "link-id"), ), linkIds) } diff --git a/drive/navigation-drawer/presentation/build.gradle.kts b/drive/navigation-drawer/presentation/build.gradle.kts index 8bfd5de8..6ae1761e 100644 --- a/drive/navigation-drawer/presentation/build.gradle.kts +++ b/drive/navigation-drawer/presentation/build.gradle.kts @@ -32,4 +32,5 @@ driveModule( api(project(":drive:base:presentation")) implementation(project(":drive:user:presentation")) + implementation(libs.core.plan.presentation.compose) } diff --git a/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawer.kt b/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawer.kt index 63ee06f6..e75b252a 100644 --- a/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawer.kt +++ b/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawer.kt @@ -20,6 +20,7 @@ package me.proton.core.drive.navigationdrawer.presentation import androidx.activity.compose.BackHandler import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.animation.Crossfade import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,21 +36,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.launch import me.proton.core.compose.component.ProtonListSectionTitle import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing import me.proton.core.compose.theme.ProtonDimens.SmallSpacing import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm +import me.proton.core.compose.theme.defaultStrongNorm import me.proton.core.drive.base.domain.entity.Bytes import me.proton.core.drive.base.presentation.component.NavigationDrawerAppVersion import me.proton.core.drive.base.presentation.component.ProtonListItem import me.proton.core.drive.user.presentation.storage.StorageIndicator import me.proton.core.drive.user.presentation.user.PREVIEW_USER import me.proton.core.drive.user.presentation.user.UserSelector -import me.proton.core.drive.i18n.R as I18N +import me.proton.core.drive.user.presentation.user.extension.getStorageIndicatorData +import me.proton.core.plan.presentation.compose.component.UpgradeStorageInfo import me.proton.core.drive.base.presentation.R as BasePresentation +import me.proton.core.drive.i18n.R as I18N import me.proton.core.presentation.R as CorePresentation @Composable @@ -100,9 +108,15 @@ fun NavigationDrawer( modifier = Modifier .padding(top = DefaultSpacing) .verticalScroll(rememberScrollState()) - .weight(1f), + .weight(1f) + .testTag(NavigationDrawerTestTag.content), verticalArrangement = Arrangement.Top ) { + UpgradeStorageInfo( + onUpgradeClicked = { viewEvent.onSubscription() }, + withTopDivider = true, + withBottomDivider = true + ) MyFilesListItem(closeDrawerAction, viewEvent) @@ -126,10 +140,24 @@ fun NavigationDrawer( modifier = Modifier.padding(bottom = SmallSpacing) ) + Crossfade( + targetState = viewState.showGetFreeStorage, + label = "GetMoreFreeStorage", + ) { showGetFreeStorage -> + if (showGetFreeStorage) { + GetFreeStorageListItem(closeDrawerAction, viewEvent) + } + } + + val (usedSpace, availableSpace, label) = viewState.currentUser.getStorageIndicatorData() + StorageIndicator( - usedBytes = Bytes(viewState.currentUser.usedSpace), - availableBytes = Bytes(viewState.currentUser.maxSpace), - modifier = Modifier.padding(horizontal = DefaultSpacing) + label = stringResource(id = label), + usedBytes = Bytes(usedSpace), + availableBytes = Bytes(availableSpace), + modifier = Modifier + .padding(horizontal = DefaultSpacing) + .testTag(NavigationDrawerTestTag.storageIndicator) ) } @@ -255,18 +283,39 @@ private fun SignOutListItem( } } +@Composable +private fun GetFreeStorageListItem( + closeDrawerAction: (() -> Unit) -> Unit, + viewEvent: NavigationDrawerViewEvent, + modifier: Modifier = Modifier, +) { + NavigationDrawerListItem( + icon = CorePresentation.drawable.ic_proton_gift, + iconTintColor = ProtonTheme.colors.iconNorm, + title = I18N.string.navigation_item_get_free_storage, + textStyle = ProtonTheme.typography.defaultStrongNorm, + closeDrawerAction = closeDrawerAction, + modifier = modifier.padding(bottom = DefaultSpacing), + ) { + viewEvent.onGetFreeStorage() + } +} + @Composable fun NavigationDrawerListItem( @DrawableRes icon: Int, @StringRes title: Int, closeDrawerAction: (() -> Unit) -> Unit, modifier: Modifier = Modifier, + iconTintColor: Color = ProtonTheme.colors.iconWeak, + textStyle: TextStyle = ProtonTheme.typography.defaultNorm, onClick: () -> Unit, ) { ProtonListItem( icon = icon, - iconTintColor = ProtonTheme.colors.iconWeak, + iconTintColor = iconTintColor, title = title, + textStyle = textStyle, modifier = modifier .clickable { closeDrawerAction(onClick) @@ -275,6 +324,11 @@ fun NavigationDrawerListItem( ) } +object NavigationDrawerTestTag { + const val content = "navigation drawer content" + const val storageIndicator = "navigation drawer storage indicator" +} + @Preview(name = "Drawer opened") @Composable fun PreviewDrawerWithUser() { @@ -294,6 +348,7 @@ fun PreviewDrawerWithUser() { override val onSignOut = {} override val onBugReport = {} override val onSubscription = {} + override val onGetFreeStorage = {} } ) } @@ -314,6 +369,7 @@ fun PreviewDrawerWithoutUser() { override val onSignOut = {} override val onBugReport = {} override val onSubscription = {} + override val onGetFreeStorage = {} } ) } diff --git a/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawerViewEvent.kt b/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawerViewEvent.kt index 9aa049a8..ecc0ea0a 100644 --- a/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawerViewEvent.kt +++ b/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawerViewEvent.kt @@ -25,4 +25,5 @@ interface NavigationDrawerViewEvent { val onBugReport: () -> Unit val onSignOut: () -> Unit val onSubscription: () -> Unit + val onGetFreeStorage: () -> Unit } diff --git a/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawerViewState.kt b/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawerViewState.kt index 16706f9a..be123813 100644 --- a/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawerViewState.kt +++ b/drive/navigation-drawer/presentation/src/main/kotlin/me/proton/core/drive/navigationdrawer/presentation/NavigationDrawerViewState.kt @@ -28,4 +28,5 @@ data class NavigationDrawerViewState( val closeOnBackEnabled: Boolean = true, val closeOnActionEnabled: Boolean = true, val currentUser: User? = null, + val showGetFreeStorage: Boolean = false, ) diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/ImagePreview.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/ImagePreview.kt index fbeead17..7a8526fc 100644 --- a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/ImagePreview.kt +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/ImagePreview.kt @@ -166,7 +166,7 @@ fun ImagePreview( ) .transformable( state = state, - canPan = transformationState::hasScale, + canPan = { transformationState.hasScale() }, ) .testTag(ImagePreviewComponentTestTag.image) ) diff --git a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/PdfPreview.kt b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/PdfPreview.kt index 118f15fd..468f372b 100644 --- a/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/PdfPreview.kt +++ b/drive/preview/src/main/kotlin/me/proton/core/drive/files/preview/presentation/component/PdfPreview.kt @@ -179,7 +179,7 @@ fun PdfPreview( ) .transformable( state = transformableState, - canPan = transformationState::hasScale + canPan = { transformationState.hasScale() } ), ) } diff --git a/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/CreatePhotoShare.kt b/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/CreatePhotoShare.kt index 913548b6..f9df7e60 100644 --- a/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/CreatePhotoShare.kt +++ b/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/CreatePhotoShare.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -20,6 +20,7 @@ package me.proton.core.drive.share.crypto.domain.usecase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import me.proton.core.domain.arch.DataResult @@ -40,7 +41,6 @@ import me.proton.core.drive.share.domain.exception.ShareException import me.proton.core.drive.share.domain.usecase.GetMainShare import me.proton.core.drive.share.domain.usecase.GetShare import me.proton.core.drive.volume.domain.entity.VolumeId -import me.proton.core.drive.volume.domain.usecase.GetOldestActiveVolume import me.proton.core.network.domain.ApiException import javax.inject.Inject @@ -48,21 +48,23 @@ import javax.inject.Inject class CreatePhotoShare @Inject constructor( private val createPhotoInfo: CreatePhotoInfo, private val photoRepository: PhotoRepository, - private val getOldestActiveVolume: GetOldestActiveVolume, + private val getOrCreateMainShare: GetOrCreateMainShare, private val getShare: GetShare, private val withFeatureFlag: WithFeatureFlag, private val getMainShare: GetMainShare, private val getAddressId: GetAddressId, ) { operator fun invoke(userId: UserId): Flow> = - getOldestActiveVolume(userId).transformSuccess { result -> - emitAll( - invoke( - userId = userId, - volumeId = result.value.id, + getOrCreateMainShare(userId) + .distinctUntilChanged() + .transformSuccess { result -> + emitAll( + invoke( + userId = userId, + volumeId = result.value.volumeId, + ) ) - ) - } + } operator fun invoke(userId: UserId, volumeId: VolumeId): Flow> = flow { try { diff --git a/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/DeleteLocalShares.kt b/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/DeleteLocalShares.kt index 76988256..2ebf642a 100644 --- a/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/DeleteLocalShares.kt +++ b/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/DeleteLocalShares.kt @@ -18,8 +18,10 @@ package me.proton.core.drive.share.crypto.domain.usecase import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import me.proton.core.drive.base.domain.extension.filterSuccessOrError import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.link.domain.entity.LinkId import me.proton.core.drive.link.domain.extension.userId import me.proton.core.drive.share.domain.entity.Share @@ -31,23 +33,25 @@ class DeleteLocalShares @Inject constructor( private val getShares: GetShares, private val deleteShare: DeleteShare, ) { - suspend operator fun invoke(linkIds: List) = with (linkIds.map { linkId -> linkId.id }) { - listOf(Share.Type.STANDARD, Share.Type.DEVICE) - .map { shareType -> - getShares(linkIds.first().userId, shareType) - .filterSuccessOrError() - .first() - .toResult() - .getOrNull() - ?.mapNotNull { share -> if (share.rootLinkId in this) share.id else null } - ?: emptyList() - } - .flatten() - .forEach { shareId -> - deleteShare( - shareId = shareId, - locallyOnly = true, - ) - } + suspend operator fun invoke(linkIds: List) = coRunCatching { + with (linkIds.map { linkId -> linkId.id }) { + listOf(Share.Type.STANDARD, Share.Type.DEVICE) + .map { shareType -> + getShares(linkIds.first().userId, shareType, flowOf(false)) + .filterSuccessOrError() + .first() + .toResult() + .getOrNull() + ?.mapNotNull { share -> if (share.rootLinkId in this) share.id else null } + ?: emptyList() + } + .flatten() + .forEach { shareId -> + deleteShare( + shareId = shareId, + locallyOnly = true, + ) + } + } } } diff --git a/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/GetOrCreateShare.kt b/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/GetOrCreateShare.kt index 6c6e32ec..97b1f7dc 100644 --- a/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/GetOrCreateShare.kt +++ b/drive/share-crypto/domain/src/main/kotlin/me/proton/core/drive/share/crypto/domain/usecase/GetOrCreateShare.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Proton AG. + * Copyright (c) 2022-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -36,7 +36,7 @@ class GetOrCreateShare @Inject constructor( ) { operator fun invoke(volumeId: VolumeId, linkId: LinkId): Flow> = flow { val link = getLink(linkId).toResult().getOrNull() - link?.shareUrlId?.shareId?.let { shareId -> + link?.sharingDetails?.shareId?.let { shareId -> emitAll(getShare(shareId)) } ?: emitAll(createShare(volumeId, linkId)) } diff --git a/drive/share-url/crypto/data/src/main/kotlin/me/proton/core/drive/shareurl/crypto/data/usecase/CopyPublicUrlImpl.kt b/drive/share-url/crypto/data/src/main/kotlin/me/proton/core/drive/shareurl/crypto/data/usecase/CopyPublicUrlImpl.kt index e10742a5..9a831371 100644 --- a/drive/share-url/crypto/data/src/main/kotlin/me/proton/core/drive/shareurl/crypto/data/usecase/CopyPublicUrlImpl.kt +++ b/drive/share-url/crypto/data/src/main/kotlin/me/proton/core/drive/shareurl/crypto/data/usecase/CopyPublicUrlImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Proton AG. + * Copyright (c) 2022-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -42,7 +42,7 @@ class CopyPublicUrlImpl @Inject constructor( override suspend operator fun invoke(volumeId: VolumeId, linkId: LinkId) = coRunCatching { val userId = linkId.shareId.userId val link = getLink(linkId).toResult().getOrThrow() - val shareUrlId = requireNotNull(link.shareUrlId) { "ShareUrlId must not be null" } + val shareUrlId = requireNotNull(link.sharingDetails?.shareUrlId) { "ShareUrlId must not be null" } val shareUrl = getShareUrl(volumeId, shareUrlId, flowOf { false }).toResult().getOrThrow() val publicUrl = getPublicUrl(userId, shareUrl).getOrThrow() copyToClipboard( diff --git a/drive/share-url/crypto/domain/src/main/kotlin/me/proton/core/drive/shareurl/crypto/domain/usecase/GetOrCreateShareUrl.kt b/drive/share-url/crypto/domain/src/main/kotlin/me/proton/core/drive/shareurl/crypto/domain/usecase/GetOrCreateShareUrl.kt index 96a0cc6a..957a4cfa 100644 --- a/drive/share-url/crypto/domain/src/main/kotlin/me/proton/core/drive/shareurl/crypto/domain/usecase/GetOrCreateShareUrl.kt +++ b/drive/share-url/crypto/domain/src/main/kotlin/me/proton/core/drive/shareurl/crypto/domain/usecase/GetOrCreateShareUrl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2023 Proton AG. + * Copyright (c) 2022-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -41,7 +41,7 @@ class GetOrCreateShareUrl @Inject constructor( private val getLink: GetLink, ) { suspend operator fun invoke(volumeId: VolumeId, share: Share, linkId: LinkId): Flow> = flow { - val shareUrl = getLink(linkId).toResult().getOrNull()?.shareUrlId?.let { shareUrlId -> + val shareUrl = getShareUrlId(linkId)?.let { shareUrlId -> getShareUrl( volumeId = volumeId, shareUrlId = shareUrlId, @@ -72,4 +72,8 @@ class GetOrCreateShareUrl @Inject constructor( ) ) } + + private suspend fun getShareUrlId( + linkId: LinkId, + ) = getLink(linkId).toResult().getOrNull()?.sharingDetails?.shareUrlId } diff --git a/drive/share/domain/src/main/kotlin/me/proton/core/drive/share/domain/extension/Share.kt b/drive/share/domain/src/main/kotlin/me/proton/core/drive/share/domain/extension/Share.kt index b634dfe9..f36542d2 100644 --- a/drive/share/domain/src/main/kotlin/me/proton/core/drive/share/domain/extension/Share.kt +++ b/drive/share/domain/src/main/kotlin/me/proton/core/drive/share/domain/extension/Share.kt @@ -22,3 +22,5 @@ import me.proton.core.drive.share.domain.entity.Share val Share.creationTimeOrMaxLongIfNull: TimestampS get() = creationTime ?: TimestampS(Long.MAX_VALUE) + +val Share.isDevice: Boolean get() = type == Share.Type.DEVICE diff --git a/drive/stats/domain/src/test/kotlin/me/proton/core/drive/stats/domain/usecase/UploadStatsTest.kt b/drive/stats/domain/src/test/kotlin/me/proton/core/drive/stats/domain/usecase/UploadStatsTest.kt index 021f8db7..c3c75362 100644 --- a/drive/stats/domain/src/test/kotlin/me/proton/core/drive/stats/domain/usecase/UploadStatsTest.kt +++ b/drive/stats/domain/src/test/kotlin/me/proton/core/drive/stats/domain/usecase/UploadStatsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -24,7 +24,7 @@ import me.proton.core.drive.base.domain.entity.Bytes import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.stats.data.repository.UploadStatsRepositoryImpl import org.junit.Assert.assertEquals @@ -48,7 +48,7 @@ class UploadStatsTest { @Before fun setUp() = runTest { - folderId = database.myDrive {} + folderId = database.myFiles {} val repository = UploadStatsRepositoryImpl(database.db) getUploadStats = GetUploadStats(repository) updateUploadStats = UpdateUploadStats(repository) diff --git a/drive/telemetry/domain/src/main/kotlin/me/proton/core/drive/telemetry/domain/event/PhotosEvent.kt b/drive/telemetry/domain/src/main/kotlin/me/proton/core/drive/telemetry/domain/event/PhotosEvent.kt index 1f6616a4..cdf66700 100644 --- a/drive/telemetry/domain/src/main/kotlin/me/proton/core/drive/telemetry/domain/event/PhotosEvent.kt +++ b/drive/telemetry/domain/src/main/kotlin/me/proton/core/drive/telemetry/domain/event/PhotosEvent.kt @@ -82,4 +82,15 @@ object PhotosEvent { ) ) + fun UpsellPhotosAccepted() = UpsellPhotos("accepted") + + fun UpsellPhotosDeclined() = UpsellPhotos("declined") + + internal fun UpsellPhotos(answer: String) = DriveTelemetryEvent( + group = group, + name = "upsell_photos", + dimensions = mapOf( + "answer" to answer + ) + ) } diff --git a/drive/telemetry/domain/src/test/kotlin/me/proton/core/drive/telemetry/domain/event/PhotosEventTest.kt b/drive/telemetry/domain/src/test/kotlin/me/proton/core/drive/telemetry/domain/event/PhotosEventTest.kt index 8a866d98..53aff79d 100644 --- a/drive/telemetry/domain/src/test/kotlin/me/proton/core/drive/telemetry/domain/event/PhotosEventTest.kt +++ b/drive/telemetry/domain/src/test/kotlin/me/proton/core/drive/telemetry/domain/event/PhotosEventTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -27,6 +27,7 @@ class PhotosEventTest { PhotosEvent.SettingEnabled(), PhotosEvent.UploadDone(0, 0, PhotosEvent.Reason.COMPLETED), PhotosEvent.BackupStopped(0, 0, 0, PhotosEvent.Reason.COMPLETED, true), + PhotosEvent.UpsellPhotos(""), ) @Test diff --git a/drive/test/build.gradle.kts b/drive/test/build.gradle.kts index 37ffd34e..f07cb37c 100644 --- a/drive/test/build.gradle.kts +++ b/drive/test/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -52,12 +52,16 @@ dependencies { api(libs.core.crypto.dagger) api(libs.core.key.dagger) api(libs.core.user.dagger) + api(libs.core.observability.dagger) api(libs.core.test.kotlin) api(libs.mockwebserver) api(libs.androidx.work.runtime.ktx) + api(libs.kotlinx.serialization.json) api(project(":drive:base:domain")) api(project(":drive:crypto-base:data")) + api(project(":drive:trash:data")) api(project(":drive:db-test")) + api(project(":drive:key:data")) implementation(libs.dagger.hilt.android) implementation(libs.dagger.hilt.android.testing) diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/api/DriveDispatcher.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/api/DriveDispatcher.kt index 9c2ccf97..67be8d64 100644 --- a/drive/test/src/main/kotlin/me/proton/core/drive/test/api/DriveDispatcher.kt +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/api/DriveDispatcher.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -18,6 +18,7 @@ package me.proton.core.drive.test.api +import kotlinx.serialization.json.Json import me.proton.core.util.kotlin.serialize import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse @@ -61,6 +62,13 @@ fun MockWebServer.routing( } } +fun MockWebServer.clear() { + val internalDispatcher = dispatcher + if (internalDispatcher is DriveDispatcher) { + internalDispatcher.handlers = emptyList() + } +} + fun RoutingConfiguration.get(path: String, block: RequestContext.() -> MockResponse) = route("GET", path, block) @@ -79,6 +87,10 @@ data class RequestContext( val parameters: Map, ) +inline fun RequestContext.request() : T { + return Json.decodeFromString(recordedRequest.body.readUtf8()) +} + private fun RoutingConfiguration.route( method: String, path: String, diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/api/TrashApiDataSource.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/api/TrashApiDataSource.kt new file mode 100644 index 00000000..b944d3fc --- /dev/null +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/api/TrashApiDataSource.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.test.api + +import me.proton.core.drive.base.data.api.ProtonApiCode +import me.proton.core.drive.base.data.api.response.CodeResponse +import me.proton.core.drive.base.data.api.response.Response +import me.proton.core.drive.link.data.api.response.LinkResponse +import me.proton.core.drive.link.data.api.response.LinkResponses +import me.proton.core.drive.trash.data.api.request.LinkIDsRequest +import okhttp3.mockwebserver.MockWebServer + +fun MockWebServer.trash() = routing { + delete("/drive/volumes/{volumeId}/trash") { + jsonResponse { + CodeResponse( + code = ProtonApiCode.SUCCESS.toInt(), + ) + } + } +} + +fun MockWebServer.trashMultiple( + responsePerLink: (String) -> LinkResponse = { linkId -> + LinkResponse(linkId, response = Response(ProtonApiCode.SUCCESS)) + }, +) = routing { + post("/drive/shares/{enc_shareID}/folders/{enc_linkID}/trash_multiple") { + jsonResponse { + LinkResponses( + code = ProtonApiCode.SUCCESS, + responses = with(request()) { linkIDs.map(responsePerLink) } + ) + } + } +} + +fun MockWebServer.restoreMultiple( + responsePerLink: (String) -> LinkResponse = { linkId -> + LinkResponse(linkId, response = Response(ProtonApiCode.SUCCESS)) + }, +) = routing { + put("/drive/shares/{enc_shareID}/trash/restore_multiple") { + jsonResponse { + LinkResponses( + code = ProtonApiCode.SUCCESS, + responses = with(request()) { linkIDs.map(responsePerLink) } + ) + } + } +} + +fun MockWebServer.deleteMultiple( + responsePerLink: (String) -> LinkResponse = { linkId -> + LinkResponse(linkId, response = Response(ProtonApiCode.SUCCESS)) + }, +) = routing { + post("/drive/shares/{enc_shareID}/trash/delete_multiple") { + jsonResponse { + LinkResponses( + code = ProtonApiCode.SUCCESS, + responses = with(request()) { linkIDs.map(responsePerLink) } + ) + } + } +} diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/crypto/FakePGPCrypto.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/crypto/FakePGPCrypto.kt index 66903f12..4b9b61fb 100644 --- a/drive/test/src/main/kotlin/me/proton/core/drive/test/crypto/FakePGPCrypto.kt +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/crypto/FakePGPCrypto.kt @@ -244,6 +244,14 @@ class FakePGPCrypto : PGPCrypto { return sessionKey.key } + override fun encryptMessageToAdditionalKey( + message: EncryptedMessage, + unlockedKey: Unarmored, + publicKey: Armored + ): EncryptedMessage { + return message + } + override fun encryptText(plainText: String, publicKey: Armored): EncryptedMessage { return plainText } diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/di/ApplicationModule.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/ApplicationModule.kt index 7e075f54..31008f4d 100644 --- a/drive/test/src/main/kotlin/me/proton/core/drive/test/di/ApplicationModule.kt +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/ApplicationModule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -29,9 +29,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ElementsIntoSet import me.proton.core.account.domain.entity.AccountType import me.proton.core.domain.entity.AppStore import me.proton.core.domain.entity.Product +import me.proton.core.drive.announce.event.domain.handler.EventHandler import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.test.TestConfigurationProvider import javax.inject.Singleton @@ -73,6 +75,11 @@ object ApplicationModule { @Singleton fun provideActivityManager(@ApplicationContext context: Context): ActivityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + + @Provides + @Singleton + @ElementsIntoSet + fun provideEventHandlers() = setOf() } @Module diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/di/DriveDatabaseModule.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/DriveDatabaseModule.kt index 4c554276..8c72491e 100644 --- a/drive/test/src/main/kotlin/me/proton/core/drive/test/di/DriveDatabaseModule.kt +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/DriveDatabaseModule.kt @@ -27,16 +27,22 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import me.proton.android.drive.db.DriveDatabase +import me.proton.android.drive.photos.data.db.MediaStoreVersionDatabase import me.proton.core.account.data.db.AccountDatabase import me.proton.core.challenge.data.db.ChallengeDatabase +import me.proton.core.contact.data.local.db.ContactDatabase import me.proton.core.drive.backup.data.db.BackupDatabase +import me.proton.core.drive.base.data.db.BaseDatabase +import me.proton.core.drive.device.data.db.DeviceDatabase import me.proton.core.drive.drivelink.data.db.DriveLinkDatabase import me.proton.core.drive.drivelink.download.data.db.DriveLinkDownloadDatabase import me.proton.core.drive.drivelink.offline.data.db.DriveLinkOfflineDatabase import me.proton.core.drive.drivelink.paged.data.db.DriveLinkPagedDatabase +import me.proton.core.drive.drivelink.photo.data.db.DriveLinkPhotoDatabase import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase import me.proton.core.drive.drivelink.shared.data.db.DriveLinkSharedDatabase import me.proton.core.drive.drivelink.trash.data.db.DriveLinkTrashDatabase +import me.proton.core.drive.feature.flag.data.db.DriveFeatureFlagDatabase import me.proton.core.drive.folder.data.db.FolderDatabase import me.proton.core.drive.link.data.db.LinkDatabase import me.proton.core.drive.link.selection.data.db.LinkSelectionDatabase @@ -47,9 +53,12 @@ import me.proton.core.drive.linktrash.data.db.LinkTrashDatabase import me.proton.core.drive.linkupload.data.db.LinkUploadDatabase import me.proton.core.drive.messagequeue.data.storage.db.MessageQueueDatabase import me.proton.core.drive.notification.data.db.NotificationDatabase +import me.proton.core.drive.photo.data.db.PhotoDatabase import me.proton.core.drive.share.data.db.ShareDatabase import me.proton.core.drive.shareurl.base.data.db.ShareUrlDatabase import me.proton.core.drive.sorting.data.db.SortingDatabase +import me.proton.core.drive.stats.data.db.StatsDatabase +import me.proton.core.drive.user.data.db.UserMessageDatabase import me.proton.core.drive.volume.data.db.VolumeDatabase import me.proton.core.drive.worker.data.db.WorkerDatabase import me.proton.core.eventmanager.data.db.EventMetadataDatabase @@ -60,6 +69,8 @@ import me.proton.core.key.data.db.PublicAddressDatabase import me.proton.core.keytransparency.data.local.KeyTransparencyDatabase import me.proton.core.observability.data.db.ObservabilityDatabase import me.proton.core.payment.data.local.db.PaymentDatabase +import me.proton.core.push.data.local.db.PushDatabase +import me.proton.core.telemetry.data.db.TelemetryDatabase import me.proton.core.user.data.db.AddressDatabase import me.proton.core.user.data.db.UserDatabase import me.proton.core.usersettings.data.db.OrganizationDatabase @@ -128,6 +139,9 @@ abstract class DriveDatabaseBindsModule { @Binds abstract fun provideAddressDatabase(db: DriveDatabase): AddressDatabase + @Binds + abstract fun provideContactDatabase(db: DriveDatabase): ContactDatabase + @Binds abstract fun provideFeatureFlagDatabase(db: DriveDatabase): FeatureFlagDatabase @@ -185,6 +199,9 @@ abstract class DriveDatabaseBindsModule { @Binds abstract fun provideBackupDatabase(db: DriveDatabase): BackupDatabase + @Binds + abstract fun provideUserMessageDatabase(db: DriveDatabase): UserMessageDatabase + @Binds abstract fun provideObservabilityDatabase(db: DriveDatabase): ObservabilityDatabase @@ -193,4 +210,31 @@ abstract class DriveDatabaseBindsModule { @Binds abstract fun provideWorkerDatabase(db: DriveDatabase): WorkerDatabase + + @Binds + abstract fun providePushDatabase(appDatabase: DriveDatabase): PushDatabase + + @Binds + abstract fun provideTelemetryDatabase(appDatabase: DriveDatabase): TelemetryDatabase + + @Binds + abstract fun provideStatsDatabase(appDatabase: DriveDatabase): StatsDatabase + + @Binds + abstract fun providePhotoDatabase(appDatabase: DriveDatabase): PhotoDatabase + + @Binds + abstract fun provideDriveLinkPhotoDatabase(appDatabase: DriveDatabase): DriveLinkPhotoDatabase + + @Binds + abstract fun provideDriveFeatureFlagDatabase(appDatabase: DriveDatabase): DriveFeatureFlagDatabase + + @Binds + abstract fun provideMediaStoreVersionDatabase(appDatabase: DriveDatabase): MediaStoreVersionDatabase + + @Binds + abstract fun provideDeviceDatabase(appDatabase: DriveDatabase): DeviceDatabase + + @Binds + abstract fun provideBaseDatabase(appDatabase: DriveDatabase): BaseDatabase } diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestBaseBindModule.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestBaseBindModule.kt index e49e5d04..11bab8a7 100644 --- a/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestBaseBindModule.kt +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestBaseBindModule.kt @@ -24,9 +24,11 @@ import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import me.proton.core.drive.base.data.di.BaseBindModule import me.proton.core.drive.base.data.formatter.DateTimeFormatterImpl +import me.proton.core.drive.base.data.repository.BaseRepositoryImpl import me.proton.core.drive.base.data.usecase.CopyToClipboardImpl import me.proton.core.drive.base.data.usecase.Sha256Impl import me.proton.core.drive.base.domain.formatter.DateTimeFormatter +import me.proton.core.drive.base.domain.repository.BaseRepository import me.proton.core.drive.base.domain.usecase.CopyToClipboard import me.proton.core.drive.base.domain.usecase.GetInternalStorageInfo import me.proton.core.drive.base.domain.usecase.GetMemoryInfo @@ -61,4 +63,8 @@ interface TestBaseBindModule { @Binds @Singleton fun bindsGetInternalStorageInfoImpl(impl: TestGetInternalStorageInfo): GetInternalStorageInfo + + @Binds + @Singleton + fun bindsRepositoryImpl(impl: BaseRepositoryImpl): BaseRepository } diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestEventManagerModule.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestEventManagerModule.kt new file mode 100644 index 00000000..04841f4b --- /dev/null +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestEventManagerModule.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.test.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.core.drive.test.eventmanager.TestEventManagerProvider +import me.proton.core.eventmanager.domain.EventManagerProvider +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface TestEventManagerModule { + @Binds + @Singleton + fun bindsEventManagerProvider(impl: TestEventManagerProvider): EventManagerProvider +} diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestMessageQueueModule.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestMessageQueueModule.kt new file mode 100644 index 00000000..3c072bca --- /dev/null +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/di/TestMessageQueueModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.test.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import me.proton.core.drive.messagequeue.data.MessageQueueImpl +import me.proton.core.drive.messagequeue.data.storage.RoomStorage +import me.proton.core.drive.messagequeue.data.storage.db.MessageQueueDatabase +import me.proton.core.drive.messagequeue.domain.MessageQueue +import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage +import javax.inject.Singleton + +@ExperimentalCoroutinesApi +@Module +@InstallIn(SingletonComponent::class) +object TestMessageQueueModule { + + @Provides + @Singleton + fun providesMessageQueue(database: MessageQueueDatabase): MessageQueue = + MessageQueueImpl(RoomStorage(database)) +} diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/eventmanager/TestEventManager.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/eventmanager/TestEventManager.kt new file mode 100644 index 00000000..721e070f --- /dev/null +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/eventmanager/TestEventManager.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.test.eventmanager + +import me.proton.core.eventmanager.domain.EventListener +import me.proton.core.eventmanager.domain.EventManager +import me.proton.core.eventmanager.domain.EventManagerConfig +import me.proton.core.eventmanager.domain.entity.EventId +import me.proton.core.eventmanager.domain.entity.EventMetadata +import me.proton.core.eventmanager.domain.entity.EventsResponse + +class TestEventManager(override val config: EventManagerConfig) : EventManager { + + override val isStarted: Boolean + get() = TODO("Not yet implemented") + + override suspend fun deserializeEventMetadata( + eventId: EventId, + response: EventsResponse, + ): EventMetadata { + TODO("Not yet implemented") + } + + override suspend fun getEventResponse(eventId: EventId): EventsResponse { + TODO("Not yet implemented") + } + + override suspend fun getLatestEventId(): EventId { + TODO("Not yet implemented") + } + + override suspend fun process() { + TODO("Not yet implemented") + } + + override suspend fun start() { + TODO("Not yet implemented") + } + + override suspend fun stop() { + TODO("Not yet implemented") + } + + override fun subscribe(eventListener: EventListener<*, *>) { + TODO("Not yet implemented") + } + + override suspend fun suspend(block: suspend () -> R): R { + return block() + } +} diff --git a/drive/test/src/main/kotlin/me/proton/core/drive/test/eventmanager/TestEventManagerProvider.kt b/drive/test/src/main/kotlin/me/proton/core/drive/test/eventmanager/TestEventManagerProvider.kt new file mode 100644 index 00000000..2a1ec4a8 --- /dev/null +++ b/drive/test/src/main/kotlin/me/proton/core/drive/test/eventmanager/TestEventManagerProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.test.eventmanager + +import me.proton.core.domain.entity.UserId +import me.proton.core.eventmanager.domain.EventManager +import me.proton.core.eventmanager.domain.EventManagerConfig +import me.proton.core.eventmanager.domain.EventManagerProvider +import javax.inject.Inject + +class TestEventManagerProvider @Inject constructor() : EventManagerProvider { + + override suspend fun get(config: EventManagerConfig): EventManager { + return TestEventManager(config) + } + + override suspend fun getAll(userId: UserId): List { + return emptyList() + } +} diff --git a/drive/trash/data-test/build.gradle.kts b/drive/trash/data-test/build.gradle.kts index 3ca013d3..ee387f8e 100644 --- a/drive/trash/data-test/build.gradle.kts +++ b/drive/trash/data-test/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -27,6 +27,14 @@ android { driveModule( hilt = true, ) { + api(project(":drive:base:data")) + api(project(":drive:event-manager:data")) + api(project(":drive:link:data")) api(project(":drive:trash:domain")) - api(project(":drive:base:data-test")) + api(project(":drive:volume:data")) + + implementation(project(":drive:trash:data")) + implementation(libs.androidx.work.testing) + implementation(libs.androidx.test.core.ktx) + implementation(libs.dagger.hilt.android.testing) } diff --git a/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/di/TestDriveTrashManagerModule.kt b/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/di/TestDriveTrashManagerModule.kt new file mode 100644 index 00000000..6921f930 --- /dev/null +++ b/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/di/TestDriveTrashManagerModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.data.test.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.ExperimentalCoroutinesApi +import me.proton.core.drive.trash.data.di.DriveTrashManagerModule +import me.proton.core.drive.trash.data.test.manager.StubbedTrashManager +import me.proton.core.drive.trash.domain.TrashManager + +@Suppress("unused") +@ExperimentalCoroutinesApi +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DriveTrashManagerModule::class], +) +interface TestDriveTrashManagerModule { + @Binds + fun bindTrashManager(impl: StubbedTrashManager): TrashManager + +} diff --git a/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/manager/StubbedTrashManager.kt b/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/manager/StubbedTrashManager.kt index 41f0f4ed..22eaee9f 100644 --- a/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/manager/StubbedTrashManager.kt +++ b/drive/trash/data-test/src/main/kotlin/me/proton/core/drive/trash/data/test/manager/StubbedTrashManager.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -17,69 +17,221 @@ */ package me.proton.core.drive.trash.data.test.manager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.onSuccess import me.proton.core.domain.entity.UserId -import me.proton.core.drive.base.data.test.manager.StubbedWorkManager +import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.base.domain.usecase.BroadcastMessages +import me.proton.core.drive.eventmanager.base.domain.usecase.UpdateEventAction +import me.proton.core.drive.eventmanager.usecase.HandleOnDeleteEvent import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.link.domain.entity.LinkId +import me.proton.core.drive.link.domain.repository.LinkRepository import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository import me.proton.core.drive.share.domain.entity.ShareId +import me.proton.core.drive.share.domain.usecase.GetShare +import me.proton.core.drive.trash.data.manager.worker.EmptyTrashSuccessWorker +import me.proton.core.drive.trash.data.manager.worker.EmptyTrashWorker +import me.proton.core.drive.trash.data.manager.worker.PermanentlyDeleteFileNodesWorker +import me.proton.core.drive.trash.data.manager.worker.RestoreFileNodesWorker +import me.proton.core.drive.trash.data.manager.worker.TrashFileNodesWorker import me.proton.core.drive.trash.domain.TrashManager +import me.proton.core.drive.trash.domain.repository.DriveTrashRepository import me.proton.core.drive.volume.domain.entity.VolumeId +import me.proton.core.drive.volume.domain.usecase.GetVolume import javax.inject.Inject import javax.inject.Singleton @Singleton -@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LongParameterList") class StubbedTrashManager @Inject constructor( - private val repository: LinkTrashRepository, - private val manager: StubbedWorkManager, + private val driveTrashRepository: DriveTrashRepository, + private val linkTrashRepository: LinkTrashRepository, + private val broadcastMessages: BroadcastMessages, + private val updateEventAction: UpdateEventAction, + private val linkRepository: LinkRepository, + private val handleOnDeleteEvent: HandleOnDeleteEvent, + private val getShare: GetShare, + private val getVolume: GetVolume, ) : TrashManager { + private val context by lazy { ApplicationProvider.getApplicationContext() } + + private val emptyTrashState = MutableStateFlow(false) override suspend fun trash( userId: UserId, folderId: FolderId, - linkIds: List - ): DataResult = manager.add("trash", userId, folderId, linkIds) + linkIds: List, + ): DataResult = linkTrashRepository.insertWork(linkIds).onSuccess { workId -> + TestListenableWorkerBuilder(context) + .setWorkerFactory(object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ) = TrashFileNodesWorker( + appContext = appContext, + params = workerParameters, + driveTrashRepository = driveTrashRepository, + linkTrashRepository = linkTrashRepository, + broadcastMessages = broadcastMessages, + updateEventAction = updateEventAction, + linkRepository = linkRepository, + handleOnDeleteEvent = handleOnDeleteEvent, + getShare = getShare, + ) + + }) + .setInputData( + TrashFileNodesWorker.workDataOf(userId, folderId, workId) + ) + .build() + .doWork() + } override suspend fun restore( userId: UserId, shareId: ShareId, - linkIds: List - ): DataResult = manager.add("restore", userId, shareId, linkIds) + linkIds: List, + ): DataResult = linkTrashRepository.insertWork(linkIds).onSuccess { workId -> + TestListenableWorkerBuilder(context) + .setWorkerFactory(object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ) = RestoreFileNodesWorker( + appContext = appContext, + params = workerParameters, + driveTrashRepository = driveTrashRepository, + linkTrashRepository = linkTrashRepository, + broadcastMessages = broadcastMessages, + updateEventAction = updateEventAction, + getShare = getShare, + ) + + }) + .setInputData( + RestoreFileNodesWorker.workDataOf(userId, shareId, workId) + ) + .build() + .doWork() + + } override suspend fun delete( userId: UserId, shareId: ShareId, - linkIds: List - ): DataResult = manager.add("delete", userId, shareId, linkIds) + linkIds: List, + ): DataResult = linkTrashRepository.insertWork(linkIds).onSuccess { workId -> + TestListenableWorkerBuilder(context) + .setWorkerFactory(object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ) = PermanentlyDeleteFileNodesWorker( + appContext = appContext, + params = workerParameters, + driveTrashRepository = driveTrashRepository, + linkRepository = linkRepository, + trashRepository = linkTrashRepository, + broadcastMessages = broadcastMessages, + updateEventAction = updateEventAction, + getShare = getShare, + linkTrashRepository = linkTrashRepository, + ) - override suspend fun emptyTrash(userId: UserId, volumeId: VolumeId) { - manager.add("emptyTrash", userId, volumeId) + }) + .setInputData( + PermanentlyDeleteFileNodesWorker.workDataOf(userId, shareId, workId) + ) + .build() + .doWork() } + override suspend fun emptyTrash( + userId: UserId, + volumeId: VolumeId, + ) { + emptyTrashState.value = true + val volume = getVolume(userId, volumeId).toResult().getOrThrow() + val share = getShare(ShareId(userId, volume.shareId)).toResult().getOrThrow() + val result = TestListenableWorkerBuilder(context) + .setWorkerFactory(object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ) = EmptyTrashWorker( + appContext = appContext, + params = workerParameters, + driveTrashRepository = driveTrashRepository, + broadcastMessages = broadcastMessages, + updateEventAction = updateEventAction, + getShare = getShare, + ) + + }) + .setInputData( + EmptyTrashWorker.workDataOf(userId, share.id) + ) + .build() + .doWork() + if (result == ListenableWorker.Result.success()) { + TestListenableWorkerBuilder(context) + .setWorkerFactory(object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ) = EmptyTrashSuccessWorker( + appContext = appContext, + params = workerParameters, + broadcastMessages = broadcastMessages, + ) + + }) + .setInputData( + EmptyTrashSuccessWorker.workDataOf(userId) + ) + .build() + .doWork() + } + emptyTrashState.value = false + } + + @OptIn(ExperimentalCoroutinesApi::class) override fun getEmptyTrashState( userId: UserId, volumeId: VolumeId, ): Flow { - return manager.works.flatMapLatest { works -> - if (works.isNotEmpty()) { - flowOf(TrashManager.EmptyTrashState.TRASHING) + return emptyTrashState.transformLatest { trashing -> + if (trashing) { + emit(TrashManager.EmptyTrashState.TRASHING) } else { - repository.hasTrashContent(userId, volumeId).map { hasTrashContent -> - if (hasTrashContent) { - TrashManager.EmptyTrashState.INACTIVE - } else { - TrashManager.EmptyTrashState.NO_FILES_TO_TRASH + emitAll( + linkTrashRepository.hasTrashContent(userId, volumeId).map { hasTrashContent -> + if (hasTrashContent) { + TrashManager.EmptyTrashState.INACTIVE + } else { + TrashManager.EmptyTrashState.NO_FILES_TO_TRASH + } } - } + ) } } - } -} \ No newline at end of file +} diff --git a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/di/DriveTrashManagerModule.kt b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/di/DriveTrashManagerModule.kt new file mode 100644 index 00000000..917e3e46 --- /dev/null +++ b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/di/DriveTrashManagerModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.trash.data.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import me.proton.core.drive.trash.data.manager.TrashManagerImpl +import me.proton.core.drive.trash.domain.TrashManager + +@ExperimentalCoroutinesApi +@Module +@InstallIn(SingletonComponent::class) +interface DriveTrashManagerModule { + + @Binds + fun bindTrashManager(manager: TrashManagerImpl): TrashManager +} diff --git a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/di/DriveTrashModule.kt b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/di/DriveTrashModule.kt index 34436b54..201f2f55 100644 --- a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/di/DriveTrashModule.kt +++ b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/di/DriveTrashModule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -25,9 +25,7 @@ import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet import kotlinx.coroutines.ExperimentalCoroutinesApi import me.proton.core.drive.messagequeue.domain.ActionProvider -import me.proton.core.drive.trash.data.manager.TrashManagerImpl import me.proton.core.drive.trash.data.repository.DriveTrashRepositoryImpl -import me.proton.core.drive.trash.domain.TrashManager import me.proton.core.drive.trash.domain.notification.TrashExtraActionProvider import me.proton.core.drive.trash.domain.repository.DriveTrashRepository @@ -36,9 +34,6 @@ import me.proton.core.drive.trash.domain.repository.DriveTrashRepository @InstallIn(SingletonComponent::class) interface DriveTrashModule { - @Binds - fun bindTrashManager(manager: TrashManagerImpl): TrashManager - @Binds fun bindDriveTrashRepository(repository: DriveTrashRepositoryImpl): DriveTrashRepository diff --git a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/EmptyTrashSuccessWorker.kt b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/EmptyTrashSuccessWorker.kt index 1a3d0d46..13d2ba19 100644 --- a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/EmptyTrashSuccessWorker.kt +++ b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/EmptyTrashSuccessWorker.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -56,11 +56,13 @@ class EmptyTrashSuccessWorker @AssistedInject constructor( tags: List = emptyList(), ): OneTimeWorkRequest = OneTimeWorkRequest.Builder(EmptyTrashSuccessWorker::class.java) .setInputData( - Data.Builder() - .putString(KEY_USER_ID, userId.id) - .build() + workDataOf(userId) ) .addTags(listOf(userId.id) + tags) .build() + + fun workDataOf(userId: UserId) = Data.Builder() + .putString(KEY_USER_ID, userId.id) + .build() } } diff --git a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/EmptyTrashWorker.kt b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/EmptyTrashWorker.kt index e9bf9081..1177a0ae 100644 --- a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/EmptyTrashWorker.kt +++ b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/EmptyTrashWorker.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -84,10 +84,7 @@ class EmptyTrashWorker @AssistedInject constructor( tags: List = emptyList(), ): OneTimeWorkRequest = OneTimeWorkRequest.Builder(EmptyTrashWorker::class.java) .setInputData( - Data.Builder() - .putString(KEY_USER_ID, userId.id) - .putString(KEY_SHARE_ID, shareId.id) - .build() + workDataOf(userId, shareId) ) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, @@ -96,5 +93,13 @@ class EmptyTrashWorker @AssistedInject constructor( ) .addTags(listOf(userId.id) + tags) .build() + + fun workDataOf( + userId: UserId, + shareId: ShareId, + ) = Data.Builder() + .putString(KEY_USER_ID, userId.id) + .putString(KEY_SHARE_ID, shareId.id) + .build() } } diff --git a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/PermanentlyDeleteFileNodesWorker.kt b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/PermanentlyDeleteFileNodesWorker.kt index acd485bf..b03c7ddd 100644 --- a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/PermanentlyDeleteFileNodesWorker.kt +++ b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/PermanentlyDeleteFileNodesWorker.kt @@ -111,11 +111,7 @@ class PermanentlyDeleteFileNodesWorker @AssistedInject constructor( ): OneTimeWorkRequest = OneTimeWorkRequest.Builder(PermanentlyDeleteFileNodesWorker::class.java) .setInputData( - Data.Builder() - .putString(KEY_USER_ID, userId.id) - .putString(KEY_SHARE_ID, shareId.id) - .putString(KEY_WORK_ID, workId) - .build() + workDataOf(userId, shareId, workId) ) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, @@ -124,5 +120,15 @@ class PermanentlyDeleteFileNodesWorker @AssistedInject constructor( ) .addTags(listOf(userId.id) + tags) .build() + + fun workDataOf( + userId: UserId, + shareId: ShareId, + workId: String, + ) = Data.Builder() + .putString(KEY_USER_ID, userId.id) + .putString(KEY_SHARE_ID, shareId.id) + .putString(KEY_WORK_ID, workId) + .build() } } diff --git a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/RestoreFileNodesWorker.kt b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/RestoreFileNodesWorker.kt index d6d453d3..f13ae255 100644 --- a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/RestoreFileNodesWorker.kt +++ b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/RestoreFileNodesWorker.kt @@ -106,11 +106,7 @@ class RestoreFileNodesWorker @AssistedInject constructor( tags: List = emptyList(), ): OneTimeWorkRequest = OneTimeWorkRequest.Builder(RestoreFileNodesWorker::class.java) .setInputData( - Data.Builder() - .putString(KEY_USER_ID, userId.id) - .putString(KEY_SHARE_ID, shareId.id) - .putString(KEY_WORK_ID, workId) - .build() + workDataOf(userId, shareId, workId) ) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, @@ -119,5 +115,15 @@ class RestoreFileNodesWorker @AssistedInject constructor( ) .addTags(listOf(userId.id) + tags) .build() + + fun workDataOf( + userId: UserId, + shareId: ShareId, + workId: String, + ) = Data.Builder() + .putString(KEY_USER_ID, userId.id) + .putString(KEY_SHARE_ID, shareId.id) + .putString(KEY_WORK_ID, workId) + .build() } } diff --git a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/TrashFileNodesWorker.kt b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/TrashFileNodesWorker.kt index f4e949f5..c7dda068 100644 --- a/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/TrashFileNodesWorker.kt +++ b/drive/trash/data/src/main/kotlin/me/proton/core/drive/trash/data/manager/worker/TrashFileNodesWorker.kt @@ -118,7 +118,10 @@ class TrashFileNodesWorker @AssistedInject constructor( error.log(TRASH, "Cannot get link ${linkId.id.logId()}") error.onProtonHttpException { protonCode -> if (protonCode == NOT_EXISTS) { - handleOnDeleteEvent(linkIds) + handleOnDeleteEvent(listOf(linkId), stopOnFailure = true) + .onFailure { error -> + error.log(TRASH) + } } } } @@ -134,12 +137,7 @@ class TrashFileNodesWorker @AssistedInject constructor( tags: List = emptyList(), ): OneTimeWorkRequest = OneTimeWorkRequest.Builder(TrashFileNodesWorker::class.java) .setInputData( - Data.Builder() - .putString(KEY_USER_ID, userId.id) - .putString(KEY_SHARE_ID, folderId.shareId.id) - .putString(KEY_FOLDER_ID, folderId.id) - .putString(KEY_WORK_ID, workId) - .build() + workDataOf(userId, folderId, workId) ) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, @@ -148,5 +146,16 @@ class TrashFileNodesWorker @AssistedInject constructor( ) .addTags(listOf(userId.id) + tags) .build() + + fun workDataOf( + userId: UserId, + folderId: FolderId, + workId: String, + ) = Data.Builder() + .putString(KEY_USER_ID, userId.id) + .putString(KEY_SHARE_ID, folderId.shareId.id) + .putString(KEY_FOLDER_ID, folderId.id) + .putString(KEY_WORK_ID, workId) + .build() } } diff --git a/drive/trash/domain/build.gradle.kts b/drive/trash/domain/build.gradle.kts index d5723b63..ce81d220 100644 --- a/drive/trash/domain/build.gradle.kts +++ b/drive/trash/domain/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021-2023 Proton AG. + * Copyright (c) 2021-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -27,6 +27,7 @@ android { driveModule( hilt = true, room = true, + socialTest = true, i18n = true, ) { api(project(":drive:link-trash:domain")) @@ -35,13 +36,10 @@ driveModule( implementation(project(":drive:message-queue:domain")) implementation(project(":drive:share:domain")) - testImplementation(libs.dagger.hilt.android.testing) - add("kaptTest", libs.dagger.hilt.compiler) - + testImplementation(project(":drive:drivelink-download:data-test")) + testImplementation(project(":drive:file:base:data")) + testImplementation(project(":drive:message-queue:data")) testImplementation(project(":drive:trash:data-test")) - testImplementation(project(":drive:link-trash:data-test")) - testImplementation(project(":drive:link:data-test")) - testImplementation(project(":drive:share:data-test")) } -configureJacoco() \ No newline at end of file +configureJacoco() diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/notification/TrashExtraActionProviderTest.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/notification/TrashExtraActionProviderTest.kt index fc7c4285..61095986 100644 --- a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/notification/TrashExtraActionProviderTest.kt +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/notification/TrashExtraActionProviderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -17,21 +17,33 @@ */ package me.proton.core.drive.trash.domain.notification -import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.coEvery import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.data.api.ProtonApiCode +import me.proton.core.drive.base.data.api.response.Response +import me.proton.core.drive.base.domain.extension.firstSuccessOrError +import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.db.test.file +import me.proton.core.drive.db.test.myFiles +import me.proton.core.drive.db.test.userId +import me.proton.core.drive.drivelink.domain.usecase.GetDriveLink +import me.proton.core.drive.link.data.api.response.LinkResponse import me.proton.core.drive.link.domain.entity.FileId import me.proton.core.drive.link.domain.entity.FolderId -import me.proton.core.drive.linktrash.data.test.repository.state import me.proton.core.drive.linktrash.domain.entity.TrashState -import me.proton.core.drive.linktrash.domain.repository.LinkTrashRepository -import me.proton.core.drive.share.domain.entity.ShareId -import org.junit.Assert.* +import me.proton.core.drive.test.DriveRule +import me.proton.core.drive.test.api.clear +import me.proton.core.drive.test.api.deleteMultiple +import me.proton.core.drive.test.api.restoreMultiple +import me.proton.core.drive.test.api.trashMultiple +import me.proton.core.drive.trash.domain.usecase.DeleteFromTrash +import me.proton.core.drive.trash.domain.usecase.EmptyTrash +import me.proton.core.drive.trash.domain.usecase.RestoreFromTrash +import me.proton.core.drive.trash.domain.usecase.SendToTrash +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -42,23 +54,36 @@ import javax.inject.Inject @HiltAndroidTest @RunWith(RobolectricTestRunner::class) class TrashExtraActionProviderTest { + @get:Rule - var hiltRule = HiltAndroidRule(this) + var driveRule = DriveRule(this) + private lateinit var folderId: FolderId + private lateinit var fileId: FileId @Inject - lateinit var repository: LinkTrashRepository + lateinit var getDriveLink: GetDriveLink @Inject lateinit var actionProvider: TrashExtraActionProvider - private val userId = UserId("user-id") - private val shareId = ShareId(userId, "share-id") - private val folderId = FolderId(shareId, "folder-id") - private val fileId = FileId(shareId, "file-id") + @Inject + lateinit var deleteFromTrash: DeleteFromTrash + + @Inject + lateinit var emptyTrash: EmptyTrash + + @Inject + lateinit var sendToTrash: SendToTrash + + @Inject + lateinit var restoreFromTrash: RestoreFromTrash @Before - fun setUp() { - hiltRule.inject() + fun setUp() = runTest { + folderId = driveRule.db.myFiles { + file("file-id-1") + } + fileId = FileId(folderId.shareId, "file-id-1") } @Test @@ -67,7 +92,7 @@ class TrashExtraActionProviderTest { actionProvider.provideAction( DeleteFilesExtra( userId = userId, - shareId = shareId, + shareId = folderId.shareId, links = listOf(fileId), ) ) @@ -75,18 +100,19 @@ class TrashExtraActionProviderTest { } @Test - @Ignore("Breaks with getShare usage in deleteFromTrash use case") fun `exception during delete`() = runTest { + driveRule.server.deleteMultiple() + actionProvider.provideAction( DeleteFilesExtra( userId = userId, - shareId = shareId, + shareId = folderId.shareId, links = listOf(fileId), exception = RuntimeException(), ) - )?.invoke() + )!!.invoke() - assertEquals(TrashState.DELETING, repository.state[listOf(fileId)]) + assertNull(getFile(fileId).getOrNull()) } @Test @@ -95,7 +121,7 @@ class TrashExtraActionProviderTest { actionProvider.provideAction( EmptyTrashExtra( userId = userId, - shareId = shareId, + shareId = folderId.shareId, ) ) ) @@ -107,7 +133,7 @@ class TrashExtraActionProviderTest { actionProvider.provideAction( EmptyTrashExtra( userId = userId, - shareId = shareId, + shareId = folderId.shareId, exception = RuntimeException(), ) @@ -121,7 +147,7 @@ class TrashExtraActionProviderTest { actionProvider.provideAction( RestoreFilesExtra( userId = userId, - shareId = shareId, + shareId = folderId.shareId, links = listOf(fileId), ) ) @@ -129,36 +155,58 @@ class TrashExtraActionProviderTest { } @Test - @Ignore("Breaks with getShare usage in restoreFromTrash use case") fun `exception during restore`() = runTest { + driveRule.server.run { + restoreMultiple { linkId -> + LinkResponse(linkId, response = Response(ProtonApiCode.INVALID_VALUE.toLong())) + } + } + + restoreFromTrash(userId, fileId) + + assertTrashStateOf(fileId, TrashState.TRASHED) + + driveRule.server.run { + clear() + restoreMultiple() + } + actionProvider.provideAction( RestoreFilesExtra( userId = userId, - shareId = shareId, + shareId = folderId.shareId, links = listOf(fileId), exception = RuntimeException(), ) - )?.invoke() + )!!.invoke() - assertEquals(TrashState.RESTORING, repository.state[listOf(fileId)]) + assertTrashStateOf(fileId, null) } @Test - @Ignore("Breaks with getShare usage in restoreFromTrash use case") fun `no exception during trash`() = runTest { + driveRule.server.run { + trashMultiple() + restoreMultiple() + } + sendToTrash(userId, getFile(fileId).getOrThrow()) + + assertTrashStateOf(fileId, TrashState.TRASHED) + actionProvider.provideAction( TrashFilesExtra( userId = userId, folderId = folderId, links = listOf(fileId), ) - )?.invoke() + )!!.invoke() - assertEquals(TrashState.RESTORING, repository.state[listOf(fileId)]) + assertTrashStateOf(fileId, null) } @Test - @Ignore("Breaks with getShare usage in sendToTrash use case") fun `exception during trash`() = runTest { + driveRule.server.trashMultiple() + actionProvider.provideAction( TrashFilesExtra( userId = userId, @@ -166,8 +214,18 @@ class TrashExtraActionProviderTest { links = listOf(fileId), exception = RuntimeException(), ) - )?.invoke() + )!!.invoke() - assertEquals(TrashState.TRASHING, repository.state[listOf(fileId)]) + assertTrashStateOf(fileId, TrashState.TRASHED) } -} \ No newline at end of file + + private suspend fun assertTrashStateOf(fileId: FileId, trashState: TrashState?) { + assertEquals( + trashState, + getFile(fileId).getOrThrow().trashState + ) + } + + private suspend fun getFile(fileId: FileId) = + getDriveLink(fileId).firstSuccessOrError().toResult() +} diff --git a/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/TrashTest.kt b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/TrashTest.kt new file mode 100644 index 00000000..2af16034 --- /dev/null +++ b/drive/trash/domain/src/test/kotlin/me/proton/core/drive/trash/domain/usecase/TrashTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ +package me.proton.core.drive.trash.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.base.domain.extension.firstSuccessOrError +import me.proton.core.drive.base.domain.extension.toResult +import me.proton.core.drive.db.test.file +import me.proton.core.drive.db.test.folder +import me.proton.core.drive.db.test.myFiles +import me.proton.core.drive.db.test.userId +import me.proton.core.drive.db.test.volumeId +import me.proton.core.drive.drivelink.domain.usecase.GetDriveLink +import me.proton.core.drive.link.domain.entity.FileId +import me.proton.core.drive.link.domain.entity.FolderId +import me.proton.core.drive.linktrash.domain.entity.TrashState +import me.proton.core.drive.share.domain.usecase.GetShares +import me.proton.core.drive.test.DriveRule +import me.proton.core.drive.test.api.deleteMultiple +import me.proton.core.drive.test.api.restoreMultiple +import me.proton.core.drive.test.api.trash +import me.proton.core.drive.test.api.trashMultiple +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class TrashTest { + + @get:Rule + var driveRule = DriveRule(this) + private lateinit var folderId: FolderId + private lateinit var fileId1: FileId + private lateinit var fileId2: FileId + private lateinit var fileId3: FileId + + @Inject + lateinit var deleteFromTrash: DeleteFromTrash + + @Inject + lateinit var emptyTrash: EmptyTrash + + @Inject + lateinit var sendToTrash: SendToTrash + + @Inject + lateinit var restoreFromTrash: RestoreFromTrash + + @Inject + lateinit var getDriveLink: GetDriveLink + + @Inject + lateinit var getShares: GetShares + + @Before + fun setUp() = runTest { + folderId = driveRule.db.myFiles { + file("file-id-1") + file("file-id-2") + folder("folder-id-1") { + file("file-id-3") + } + } + fileId1 = FileId(folderId.shareId, "file-id-1") + fileId2 = FileId(folderId.shareId, "file-id-2") + fileId3 = FileId(folderId.shareId, "file-id-3") + } + + @Test + fun deleteFromTrash() = runTest { + driveRule.server.deleteMultiple() + + deleteFromTrash(userId, fileId1) + + assertNull(getFile(fileId1).getOrNull()) + } + + @Test + fun `emptyTrash with volumeId`() = runTest { + driveRule.server.run { + trash() + trashMultiple() + } + sendToTrash(userId, listOf(getFile(fileId1).getOrThrow())) + + emptyTrash(userId, volumeId) + + assertTrashStateOf(fileId1, TrashState.DELETED) + } + + @Test + @Suppress("DEPRECATION") + fun `emptyTrash without volumeId`() = runTest { + driveRule.server.run { + trash() + trashMultiple() + } + sendToTrash(userId, listOf(getFile(fileId1).getOrThrow())) + + emptyTrash(userId) + + assertTrashStateOf(fileId1, TrashState.DELETED) + } + + @Test + fun `sendToTrash same folder`() = runTest { + driveRule.server.trashMultiple() + + sendToTrash(userId, listOf(getFile(fileId1).getOrThrow(), getFile(fileId2).getOrThrow())) + + assertTrashStateOf(fileId1, TrashState.TRASHED) + assertTrashStateOf(fileId2, TrashState.TRASHED) + } + + @Test + fun `sendToTrash two folders`() = runTest { + driveRule.server.trashMultiple() + + sendToTrash(userId, listOf(getFile(fileId1).getOrThrow(), getFile(fileId3).getOrThrow())) + + assertTrashStateOf(fileId1, TrashState.TRASHED) + assertTrashStateOf(fileId3, TrashState.TRASHED) + } + + @Test + fun restoreFromTrash() = runTest { + driveRule.server.restoreMultiple() + + restoreFromTrash(userId, fileId1) + + assertTrashStateOf(fileId1, null) + } + + private suspend fun assertTrashStateOf(fileId: FileId, trashState: TrashState?) { + assertEquals( + trashState, + getFile(fileId).getOrThrow().trashState + ) + } + + private suspend fun getFile(fileId: FileId) = + getDriveLink(fileId, refresh = flowOf(false)).firstSuccessOrError().toResult() +} diff --git a/drive/upload/data/src/main/kotlin/me/proton/core/drive/upload/data/extension/Cursor.kt b/drive/upload/data/src/main/kotlin/me/proton/core/drive/upload/data/extension/Cursor.kt index cb6f572d..e01a717e 100644 --- a/drive/upload/data/src/main/kotlin/me/proton/core/drive/upload/data/extension/Cursor.kt +++ b/drive/upload/data/src/main/kotlin/me/proton/core/drive/upload/data/extension/Cursor.kt @@ -46,7 +46,7 @@ val Cursor.lastModified: TimestampMs? get() { return if (documentLastModified != -1) { TimestampMs(getLong(documentLastModified)) - } else if (mediaDateModified != 1) { + } else if (mediaDateModified != -1) { TimestampS(getLong(mediaDateModified)).toTimestampMs() } else { null diff --git a/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/usecase/CreateUploadFileTest.kt b/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/usecase/CreateUploadFileTest.kt index 1cffc384..5f5fcd41 100644 --- a/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/usecase/CreateUploadFileTest.kt +++ b/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/usecase/CreateUploadFileTest.kt @@ -25,7 +25,7 @@ import me.proton.core.drive.base.domain.entity.TimestampMs import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FolderId @@ -37,7 +37,6 @@ import me.proton.core.drive.linkupload.domain.entity.UploadFileLink import me.proton.core.drive.linkupload.domain.entity.UploadFileProperties import me.proton.core.drive.linkupload.domain.repository.LinkUploadRepository import me.proton.core.drive.upload.domain.resolver.UriResolver -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -70,7 +69,7 @@ class CreateUploadFileTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val configurationProvider = object : ConfigurationProvider { override val host: String = "" override val baseUrl: String = "" @@ -96,7 +95,7 @@ class CreateUploadFileTest { runTest { val uploadFiles = createUploadFile( userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, parentId = folderId, uploadFileDescriptions = listOf( UploadFileDescription( @@ -122,7 +121,7 @@ class CreateUploadFileTest { UploadFileLink( id = 0, userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, shareId = folderId.shareId, parentLinkId = folderId, uriString = "uri", diff --git a/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/usecase/GetNextUploadFileLinksTest.kt b/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/usecase/GetNextUploadFileLinksTest.kt index 0c1e8393..f4c319bb 100644 --- a/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/usecase/GetNextUploadFileLinksTest.kt +++ b/drive/upload/domain/src/test/kotlin/me/proton/core/drive/upload/domain/usecase/GetNextUploadFileLinksTest.kt @@ -38,7 +38,6 @@ import me.proton.core.drive.linkupload.domain.entity.UploadState import me.proton.core.drive.linkupload.domain.usecase.UpdateUploadState import me.proton.core.drive.test.DriveRule import me.proton.core.drive.test.usecase.storageInfo -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -308,7 +307,7 @@ class GetNextUploadFileLinksTest { uriStrings: List, ): List = createUploadFile( userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, parentId = folderId, uploadFileDescriptions = uriStrings.map { UploadFileDescription(it) }, shouldDeleteSourceUri = false, diff --git a/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/UserMessageDatabase.kt b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/UserMessageDatabase.kt new file mode 100644 index 00000000..478008bb --- /dev/null +++ b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/UserMessageDatabase.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.data.db + +import androidx.sqlite.db.SupportSQLiteDatabase +import me.proton.core.data.room.db.Database +import me.proton.core.data.room.db.migration.DatabaseMigration +import me.proton.core.drive.user.data.db.dao.QuotaDao +import me.proton.core.drive.user.data.db.dao.UserMessageDao + +interface UserMessageDatabase : Database { + val quotaDao: QuotaDao + val userMessageDao: UserMessageDao + + companion object { + val MIGRATION_0 = object : DatabaseMigration { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `DismissedQuotaEntity` ( + `user_id` TEXT NOT NULL, + `level` TEXT NOT NULL, + `max_space` INTEGER NOT NULL, + `update_time` INTEGER NOT NULL, + PRIMARY KEY(`user_id`, `level`, `max_space`), + FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) + ON UPDATE NO ACTION ON DELETE CASCADE ) + """.trimIndent() + ) + } + } + val MIGRATION_1 = object : DatabaseMigration { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `DismissedUserMessageEntity` ( + `user_id` TEXT NOT NULL, + `user_message` TEXT NOT NULL, + `update_time` INTEGER NOT NULL, + PRIMARY KEY(`user_id`, `user_message`), + FOREIGN KEY(`user_id`) REFERENCES `AccountEntity`(`userId`) + ON UPDATE NO ACTION ON DELETE CASCADE ) + """.trimIndent() + ) + } + } + } +} diff --git a/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/dao/UserMessageDao.kt b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/dao/UserMessageDao.kt new file mode 100644 index 00000000..e7693e40 --- /dev/null +++ b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/dao/UserMessageDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.data.db.dao + +import androidx.room.Dao +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import me.proton.core.data.room.db.BaseDao +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.user.data.db.entity.DismissedUserMessageEntity +import me.proton.core.drive.user.domain.entity.UserMessage + +@Dao +abstract class UserMessageDao : BaseDao() { + + @Query( + """ + SELECT EXISTS( + SELECT * FROM DismissedUserMessageEntity + WHERE user_id = :userId AND + user_message = :userMessage + ) + """ + ) + abstract fun exists(userId: UserId, userMessage: UserMessage): Flow +} diff --git a/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/entity/DismissedUserMessageEntity.kt b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/entity/DismissedUserMessageEntity.kt new file mode 100644 index 00000000..0cf2a756 --- /dev/null +++ b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/db/entity/DismissedUserMessageEntity.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import me.proton.core.account.data.entity.AccountEntity +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.data.db.Column +import me.proton.core.drive.base.data.db.Column.UPDATE_TIME +import me.proton.core.drive.base.data.db.Column.USER_ID +import me.proton.core.drive.base.data.db.Column.USER_MESSAGE +import me.proton.core.drive.user.domain.entity.UserMessage + +@Entity( + primaryKeys = [USER_ID, USER_MESSAGE], + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = [Column.Core.USER_ID], + childColumns = [USER_ID], + onDelete = ForeignKey.CASCADE + ), + ], +) +data class DismissedUserMessageEntity( + @ColumnInfo(name = USER_ID) + val userId: UserId, + @ColumnInfo(name = USER_MESSAGE) + val userMessage: UserMessage, + @ColumnInfo(name = UPDATE_TIME) + val timestampS: Long, +) diff --git a/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/di/UserMessageModule.kt b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/di/UserMessageModule.kt new file mode 100644 index 00000000..4583aa82 --- /dev/null +++ b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/di/UserMessageModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.data.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.core.drive.user.data.repository.QuotaRepositoryImpl +import me.proton.core.drive.user.data.repository.UserMessageRepositoryImpl +import me.proton.core.drive.user.domain.repository.QuotaRepository +import me.proton.core.drive.user.domain.repository.UserMessageRepository + +@Module +@InstallIn(SingletonComponent::class) +interface UserMessageModule { + @Binds + fun bindQuotaRepository(impl: QuotaRepositoryImpl): QuotaRepository + @Binds + fun bindUserMessageRepository(impl: UserMessageRepositoryImpl): UserMessageRepository +} diff --git a/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/repository/QuotaRepositoryImpl.kt b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/repository/QuotaRepositoryImpl.kt index 81eb238d..3b4f5457 100644 --- a/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/repository/QuotaRepositoryImpl.kt +++ b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/repository/QuotaRepositoryImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -22,21 +22,20 @@ import kotlinx.coroutines.flow.Flow import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.domain.entity.Bytes import me.proton.core.drive.base.domain.entity.TimestampS -import me.proton.core.drive.user.data.db.QuotaDatabase +import me.proton.core.drive.user.data.db.UserMessageDatabase import me.proton.core.drive.user.data.db.entity.DismissedQuotaEntity import me.proton.core.drive.user.domain.entity.QuotaLevel import me.proton.core.drive.user.domain.repository.QuotaRepository import javax.inject.Inject class QuotaRepositoryImpl @Inject constructor( - private val database: QuotaDatabase, + private val database: UserMessageDatabase, ) : QuotaRepository { - override fun exists(userId: UserId, level: QuotaLevel, maxSpace: Bytes): Flow { - return database.quotaDao.exists(userId, level, maxSpace.value) - } + override fun exists(userId: UserId, level: QuotaLevel, maxSpace: Bytes): Flow = + database.quotaDao.exists(userId, level, maxSpace.value) - override suspend fun insertAndUpdate(userId: UserId, level: QuotaLevel, maxSpace: Bytes) { + override suspend fun insertOrUpdate(userId: UserId, level: QuotaLevel, maxSpace: Bytes) { database.quotaDao.insertOrUpdate( DismissedQuotaEntity( userId = userId, diff --git a/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/repository/UserMessageRepositoryImpl.kt b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/repository/UserMessageRepositoryImpl.kt new file mode 100644 index 00000000..c84bc93d --- /dev/null +++ b/drive/user/data/src/main/kotlin/me/proton/core/drive/user/data/repository/UserMessageRepositoryImpl.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.data.repository + +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.entity.TimestampS +import me.proton.core.drive.user.data.db.UserMessageDatabase +import me.proton.core.drive.user.data.db.entity.DismissedUserMessageEntity +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.repository.UserMessageRepository +import javax.inject.Inject + +class UserMessageRepositoryImpl @Inject constructor( + private val database: UserMessageDatabase, +) : UserMessageRepository { + + override fun exists(userId: UserId, message: UserMessage): Flow = + database.userMessageDao.exists(userId, message) + + override suspend fun insertOrUpdate(userId: UserId, message: UserMessage) { + database.userMessageDao.insertOrUpdate( + DismissedUserMessageEntity( + userId = userId, + userMessage = message, + timestampS = TimestampS().value + ) + ) + } +} diff --git a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/entity/UserMessage.kt b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/entity/UserMessage.kt new file mode 100644 index 00000000..8a8c8120 --- /dev/null +++ b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/entity/UserMessage.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.domain.entity + +enum class UserMessage { + BACKUP_BATTERY_SETTINGS, + UPSELL_PHOTOS, +} diff --git a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/extension/User.kt b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/extension/User.kt new file mode 100644 index 00000000..3fb78a66 --- /dev/null +++ b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/extension/User.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.domain.extension + +import me.proton.core.user.domain.entity.User +import me.proton.core.user.domain.extension.hasSubscriptionForDrive +import me.proton.core.user.domain.extension.hasSubscriptionForMail + +val User.isFree get() = hasSubscriptionForDrive().not() +val User.hasSubscriptionWithMoreStorage get() = hasSubscriptionForDrive() or hasSubscriptionForMail() diff --git a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/repository/QuotaRepository.kt b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/repository/QuotaRepository.kt index 900048b8..a6b01a3c 100644 --- a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/repository/QuotaRepository.kt +++ b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/repository/QuotaRepository.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -25,5 +25,5 @@ import me.proton.core.drive.user.domain.entity.QuotaLevel interface QuotaRepository { fun exists(userId: UserId, level: QuotaLevel, maxSpace: Bytes): Flow - suspend fun insertAndUpdate(userId: UserId, level: QuotaLevel, maxSpace: Bytes) + suspend fun insertOrUpdate(userId: UserId, level: QuotaLevel, maxSpace: Bytes) } diff --git a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/repository/UserMessageRepository.kt b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/repository/UserMessageRepository.kt new file mode 100644 index 00000000..a598dae7 --- /dev/null +++ b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/repository/UserMessageRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.domain.repository + +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.user.domain.entity.UserMessage + +interface UserMessageRepository { + fun exists(userId: UserId, message: UserMessage): Flow + suspend fun insertOrUpdate(userId: UserId, message: UserMessage) +} diff --git a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/CancelQuotaMessage.kt b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/CancelQuotaMessage.kt index f1b18bd8..4c4b084f 100644 --- a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/CancelQuotaMessage.kt +++ b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/CancelQuotaMessage.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -30,6 +30,6 @@ class CancelQuotaMessage @Inject constructor( private val repository: QuotaRepository, ) { suspend operator fun invoke(userId: UserId, level: QuotaLevel) = coRunCatching { - repository.insertAndUpdate(userId, level, getUserMaxSpace(userId).getOrThrow().bytes) + repository.insertOrUpdate(userId, level, getUserMaxSpace(userId).getOrThrow().bytes) } } diff --git a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/CancelUserMessage.kt b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/CancelUserMessage.kt new file mode 100644 index 00000000..5e19da56 --- /dev/null +++ b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/CancelUserMessage.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.domain.usecase + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.util.coRunCatching +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.repository.UserMessageRepository +import javax.inject.Inject + +class CancelUserMessage @Inject constructor( + private val repository: UserMessageRepository, +) { + suspend operator fun invoke(userId: UserId, message: UserMessage) = coRunCatching { + repository.insertOrUpdate(userId, message) + } +} diff --git a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/GetQuotaLevel.kt b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/GetQuotaLevel.kt index a05863bc..fcf26ade 100644 --- a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/GetQuotaLevel.kt +++ b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/GetQuotaLevel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.map import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.domain.entity.Percentage import me.proton.core.drive.base.domain.extension.availableSpace +import me.proton.core.drive.base.domain.extension.rounded import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.user.domain.entity.QuotaLevel import me.proton.core.user.domain.UserManager @@ -36,8 +37,7 @@ class GetQuotaLevel @Inject constructor( operator fun invoke(userId: UserId) = userManager.observeUser(userId).filterNotNull().map { user -> val available = user.availableSpace - val percentage = - Percentage(user.usedSpace.toFloat() / user.maxSpace) + val percentage = Percentage(user.usedSpace.toFloat() / user.maxSpace).rounded() when { available < configurationProvider.backupLeftSpace -> QuotaLevel.ERROR percentage >= QuotaLevel.WARNING.percentage -> QuotaLevel.WARNING diff --git a/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/HasCanceledUserMessages.kt b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/HasCanceledUserMessages.kt new file mode 100644 index 00000000..e07c530b --- /dev/null +++ b/drive/user/domain/src/main/kotlin/me/proton/core/drive/user/domain/usecase/HasCanceledUserMessages.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.domain.usecase + +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.repository.UserMessageRepository +import javax.inject.Inject + +class HasCanceledUserMessages @Inject constructor( + private val repository: UserMessageRepository, +) { + operator fun invoke(userId: UserId, message: UserMessage) = + repository.exists(userId, message) +} diff --git a/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/extension/UserHasSubscriptionWithMoreStorageTest.kt b/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/extension/UserHasSubscriptionWithMoreStorageTest.kt new file mode 100644 index 00000000..5a4538f7 --- /dev/null +++ b/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/extension/UserHasSubscriptionWithMoreStorageTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.domain.extension + +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.Type +import me.proton.core.user.domain.entity.User +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class UserHasSubscriptionWithMoreStorageTest( + private val subscribed: Int, + private val moreStorage: Boolean, +) { + @Test + fun test() { + assertEquals(moreStorage, user.copy(subscribed = subscribed).hasSubscriptionWithMoreStorage) + } + + companion object { + private val user = User( + userId = UserId("id"), + email = "private.adam.smith.email@protonmail.com", + name = "Smith", + displayName = "Adam Smith", + currency = "€", + credit = 0, + createdAtUtc = 0, + usedSpace = 242_221_056L, + maxSpace = 2_147_483_648L, + maxUpload = 0, + role = null, + private = false, + services = 0, + subscribed = 0, + delinquent = null, + keys = emptyList(), + recovery = null, + type = Type.Proton, + ) + + private const val MASK_NONE = 0 // 0000 + private const val MASK_MAIL = 1 // 0001 + private const val MASK_DRIVE = 2 // 0010 + private const val MASK_VPN = 4 // 0100 + + @get:Parameterized.Parameters(name ="With subscribed of {0} hasSubscriptionWithMoreStorage is {1}") + @get:JvmStatic + val data = listOf( + arrayOf(MASK_NONE, false), + arrayOf(MASK_MAIL, true), + arrayOf(MASK_DRIVE, true), + arrayOf(MASK_MAIL or MASK_DRIVE, true), + arrayOf(MASK_VPN, false), + ) + } +} diff --git a/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/MockUserManager.kt b/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/MockUserManager.kt index 3d34efe5..7aa12449 100644 --- a/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/MockUserManager.kt +++ b/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/MockUserManager.kt @@ -63,4 +63,5 @@ private fun UserEntity.toUser() = User( keys = emptyList(), recovery = null, createdAtUtc = 0, + type = null, ) diff --git a/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/QuotaMessageTest.kt b/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/QuotaMessageTest.kt new file mode 100644 index 00000000..1d3a3273 --- /dev/null +++ b/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/QuotaMessageTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.domain.usecase + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.db.test.DriveDatabaseRule +import me.proton.core.drive.db.test.user +import me.proton.core.drive.db.test.userId +import me.proton.core.drive.user.data.repository.QuotaRepositoryImpl +import me.proton.core.drive.user.domain.entity.QuotaLevel +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class QuotaMessageTest { + @get:Rule + val database = DriveDatabaseRule() + + private lateinit var hasCanceledQuotaMessages: HasCanceledQuotaMessages + private lateinit var cancelQuotaMessage: CancelQuotaMessage + + @Before + fun setUp() = runTest { + database.db.user {} + val repository = QuotaRepositoryImpl(database.db) + // TODO: Use hilt to build the real implementation + val userManager = MockUserManager(database.db) + hasCanceledQuotaMessages = + HasCanceledQuotaMessages(userManager, repository) + cancelQuotaMessage = + CancelQuotaMessage(GetUserMaxSpace(userManager), repository) + } + + @Test + fun empty() = runTest { + assertFalse(hasCanceledQuotaMessages(userId,QuotaLevel.INFO).first()) + } + + @Test + fun canceledSameLevel() = runTest { + cancelQuotaMessage(userId,QuotaLevel.INFO).getOrThrow() + assertTrue(hasCanceledQuotaMessages(userId,QuotaLevel.INFO).first()) + } + + @Test + fun canceledAnotherLevel() = runTest { + cancelQuotaMessage(userId,QuotaLevel.INFO).getOrThrow() + assertFalse(hasCanceledQuotaMessages(userId,QuotaLevel.WARNING).first()) + } + +} diff --git a/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/UserMessageTest.kt b/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/UserMessageTest.kt new file mode 100644 index 00000000..c68afa3d --- /dev/null +++ b/drive/user/domain/src/test/kotlin/me/proton/core/drive/user/domain/usecase/UserMessageTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.domain.usecase + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.db.test.DriveDatabaseRule +import me.proton.core.drive.db.test.user +import me.proton.core.drive.db.test.userId +import me.proton.core.drive.user.data.repository.UserMessageRepositoryImpl +import me.proton.core.drive.user.domain.entity.UserMessage +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UserMessageTest { + @get:Rule + val database = DriveDatabaseRule() + + private lateinit var hasCanceledUserMessages: HasCanceledUserMessages + private lateinit var cancelUserMessage: CancelUserMessage + + @Before + fun setUp() = runTest { + database.db.user {} + val repository = UserMessageRepositoryImpl(database.db) + hasCanceledUserMessages = + HasCanceledUserMessages(repository) + cancelUserMessage = + CancelUserMessage(repository) + } + + @Test + fun empty() = runTest { + assertFalse(hasCanceledUserMessages(userId, UserMessage.BACKUP_BATTERY_SETTINGS).first()) + } + + @Test + fun canceled() = runTest { + cancelUserMessage(userId, UserMessage.BACKUP_BATTERY_SETTINGS).getOrThrow() + assertTrue(hasCanceledUserMessages(userId, UserMessage.BACKUP_BATTERY_SETTINGS).first()) + } + +} diff --git a/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/storage/Storage.kt b/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/storage/Storage.kt index 739c554a..bb516294 100644 --- a/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/storage/Storage.kt +++ b/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/storage/Storage.kt @@ -49,6 +49,7 @@ import me.proton.core.presentation.R as CorePresentation @Composable fun StorageIndicator( + label: String, usedBytes: Bytes, availableBytes: Bytes, modifier: Modifier = Modifier, @@ -65,7 +66,7 @@ fun StorageIndicator( contentDescription = null, ) Text( - text = stringResource(I18N.string.storage_total_usage), + text = label, style = ProtonTheme.typography.defaultSmallStrong, modifier = Modifier .padding(start = SmallSpacing) @@ -103,6 +104,7 @@ fun StorageIndicator( fun PreviewStorage() { ProtonTheme { StorageIndicator( + label = stringResource(I18N.string.storage_total_usage), usedBytes = Bytes(242_221_056L), // 231MiB availableBytes = Bytes(2_147_483_648L) // 2GiB ) diff --git a/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/user/User.kt b/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/user/User.kt index c197b48f..0c20a81c 100644 --- a/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/user/User.kt +++ b/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/user/User.kt @@ -50,6 +50,7 @@ import me.proton.core.compose.theme.default import me.proton.core.compose.theme.defaultSmallWeak import me.proton.core.domain.entity.UserId import me.proton.core.drive.base.presentation.extension.currentLocale +import me.proton.core.user.domain.entity.Type import me.proton.core.user.domain.entity.User import me.proton.core.drive.i18n.R as I18N import me.proton.core.presentation.R as CorePresentation @@ -175,6 +176,7 @@ val PREVIEW_USER = User( name = "Adam Smith", displayName = "Adam Smith", currency = "€", + type = Type.Proton, credit = 0, createdAtUtc = 0, usedSpace = 242_221_056L, diff --git a/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/user/extension/User.kt b/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/user/extension/User.kt new file mode 100644 index 00000000..1a03ba76 --- /dev/null +++ b/drive/user/presentation/src/main/kotlin/me/proton/core/drive/user/presentation/user/extension/User.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Core. + * + * Proton Core is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Core. If not, see . + */ + +package me.proton.core.drive.user.presentation.user.extension + +import me.proton.core.drive.i18n.R +import me.proton.core.user.domain.entity.User + +/** Returns data for displaying current storage status for the user. + * @return Triple of . + */ +fun User.getStorageIndicatorData(): Triple { + val usedDriveSpace = usedDriveSpace + val maxDriveSpace = maxDriveSpace + return if (usedDriveSpace != null && maxDriveSpace != null) { + Triple(usedDriveSpace, maxDriveSpace, R.string.storage_drive_usage) + } else { + Triple(usedSpace, maxSpace, R.string.storage_total_usage) + } +} diff --git a/drive/user/presentation/src/test/kotlin/me/proton/core/drive/user/presentation/user/UserKtTest.kt b/drive/user/presentation/src/test/kotlin/me/proton/core/drive/user/presentation/user/UserKtTest.kt index 7562850e..fad9dc47 100644 --- a/drive/user/presentation/src/test/kotlin/me/proton/core/drive/user/presentation/user/UserKtTest.kt +++ b/drive/user/presentation/src/test/kotlin/me/proton/core/drive/user/presentation/user/UserKtTest.kt @@ -19,6 +19,7 @@ package me.proton.core.drive.user.presentation.user import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.Type import me.proton.core.user.domain.entity.User import org.junit.Test @@ -43,6 +44,7 @@ class UserKtTest { delinquent = null, keys = emptyList(), recovery = null, + type = Type.Proton, ) @Test diff --git a/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/GetOrCreateVolumeTest.kt b/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/GetOrCreateVolumeTest.kt index f13050bb..51b9c7d6 100644 --- a/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/GetOrCreateVolumeTest.kt +++ b/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/GetOrCreateVolumeTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -95,7 +95,7 @@ class GetOrCreateVolumeTest { val volume = createOrCreateVolume(userId).resultValueOrThrow() assertEquals( - NullableVolume(VolumeId(volumeId)), + NullableVolume(volumeId), volume ) } diff --git a/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/NullableVolume.kt b/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/NullableVolume.kt index 530ec2de..7a4d8e71 100644 --- a/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/NullableVolume.kt +++ b/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/NullableVolume.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ package me.proton.core.drive.volume.crypto.domain.usecase import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes -import me.proton.core.drive.db.test.shareId +import me.proton.core.drive.db.test.mainShareId import me.proton.core.drive.volume.domain.entity.Volume import me.proton.core.drive.volume.domain.entity.VolumeId @@ -32,7 +32,7 @@ internal fun NullableVolume( creationTime: TimestampS = TimestampS(0), ) = Volume( id = id, - shareId = shareId, + shareId = mainShareId.id, maxSpace = 0.bytes, usedSpace = 0.bytes, state = state, diff --git a/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/NullableVolumeDto.kt b/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/NullableVolumeDto.kt index 15659d81..1554de10 100644 --- a/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/NullableVolumeDto.kt +++ b/drive/volume-crypto/domain/src/test/kotlin/me/proton/core/drive/volume/crypto/domain/usecase/NullableVolumeDto.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -18,15 +18,15 @@ package me.proton.core.drive.volume.crypto.domain.usecase -import me.proton.core.drive.db.test.shareId +import me.proton.core.drive.db.test.mainShareId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.volume.data.api.entity.VolumeDto import me.proton.core.drive.volume.data.api.entity.VolumeShare @Suppress("TestFunctionName") internal fun NullableVolumeDto( - id: String = volumeId, - volumeShare: VolumeShare = VolumeShare(shareId, "root-id"), + id: String = volumeId.id, + volumeShare: VolumeShare = VolumeShare(mainShareId.id, "main-root-id"), state: Long = 1, ) = VolumeDto( id = id, diff --git a/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/GetVolumeTest.kt b/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/GetVolumeTest.kt index b0ddb077..a604af44 100644 --- a/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/GetVolumeTest.kt +++ b/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/GetVolumeTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -32,7 +32,6 @@ import me.proton.core.drive.test.api.get import me.proton.core.drive.test.api.jsonResponse import me.proton.core.drive.test.api.routing import me.proton.core.drive.volume.data.api.response.GetVolumeResponse -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -71,7 +70,7 @@ class GetVolumeTest { } } - val id = VolumeId(volumeId) + val id = volumeId val volume = getVolume(userId, id).resultValueOrThrow() @@ -88,7 +87,7 @@ class GetVolumeTest { NullableVolumeEntity() ) - val id = VolumeId(volumeId) + val id = volumeId val volume = getVolume(userId, id).resultValueOrThrow() diff --git a/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/NullableVolume.kt b/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/NullableVolume.kt index aad6066d..8f204910 100644 --- a/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/NullableVolume.kt +++ b/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/NullableVolume.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ package me.proton.core.drive.volume.domain.usecase import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes -import me.proton.core.drive.db.test.shareId +import me.proton.core.drive.db.test.mainShareId import me.proton.core.drive.volume.domain.entity.Volume import me.proton.core.drive.volume.domain.entity.VolumeId @@ -28,7 +28,7 @@ import me.proton.core.drive.volume.domain.entity.VolumeId @Suppress("TestFunctionName") internal fun NullableVolume(id: VolumeId) = Volume( id = id, - shareId = shareId, + shareId = mainShareId.id, maxSpace = 0.bytes, usedSpace = 0.bytes, state = 1, diff --git a/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/NullableVolumeDto.kt b/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/NullableVolumeDto.kt index 7b41bc61..5a963255 100644 --- a/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/NullableVolumeDto.kt +++ b/drive/volume/domain/src/test/kotlin/me/proton/core/drive/volume/domain/usecase/NullableVolumeDto.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Core. * * Proton Core is free software: you can redistribute it and/or modify @@ -18,15 +18,15 @@ package me.proton.core.drive.volume.domain.usecase -import me.proton.core.drive.db.test.shareId +import me.proton.core.drive.db.test.mainShareId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.volume.data.api.entity.VolumeDto import me.proton.core.drive.volume.data.api.entity.VolumeShare @Suppress("TestFunctionName") internal fun NullableVolumeDto( - id: String = volumeId, - volumeShare: VolumeShare = VolumeShare(shareId, "root-id"), + id: String = volumeId.id, + volumeShare: VolumeShare = VolumeShare(mainShareId.id, "main-root-id"), ) = VolumeDto( id = id, creationTime = 0, diff --git a/firebase-device-config.yml b/firebase-device-config.yml index 05d528d0..62b1683a 100644 --- a/firebase-device-config.yml +++ b/firebase-device-config.yml @@ -3,7 +3,7 @@ quickTest: device: - model: MediumPhone.arm version: 33 - - model: Pixel2 + - model: Pixel2.arm version: 30 quickTest-2: timeout: 30m diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a2a5ad2d..577b5f61 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,23 +8,23 @@ androidx-activity = "1.8.0" androidx-annotation = "1.7.0" androidx-appCompat = "1.6.1" androidx-biometric = "1.2.0-alpha05" -androidx-compose = "1.5.4" -androidx-compose-compiler = "1.5.6" +androidx-compose = "1.6.3" +androidx-compose-compiler = "1.5.10" androidx-compose-constraintlayout = "1.0.1" -androidx-compose-foundation = "1.5.4" -androidx-compose-material = "1.5.4" -androidx-compose-material3 = "1.1.2" +androidx-compose-foundation = "1.6.3" +androidx-compose-material = "1.6.3" +androidx-compose-material3 = "1.2.1" androidx-core = "1.12.0" androidx-datastore = "1.0.0" androidx-exif = "1.3.6" -androidx-hilt = "1.1.0" -androidx-hilt-navigation-compose = "1.1.0" +androidx-hilt = "1.2.0" +androidx-hilt-navigation-compose = "1.2.0" androidx-lifecycle = "2.6.2" androidx-media3 = "1.2.1" -androidx-navigation = "2.7.5" +androidx-navigation = "2.7.7" androidx-paging = "3.1.1" androidx-paging-compose = "3.2.1" -androidx-room = "2.6.0" +androidx-room = "2.6.1" androidx-test = "1.5.0" androidx-test-orchestrator = "1.4.2" androidx-test-uiautomator = "2.2.0" @@ -35,23 +35,25 @@ android-tools = "1.1.5" # Coil coil = "2.4.0" # Core -core = "19.0.0" +core = "21.0.0" core-test-quark = "14.1.0" # Dagger -dagger = "2.48.1" +dagger = "2.49" +# Desugar +desugar = "2.0.4" # Github treessence = "1.0.5" # Gradle -android-gradle-plugin = "8.1.3" -proton-detekt-plugin = "1.2.0" +android-gradle-plugin = "8.2.2" +proton-detekt-plugin = "1.3.0" # JaCoCo jaCoCo = "0.8.8" # JakeWharton timber = "5.0.1" # Kotlin -kotlin = "1.9.21" -coroutines = "1.6.4" -serializationJson = "1.6.0" +kotlin = "1.9.22" +coroutines = "1.8.0" +serializationJson = "1.6.3" # Sentry sentry = "6.25.0" # Squareup @@ -143,10 +145,12 @@ coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil" } core-account = { module = "me.proton.core:account", version.ref = "core" } core-account-dagger = { module = "me.proton.core:account-dagger", version.ref = "core" } core-account-data = { module = "me.proton.core:account-data", version.ref = "core" } +core-account-domain = { module = "me.proton.core:account-domain", version.ref = "core" } core-accountManager = { module = "me.proton.core:account-manager", version.ref = "core" } core-accountManager-dagger = { module = "me.proton.core:account-manager-dagger", version.ref = "core" } core-accountManager-data = { module = "me.proton.core:account-manager-data", version.ref = "core" } core-accountManager-domain = { module = "me.proton.core:account-manager-domain", version.ref = "core" } +core-accountManager-presentation = { module = "me.proton.core:account-manager-presentation", version.ref = "core" } core-accountRecovery = { module = "me.proton.core:account-recovery", version.ref = "core" } core-accountRecovery-test = { module = "me.proton.core:account-recovery-test", version.ref = "core" } core-auth = { module = "me.proton.core:auth", version.ref = "core" } @@ -154,6 +158,9 @@ core-auth-domain = { module = "me.proton.core:auth-domain", version.ref = "core" core-auth-test = { module = "me.proton.core:auth-test", version.ref = "core" } core-challenge = { module = "me.proton.core:challenge", version.ref = "core" } core-challenge-data = { module = "me.proton.core:challenge-data", version.ref = "core" } +core-contact = { module = "me.proton.core:contact", version.ref = "core" } +core-contact-data = { module = "me.proton.core:contact", version.ref = "core" } +core-contact-domain = { module = "me.proton.core:contact", version.ref = "core" } core-country = { module = "me.proton.core:country", version.ref = "core" } core-crypto = { module = "me.proton.core:crypto", version.ref = "core" } core-crypto-dagger = { module = "me.proton.core:crypto-dagger", version.ref = "core" } @@ -164,7 +171,9 @@ core-data = { module = "me.proton.core:data", version.ref = "core" } core-dataRoom = { module = "me.proton.core:data-room", version.ref = "core" } core-domain = { module = "me.proton.core:domain", version.ref = "core" } core-eventManager = { module = "me.proton.core:event-manager", version.ref = "core" } +core-eventManager-dagger = { module = "me.proton.core:event-manager-dagger", version.ref = "core" } core-eventManager-data = { module = "me.proton.core:event-manager-data", version.ref = "core" } +core-eventManager-domain = { module = "me.proton.core:event-manager-domain", version.ref = "core" } core-featureFlag = { module = "me.proton.core:feature-flag", version.ref = "core" } core-featureFlag-data = { module = "me.proton.core:feature-flag-data", version.ref = "core" } core-featureFlag-domain = { module = "me.proton.core:feature-flag-domain", version.ref = "core" } @@ -184,6 +193,7 @@ core-network-domain = { module = "me.proton.core:network-domain", version.ref = core-notification = { module = "me.proton.core:notification", version.ref = "core" } core-notification-data = { module = "me.proton.core:notification-data", version.ref = "core" } core-observability = { module = "me.proton.core:observability", version.ref = "core" } +core-observability-dagger = { module = "me.proton.core:observability-dagger", version.ref = "core" } core-observability-data = { module = "me.proton.core:observability-data", version.ref = "core" } core-payment = { module = "me.proton.core:payment", version.ref = "core" } core-payment-domain = { module = "me.proton.core:payment-domain", version.ref = "core" } @@ -191,6 +201,7 @@ core-payment-data = { module = "me.proton.core:payment-data", version.ref = "cor core-payment-iap = { module = "me.proton.core:payment-iap", version.ref = "core" } # core-payment-iap-test = { module = "me.proton.core:payment-iap-test", version.ref = "core" } core-plan = { module = "me.proton.core:plan", version.ref = "core" } +core-plan-presentation-compose = { module = "me.proton.core:plan-presentation-compose", version.ref = "core" } core-plan-test = { module = "me.proton.core:plan-test", version.ref = "core" } core-presentation = { module = "me.proton.core:presentation", version.ref = "core" } core-presentation-compose = { module = "me.proton.core:presentation-compose", version.ref = "core" } @@ -231,6 +242,9 @@ treessence = { module = "com.github.bastienpaulfr:treessence", version.ref = "tr # JakeWharton timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +# Desugar +tools-desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" } + # Kotlin kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } @@ -271,7 +285,7 @@ fusion = { module = "me.proton.test:fusion", version.ref = "fusion"} [bundles] accompanist = ["accompanist-systemUiController", "accompanist-permissions", "accompanist-drawablepainter", "accompanist-placeholderMaterial"] -core = ["core-account", "core-accountManager", "core-accountRecovery", "core-auth", "core-challenge", "core-country", "core-crypto", "core-cryptoValidator", "core-data", "core-dataRoom", "core-domain", "core-eventManager", "core-featureFlag", "core-humanVerification", "core-key", "core-keyTransparency", "core-network", "core-notification", "core-observability", "core-payment", "core-payment-iap", "core-plan", "core-report", "core-presentation", "core-presentation-compose", "core-proguard-rules", "core-push", "core-telemetry", "core-user", "core-userSettings", "core-utilAndroidDagger", "core-utilAndroidSentry", "core-utilKotlin", "core-config-data"] +core = ["core-account", "core-accountManager", "core-accountRecovery", "core-auth", "core-challenge", "core-contact", "core-country", "core-crypto", "core-cryptoValidator", "core-data", "core-dataRoom", "core-domain", "core-eventManager", "core-featureFlag", "core-humanVerification", "core-key", "core-keyTransparency", "core-network", "core-notification", "core-observability", "core-payment", "core-payment-iap", "core-plan", "core-plan-presentation-compose", "core-report", "core-presentation", "core-presentation-compose", "core-proguard-rules", "core-push", "core-telemetry", "core-user", "core-userSettings", "core-utilAndroidDagger", "core-utilAndroidSentry", "core-utilKotlin", "core-config-data"] core-test = ["core-auth-test", "core-accountRecovery-test", "core-humanVerification-test", "core-report-test", "core-plan-test"] test-android = ["junit", "mockk-android", "coroutines-test", "androidx-test-core-ktx", "androidx-test-runner", "androidx-test-rules", "androidx-compose-ui-test", "androidx-compose-ui-test-junit", "androidx-test-uiautomator", "core-test-android-instrumented"] test-jvm = ["junit", "mockk-jvm", "coroutines-test", "androidx-test-core-ktx", "androidx-work-testing", "core-test-kotlin", "core-test-quark", "robolectric", "mockwebserver"] diff --git a/photos/data/src/test/kotlin/me/proton/android/drive/photos/data/repository/PhotoFindDuplicatesRepositoryTest.kt b/photos/data/src/test/kotlin/me/proton/android/drive/photos/data/repository/PhotoFindDuplicatesRepositoryTest.kt index c48ce265..f1d1f6a5 100644 --- a/photos/data/src/test/kotlin/me/proton/android/drive/photos/data/repository/PhotoFindDuplicatesRepositoryTest.kt +++ b/photos/data/src/test/kotlin/me/proton/android/drive/photos/data/repository/PhotoFindDuplicatesRepositoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -25,7 +25,7 @@ import kotlinx.coroutines.test.runTest import me.proton.core.drive.backup.domain.entity.BackupDuplicate import me.proton.core.drive.base.data.api.ProtonApiCode import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volumeId import me.proton.core.drive.link.domain.entity.FolderId @@ -37,7 +37,6 @@ import me.proton.core.drive.photo.data.api.response.FindDuplicatesResponse import me.proton.core.drive.photo.data.repository.PhotoRepositoryImpl import me.proton.core.drive.volume.data.api.VolumeApiDataSource import me.proton.core.drive.volume.data.repository.VolumeRepositoryImpl -import me.proton.core.drive.volume.domain.entity.VolumeId import me.proton.core.drive.volume.domain.usecase.GetOldestActiveVolume import me.proton.core.drive.volume.domain.usecase.GetVolume import me.proton.core.drive.volume.domain.usecase.GetVolumes @@ -64,7 +63,7 @@ class PhotoFindDuplicatesRepositoryTest { @Before fun setUp() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } val volumeRepository = VolumeRepositoryImpl( volumeApiDataSource, @@ -80,7 +79,7 @@ class PhotoFindDuplicatesRepositoryTest { fun test() = runTest { coEvery { photoApiDataSource.findDuplicate( - userId, VolumeId(volumeId), FindDuplicatesRequest( + userId, volumeId, FindDuplicatesRequest( nameHashes = listOf("hash"), clientUids = emptyList(), ) diff --git a/photos/data/src/test/kotlin/me/proton/android/drive/photos/data/repository/PhotoSyncLinkFolderTest.kt b/photos/data/src/test/kotlin/me/proton/android/drive/photos/data/repository/PhotoSyncLinkFolderTest.kt index 77ad9ad8..649e8e4e 100644 --- a/photos/data/src/test/kotlin/me/proton/android/drive/photos/data/repository/PhotoSyncLinkFolderTest.kt +++ b/photos/data/src/test/kotlin/me/proton/android/drive/photos/data/repository/PhotoSyncLinkFolderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -34,7 +34,7 @@ import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.base.domain.extension.bytes import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.photo import me.proton.core.drive.db.test.userId import me.proton.core.drive.db.test.volumeId @@ -50,7 +50,6 @@ import me.proton.core.drive.stats.data.repository.UploadStatsRepositoryImpl import me.proton.core.drive.stats.domain.entity.UploadStats import me.proton.core.drive.stats.domain.usecase.GetUploadStats import me.proton.core.drive.stats.domain.usecase.UpdateUploadStats -import me.proton.core.drive.volume.domain.entity.VolumeId import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -104,7 +103,7 @@ class PhotoSyncLinkFolderTest { @Test fun `Given not photo share when sync should no do nothing`() = runTest { - folderId = database.myDrive { } + folderId = database.myFiles { } photoSyncLinkFolder(userId).getOrThrow() @@ -129,7 +128,7 @@ class PhotoSyncLinkFolderTest { coEvery { photoApiDataSource.getPhotoListings( userId = userId, - volumeId = VolumeId(volumeId), + volumeId = volumeId, sortingDirection = Direction.DESCENDING, pageSize = pageSize, previousPageLastLinkId = any(), @@ -159,7 +158,7 @@ class PhotoSyncLinkFolderTest { assertEquals( 88, - database.db.photoListingDao.getPhotoListingCount(userId, volumeId).first() + database.db.photoListingDao.getPhotoListingCount(userId, volumeId.id).first() ) } } diff --git a/photos/domain/build.gradle.kts b/photos/domain/build.gradle.kts index d07c0125..11415ab9 100644 --- a/photos/domain/build.gradle.kts +++ b/photos/domain/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -27,6 +27,7 @@ android { driveModule( hilt = true, + socialTest = true, ) { api(project(":drive:announce-event:domain")) api(project(":drive:backup:domain")) @@ -35,6 +36,5 @@ driveModule( api(project(":drive:drivelink-selection:domain")) api(project(":drive:share-crypto:domain")) api(project(":drive:stats:domain")) - testImplementation(project(":drive:db-test")) testImplementation(project(":photos:data")) } diff --git a/photos/domain/src/main/kotlin/me/proton/android/drive/photos/domain/usecase/ShowUpsell.kt b/photos/domain/src/main/kotlin/me/proton/android/drive/photos/domain/usecase/ShowUpsell.kt new file mode 100644 index 00000000..3b57ee0b --- /dev/null +++ b/photos/domain/src/main/kotlin/me/proton/android/drive/photos/domain/usecase/ShowUpsell.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.photos.domain.usecase + +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import me.proton.core.domain.entity.UserId +import me.proton.core.drive.base.domain.provider.ConfigurationProvider +import me.proton.core.drive.base.domain.util.coRunCatching +import me.proton.core.drive.photo.domain.usecase.GetPhotoCount +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.extension.hasSubscriptionWithMoreStorage +import me.proton.core.drive.user.domain.usecase.HasCanceledUserMessages +import me.proton.core.user.domain.usecase.GetUser +import javax.inject.Inject + +class ShowUpsell @Inject constructor( + private val hasCanceledUserMessages: HasCanceledUserMessages, + private val configurationProvider: ConfigurationProvider, + private val isPhotosEnabled: IsPhotosEnabled, + private val getPhotoCount: GetPhotoCount, + private val getUser: GetUser, +) { + operator fun invoke(userId: UserId) = combine( + hasCanceledUserMessages(userId, UserMessage.UPSELL_PHOTOS), + isPhotosEnabled(userId), + getPhotoCount(userId), + ) { hasCanceledUserMessages, isPhotosEnabled, photoCount -> + if (hasCanceledUserMessages) { + false + } else { + isPhotosEnabled && hasEnoughPhotosUploaded(photoCount) && isFreeUser(userId) == true + } + }.distinctUntilChanged() + + private fun hasEnoughPhotosUploaded(photoCount: Int) = + photoCount >= configurationProvider.photosUpsellPhotoCount + + private suspend fun isFreeUser(userId: UserId) = + coRunCatching { getUser(userId, false) }.getOrNull() + ?.hasSubscriptionWithMoreStorage?.not() +} diff --git a/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/di/TestBackupManagerBindModule.kt b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/di/TestBackupManagerBindModule.kt new file mode 100644 index 00000000..829845f9 --- /dev/null +++ b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/di/TestBackupManagerBindModule.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.photos.domain.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import me.proton.android.drive.photos.domain.manager.StubbedBackupManager +import me.proton.core.drive.backup.data.di.BackupManagerBindModule +import me.proton.core.drive.backup.domain.manager.BackupManager +import javax.inject.Singleton + +@Module +@Suppress("unused") +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BackupManagerBindModule::class] +) +interface TestBackupManagerBindModule { + + @Binds + @Singleton + fun bindsBackupManager(impl: StubbedBackupManager): BackupManager +} diff --git a/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/manager/StubbedBackupManager.kt b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/manager/StubbedBackupManager.kt index aed3208f..52715822 100644 --- a/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/manager/StubbedBackupManager.kt +++ b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/manager/StubbedBackupManager.kt @@ -26,8 +26,9 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder import me.proton.core.drive.backup.domain.manager.BackupManager import me.proton.core.drive.backup.domain.repository.BackupFolderRepository import me.proton.core.drive.link.domain.entity.FolderId +import javax.inject.Inject -class StubbedBackupManager( +class StubbedBackupManager @Inject constructor( private val repository: BackupFolderRepository, ) : BackupManager { var started = false diff --git a/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/PhotoBackupStateTest.kt b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/PhotoBackupStateTest.kt index c57d0ce5..5479e303 100644 --- a/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/PhotoBackupStateTest.kt +++ b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/PhotoBackupStateTest.kt @@ -57,6 +57,7 @@ import me.proton.core.drive.backup.domain.usecase.GetAllBuckets import me.proton.core.drive.backup.domain.usecase.GetBackupState import me.proton.core.drive.backup.domain.usecase.GetBackupStatus import me.proton.core.drive.backup.domain.usecase.GetConfiguration +import me.proton.core.drive.backup.domain.usecase.GetDisabledBackupState import me.proton.core.drive.backup.domain.usecase.GetErrors import me.proton.core.drive.backup.domain.usecase.StartBackup import me.proton.core.drive.backup.domain.usecase.UpdateConfiguration @@ -72,7 +73,8 @@ import me.proton.core.drive.drivelink.data.extension.toEncryptedDriveLink import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.link.data.extension.toLink import me.proton.core.drive.link.domain.entity.FolderId -import me.proton.core.drive.volume.domain.entity.VolumeId +import me.proton.core.drive.user.data.repository.UserMessageRepositoryImpl +import me.proton.core.drive.user.domain.usecase.HasCanceledUserMessages import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -118,7 +120,7 @@ class PhotoBackupStateTest { linkId = folderId.id ).map { linkWithProperties -> (linkWithProperties?.toLink()?.toEncryptedDriveLink( - volumeId = VolumeId(volumeId), + volumeId = volumeId, isMarkedAsOffline = false, downloadState = null, trashState = null @@ -166,19 +168,22 @@ class PhotoBackupStateTest { permissionsManager = permissionsManager, connectivityManager = connectivityManager, getErrors = GetErrors(errorRepository, NoNetworkConfigurationProvider.instance), - getAllBuckets = GetAllBuckets(object : BucketRepository { - override suspend fun getAll(): List = listOf(BucketEntry(0, "Camera")) - }, permissionsManager), - configurationProvider = object : ConfigurationProvider { - override val host = "" - override val baseUrl = "" - override val appVersionHeader = "" - override val backupDefaultBucketName = "Camera" - }, isBackgroundRestricted = object : IsBackgroundRestricted { override fun invoke(): Flow = flowOf(false) }, + hasCanceledUserMessages = HasCanceledUserMessages(UserMessageRepositoryImpl(database.db)), getConfiguration = getConfiguration, + getDisabledBackupState = GetDisabledBackupState( + getAllBuckets = GetAllBuckets(object : BucketRepository { + override suspend fun getAll(): List = listOf(BucketEntry(0, "Camera")) + }, permissionsManager), + configurationProvider = object : ConfigurationProvider { + override val host = "" + override val baseUrl = "" + override val appVersionHeader = "" + override val backupDefaultBucketName = "Camera" + }, + ), ) backupState = getBackupState(folderId) diff --git a/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/RescanOnMediaStoreUpdateTest.kt b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/RescanOnMediaStoreUpdateTest.kt index f5ecd6d6..6364b93d 100644 --- a/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/RescanOnMediaStoreUpdateTest.kt +++ b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/RescanOnMediaStoreUpdateTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Proton AG. + * Copyright (c) 2023-2024 Proton AG. * This file is part of Proton Drive. * * Proton Drive is free software: you can redistribute it and/or modify @@ -30,7 +30,7 @@ import me.proton.core.drive.backup.domain.usecase.ResetFoldersUpdateTime import me.proton.core.drive.backup.domain.usecase.SyncFolders import me.proton.core.drive.base.domain.entity.TimestampS import me.proton.core.drive.db.test.DriveDatabaseRule -import me.proton.core.drive.db.test.myDrive +import me.proton.core.drive.db.test.myFiles import me.proton.core.drive.db.test.userId import org.junit.Assert.assertEquals import org.junit.Before @@ -55,7 +55,7 @@ class RescanOnMediaStoreUpdateTest { @Before fun setup() = runTest { - val folderId = database.myDrive { } + val folderId = database.myFiles { } val folderRepository = BackupFolderRepositoryImpl(database.db) backupManager = StubbedBackupManager(folderRepository) diff --git a/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/ShowUpsellTest.kt b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/ShowUpsellTest.kt new file mode 100644 index 00000000..731affae --- /dev/null +++ b/photos/domain/src/test/kotlin/me/proton/android/drive/photos/domain/usecase/ShowUpsellTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.photos.domain.usecase + +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import me.proton.core.drive.backup.domain.entity.BackupFolder +import me.proton.core.drive.backup.domain.usecase.AddFolder +import me.proton.core.drive.db.test.NullableUserEntity +import me.proton.core.drive.db.test.VolumeContext +import me.proton.core.drive.db.test.file +import me.proton.core.drive.db.test.photoShare +import me.proton.core.drive.db.test.user +import me.proton.core.drive.db.test.userId +import me.proton.core.drive.db.test.volume +import me.proton.core.drive.test.DriveRule +import me.proton.core.drive.user.domain.entity.UserMessage +import me.proton.core.drive.user.domain.usecase.CancelUserMessage +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.inject.Inject + + +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class ShowUpsellTest { + + @get:Rule + val driveRule = DriveRule(this) + + @Inject + lateinit var addFolder: AddFolder + + @Inject + lateinit var cancelUserMessage: CancelUserMessage + + @Inject + lateinit var showUpsell: ShowUpsell + + @Test + fun `Given free user and backup disabled when show upsell should returns false`() = runTest { + driveRule.db.user { + volume {} + } + + assertFalse(showUpsell(userId).first()) + } + + @Test + fun `Given free user without photos and backup enabled when show upsell should returns false`() = runTest { + val folderId = driveRule.db.user { + volume { + photoShare {} + } + } + + addFolder(BackupFolder(0, folderId)).getOrThrow() + + assertFalse(showUpsell(userId).first()) + } + + @Test + fun `Given free user with 5 photos and backup disabled when show upsell should returns false`() = runTest { + driveRule.db.user { + volume { + photoShareWithPhotos(5) + } + } + + assertFalse(showUpsell(userId).first()) + } + + @Test + fun `Given free user with 5 photos and backup enabled when show upsell should returns true`() = runTest { + val folderId = driveRule.db.user { + volume { + photoShareWithPhotos(5) + } + } + + addFolder(BackupFolder(0, folderId)).getOrThrow() + + assertTrue(showUpsell(userId).first()) + } + + @Test + fun `Given message cancelled when show upsell should returns false`() = runTest { + val folderId = driveRule.db.user { + volume { + photoShareWithPhotos(5) + } + } + + addFolder(BackupFolder(0, folderId)).getOrThrow() + + cancelUserMessage(userId, UserMessage.UPSELL_PHOTOS).getOrThrow() + + assertFalse(showUpsell(userId).first()) + } + + @Test + fun `Given user with more storage with 5 photos and backup enabled when show upsell should returns false`() = runTest { + val folderId = driveRule.db.user(NullableUserEntity(subscribed = 2)) { + volume { + photoShareWithPhotos(5) + } + } + + addFolder(BackupFolder(0, folderId)).getOrThrow() + + assertFalse(showUpsell(userId).first()) + } + + private suspend fun VolumeContext.photoShareWithPhotos(count : Int) = photoShare { + (0 until count).forEach { index -> + file("$index.jpg") + } + } + +} diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/Photos.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/Photos.kt index 93c12524..8fc2f0da 100644 --- a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/Photos.kt +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/Photos.kt @@ -80,6 +80,7 @@ fun Photos( onIgnoreBackgroundRestrictions = { viewEvent.onIgnoreBackgroundRestrictions(localContext) }, + onDismissBackgroundRestrictions = viewEvent.onDismissBackgroundRestrictions, ) } else { PhotosEmptyWithBackupTurnedOff( @@ -116,6 +117,7 @@ fun Photos( onIgnoreBackgroundRestrictions = { viewEvent.onIgnoreBackgroundRestrictions(localContext) }, + onDismissBackgroundRestrictions = viewEvent.onDismissBackgroundRestrictions, isRefreshEnabled = viewState.isRefreshEnabled, isRefreshing = viewState.listContentState.isRefreshing, onRefresh = viewEvent.onRefresh, diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosContent.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosContent.kt index 08b8bd44..56725516 100644 --- a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosContent.kt +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosContent.kt @@ -115,6 +115,7 @@ fun PhotosContent( onResolveMissingFolder: () -> Unit, onChangeNetwork: () -> Unit, onIgnoreBackgroundRestrictions: () -> Unit, + onDismissBackgroundRestrictions: () -> Unit, isRefreshEnabled: Boolean, isRefreshing: Boolean, onRefresh: () -> Unit, @@ -142,6 +143,7 @@ fun PhotosContent( onResolveMissingFolder = onResolveMissingFolder, onChangeNetwork = onChangeNetwork, onIgnoreBackgroundRestrictions = onIgnoreBackgroundRestrictions, + onDismissBackgroundRestrictions = onDismissBackgroundRestrictions, ) } } @@ -165,6 +167,7 @@ fun PhotosContent( onResolveMissingFolder: () -> Unit, onChangeNetwork: () -> Unit, onIgnoreBackgroundRestrictions: () -> Unit, + onDismissBackgroundRestrictions: () -> Unit, ) { val gridState = items.rememberLazyGridState() val driveLinksMap by rememberFlowWithLifecycle(flow = driveLinksFlow) @@ -208,6 +211,7 @@ fun PhotosContent( onResolveMissingFolder = onResolveMissingFolder, onChangeNetwork = onChangeNetwork, onIgnoreBackgroundRestrictions = onIgnoreBackgroundRestrictions, + onDismissBackgroundRestrictions = onDismissBackgroundRestrictions, ) StorageBanner(onGetStorage = onGetStorage) } @@ -561,7 +565,7 @@ fun MediaItemPreview() { trashedTime = null, shareUrlExpirationTime = null, xAttr = null, - shareUrlId = null, + sharingDetails = null, photoCaptureTime = TimestampS(0), photoContentHash = "", mainPhotoLinkId = "MAIN_ID" diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosEmpty.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosEmpty.kt index 15be3db7..24d6778d 100644 --- a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosEmpty.kt +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosEmpty.kt @@ -51,6 +51,7 @@ fun PhotosEmpty( onResolveMissingFolder: () -> Unit, onChangeNetwork: () -> Unit, onIgnoreBackgroundRestrictions: () -> Unit, + onDismissBackgroundRestrictions: () -> Unit, ) { Column { PhotosBanners { @@ -64,6 +65,7 @@ fun PhotosEmpty( onResolveMissingFolder = onResolveMissingFolder, onChangeNetwork = onChangeNetwork, onIgnoreBackgroundRestrictions = onIgnoreBackgroundRestrictions, + onDismissBackgroundRestrictions = onDismissBackgroundRestrictions, ) StorageBanner(onGetStorage = onGetStorage) } @@ -144,6 +146,7 @@ private fun PhotosEmptyPreview() { onResolveMissingFolder = {}, onChangeNetwork = {}, onIgnoreBackgroundRestrictions = {}, + onDismissBackgroundRestrictions = {}, ) } diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosStates.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosStates.kt index b0dfbfaa..e18f0a6d 100644 --- a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosStates.kt +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosStates.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -261,6 +262,36 @@ private fun ErrorDetails(text: String) { } } +@Composable +private fun ErrorDetails(text: String, onDismiss: () -> Unit) { + Card( + backgroundColor = ProtonTheme.colors.backgroundSecondary, + contentColor = ProtonTheme.colors.textNorm, + elevation = 0.dp, + ) { + Row { + Text( + modifier = Modifier + .defaultMinSize(minHeight = ProtonDimens.ListItemHeight) + .weight(1F) + .padding( + start = ProtonDimens.ListItemTextStartPadding, + top = ProtonDimens.SmallSpacing, + bottom = ProtonDimens.SmallSpacing, + ), + text = text, + style = ProtonTheme.typography.defaultSmallNorm, + ) + IconButton(onClick = { onDismiss() }) { + Icon( + painter = painterResource(id = CorePresentation.drawable.ic_proton_cross), + contentDescription = stringResource(id = I18N.string.common_close_action) + ) + } + } + } +} + @Composable fun BackupDisableState( modifier: Modifier = Modifier, @@ -310,6 +341,7 @@ fun BackupFailedState( fun BackgroundRestrictions( modifier: Modifier = Modifier, onIgnoreBackgroundRestrictions: () -> Unit, + onDismissBackgroundRestrictions: () -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(ProtonDimens.SmallSpacing) @@ -326,7 +358,8 @@ fun BackgroundRestrictions( stringResource( I18N.string.photos_error_background_restrictions_description, stringResource(id = I18N.string.app_name), - ) + ), + onDismiss = onDismissBackgroundRestrictions, ) } } @@ -564,7 +597,10 @@ private fun BackupFailedStatePreview() { private fun BackgroundRestrictionsPreview() { ProtonTheme { Surface(modifier = Modifier.background(MaterialTheme.colors.background)) { - BackgroundRestrictions(onIgnoreBackgroundRestrictions = { }) + BackgroundRestrictions( + onIgnoreBackgroundRestrictions = { }, + onDismissBackgroundRestrictions = { }, + ) } } } diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosStatesContainer.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosStatesContainer.kt index 6719ff39..3f794e38 100644 --- a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosStatesContainer.kt +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/component/PhotosStatesContainer.kt @@ -29,6 +29,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import me.proton.android.drive.photos.presentation.viewstate.PhotosStatusViewState import me.proton.core.compose.theme.ProtonDimens import me.proton.core.compose.theme.ProtonTheme @@ -47,6 +49,7 @@ internal fun PhotosStatesContainer( onResolveMissingFolder: () -> Unit, onChangeNetwork: () -> Unit, onIgnoreBackgroundRestrictions: () -> Unit, + onDismissBackgroundRestrictions: () -> Unit, ) { AnimatedVisibility( modifier = modifier, @@ -82,7 +85,10 @@ internal fun PhotosStatesContainer( BackupTemporarilyDisabledState(onRetry = onRetry) BackupErrorType.BACKGROUND_RESTRICTIONS -> - BackgroundRestrictions(onIgnoreBackgroundRestrictions = onIgnoreBackgroundRestrictions) + BackgroundRestrictions( + onIgnoreBackgroundRestrictions = onIgnoreBackgroundRestrictions, + onDismissBackgroundRestrictions = onDismissBackgroundRestrictions, + ) } } } @@ -90,14 +96,15 @@ internal fun PhotosStatesContainer( } } } - @Preview @Composable -fun PhotosStatesContainerDisablePreview() { +private fun PhotosStatesContainerPreview( + @PreviewParameter(ViewStatePreviewParameterProvider::class) viewState: PhotosStatusViewState, +) { ProtonTheme { PhotosStatesContainer( modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), - viewState = PhotosStatusViewState.Disabled(true), + viewState = viewState, showPhotosStateBanner = true, onEnable = { }, onPermissions = { }, @@ -106,150 +113,25 @@ fun PhotosStatesContainerDisablePreview() { onResolveMissingFolder = { }, onChangeNetwork = { }, onIgnoreBackgroundRestrictions = { }, + onDismissBackgroundRestrictions = { }, ) } } -@Preview -@Composable -fun PhotosStatesContainerMissingFolderPreview() { - ProtonTheme { - PhotosStatesContainer( - modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), - viewState = PhotosStatusViewState.Disabled(false), - showPhotosStateBanner = true, - onEnable = { }, - onPermissions = { }, - onRetry = { }, - onResolve = { }, - onResolveMissingFolder = { }, - onChangeNetwork = { }, - onIgnoreBackgroundRestrictions = { }, - ) - } -} - -@Preview -@Composable -fun PhotosStatesContainerCompletePreview() { - ProtonTheme { - PhotosStatesContainer( - modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), - viewState = PhotosStatusViewState.Complete("1 000 items saved"), - showPhotosStateBanner = true, - onEnable = { }, - onPermissions = { }, - onRetry = { }, - onResolve = { }, - onResolveMissingFolder = { }, - onChangeNetwork = { }, - onIgnoreBackgroundRestrictions = { }, - ) - } -} - -@Preview -@Composable -fun PhotosStatesContainerUncompletedPreview() { - ProtonTheme { - PhotosStatesContainer( - modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), - viewState = PhotosStatusViewState.Uncompleted, - showPhotosStateBanner = true, - onEnable = { }, - onPermissions = { }, - onRetry = { }, - onResolve = { }, - onResolveMissingFolder = { }, - onChangeNetwork = { }, - onIgnoreBackgroundRestrictions = { }, - ) - } -} - -@Preview -@Composable -fun PhotosStatesContainerInProgressPreview() { - ProtonTheme { - PhotosStatesContainer( - modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), - viewState = PhotosStatusViewState.InProgress( - 0.1F, - "X items left" - ), - showPhotosStateBanner = true, - onEnable = { }, - onPermissions = { }, - onRetry = { }, - onResolve = { }, - onResolveMissingFolder = { }, - onChangeNetwork = { }, - onIgnoreBackgroundRestrictions = { }, - ) - } -} - -@Preview -@Composable -fun PhotosStatesContainerFailedPreview() { - ProtonTheme { - PhotosStatesContainer( - modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), - viewState = PhotosStatusViewState.Failed( - errors = listOf(BackupError.Permissions()) - ), - showPhotosStateBanner = true, - onEnable = { }, - onPermissions = { }, - onRetry = { }, - onResolve = { }, - onResolveMissingFolder = { }, - onChangeNetwork = { }, - onIgnoreBackgroundRestrictions = { }, - ) - } -} - - -@Preview -@Composable -fun PhotosStatesContainerFailedConnectivityPreview() { - ProtonTheme { - PhotosStatesContainer( - modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), - viewState = PhotosStatusViewState.Failed( - errors = listOf(BackupError.Connectivity()) - ), - showPhotosStateBanner = true, - onEnable = { }, - onPermissions = { }, - onRetry = { }, - onResolve = { }, - onResolveMissingFolder = { }, - onChangeNetwork = { }, - onIgnoreBackgroundRestrictions = { }, - ) - } -} - - -@Preview -@Composable -fun PhotosStatesContainerFailedWifiConnectivityPreview() { - ProtonTheme { - PhotosStatesContainer( - modifier = Modifier.background(ProtonTheme.colors.backgroundNorm), - viewState = PhotosStatusViewState.Failed( - errors = listOf(BackupError.WifiConnectivity()) - ), - showPhotosStateBanner = true, - onEnable = { }, - onPermissions = { }, - onRetry = { }, - onResolve = { }, - onResolveMissingFolder = { }, - onChangeNetwork = { }, - onIgnoreBackgroundRestrictions = { }, - ) - } -} +private class ViewStatePreviewParameterProvider : CollectionPreviewParameterProvider( + listOf( + PhotosStatusViewState.Disabled(hasDefaultFolder = true), + PhotosStatusViewState.Disabled(hasDefaultFolder = false), + PhotosStatusViewState.Complete("1 000 items saved"), + PhotosStatusViewState.Uncompleted, + PhotosStatusViewState.InProgress(0.1F, "X items left"), + PhotosStatusViewState.Failed(listOf(BackupError.Other())), + PhotosStatusViewState.Failed(listOf(BackupError.LocalStorage())), + PhotosStatusViewState.Failed(listOf(BackupError.DriveStorage())), + PhotosStatusViewState.Failed(listOf(BackupError.Permissions())), + PhotosStatusViewState.Failed(listOf(BackupError.Connectivity())), + PhotosStatusViewState.Failed(listOf(BackupError.WifiConnectivity())), + PhotosStatusViewState.Failed(listOf(BackupError.PhotosUploadNotAllowed())), + PhotosStatusViewState.Failed(listOf(BackupError.BackgroundRestrictions())), + ) +) diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewevent/PhotosUpsellViewEvent.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewevent/PhotosUpsellViewEvent.kt new file mode 100644 index 00000000..7486e6cc --- /dev/null +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewevent/PhotosUpsellViewEvent.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023-2024 Proton AG. + * This file is part of Proton Drive. + * + * Proton Drive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Drive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Drive. If not, see . + */ + +package me.proton.android.drive.photos.presentation.viewevent + +interface PhotosUpsellViewEvent { + val onMoreStorage: () -> Unit get() = {} + val onCancel: () -> Unit get() = {} + val onDismiss: () -> Unit get() = {} +} diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewevent/PhotosViewEvent.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewevent/PhotosViewEvent.kt index db6305a0..ca654df7 100644 --- a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewevent/PhotosViewEvent.kt +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewevent/PhotosViewEvent.kt @@ -46,5 +46,7 @@ interface PhotosViewEvent { val onResolveMissingFolder: () -> Unit get() = {} val onChangeNetwork: () -> Unit get() = {} val onIgnoreBackgroundRestrictions: (Context) -> Unit get() = {} + val onDismissBackgroundRestrictions: () -> Unit get() = {} val onResolve: () -> Unit get() = {} + val onShowUpsell: () -> Unit } diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewmodel/LibraryFoldersViewModel.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewmodel/LibraryFoldersViewModel.kt index 6b099ee2..2aed36e6 100644 --- a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewmodel/LibraryFoldersViewModel.kt +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewmodel/LibraryFoldersViewModel.kt @@ -25,13 +25,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import me.proton.android.drive.photos.domain.usecase.GetPhotosDriveLink import me.proton.android.drive.photos.domain.usecase.SetupPhotosConfigurationBackup @@ -51,15 +49,17 @@ import me.proton.core.drive.base.data.extension.getDefaultMessage import me.proton.core.drive.base.data.extension.log import me.proton.core.drive.base.domain.extension.filterSuccessOrError import me.proton.core.drive.base.domain.extension.onFailure +import me.proton.core.drive.base.domain.extension.toResult import me.proton.core.drive.base.domain.log.LogTag.BACKUP import me.proton.core.drive.base.domain.provider.ConfigurationProvider import me.proton.core.drive.base.domain.usecase.BroadcastMessages import me.proton.core.drive.base.domain.util.coRunCatching import me.proton.core.drive.base.presentation.extension.quantityString import me.proton.core.drive.base.presentation.extension.require -import me.proton.core.drive.drivelink.domain.entity.DriveLink import me.proton.core.drive.link.domain.entity.FolderId import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage +import me.proton.core.drive.share.domain.entity.Share +import me.proton.core.drive.share.domain.usecase.GetShares import javax.inject.Inject import me.proton.core.drive.i18n.R as I18N @@ -70,6 +70,7 @@ class LibraryFoldersViewModel @Inject constructor( savedStateHandle: SavedStateHandle, getAllBuckets: GetAllBuckets, getFoldersFlow: GetFoldersFlow, + getShares: GetShares, getPhotosDriveLink: GetPhotosDriveLink, private val configurationProvider: ConfigurationProvider, private val broadcastMessages: BroadcastMessages, @@ -80,12 +81,14 @@ class LibraryFoldersViewModel @Inject constructor( private val userId = UserId(savedStateHandle.require("userId")) - val driveLink: StateFlow = getPhotosDriveLink(userId) + private val folderId: Flow = getShares(userId, Share.Type.PHOTO) .filterSuccessOrError() .map { result -> result - .onSuccess { driveLink -> - return@map driveLink + .onSuccess { shares -> + return@map shares.firstOrNull()?.let { share -> + FolderId(share.id, share.rootLinkId) + } } .onFailure { error -> error.log(BACKUP) @@ -99,10 +102,12 @@ class LibraryFoldersViewModel @Inject constructor( ) } return@map null - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + } - private val folders = driveLink.filterNotNull().flatMapLatest { folder -> - getFoldersFlow(folder.id) + private val photosDriveLink = getPhotosDriveLink(userId).filterSuccessOrError() + + private val folders = folderId.filterNotNull().flatMapLatest { folderId -> + getFoldersFlow(folderId) } val state = combine( @@ -170,13 +175,13 @@ class LibraryFoldersViewModel @Inject constructor( override val onToggleBucket: (Int, Boolean) -> Unit = { id, enable -> viewModelScope.launch { coRunCatching { - val folder = requireNotNull(driveLink.value) + val folderId = photosDriveLink.toResult().getOrThrow().id val backupFolder = BackupFolder( bucketId = id, - folderId = folder.id + folderId = folderId ) if (enable) { - setupPhotosConfigurationBackup(folder.id).getOrThrow() + setupPhotosConfigurationBackup(folderId).getOrThrow() enableBackupForFolder(backupFolder).getOrThrow() } else { navigateToConfirmStopSyncFolder( diff --git a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewstate/PhotosViewState.kt b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewstate/PhotosViewState.kt index 8b40b884..7205b894 100644 --- a/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewstate/PhotosViewState.kt +++ b/photos/presentation/src/main/kotlin/me/proton/android/drive/photos/presentation/viewstate/PhotosViewState.kt @@ -36,4 +36,5 @@ data class PhotosViewState( val backupStatusViewState: PhotosStatusViewState?, val selected: Flow> = emptyFlow(), val isRefreshEnabled: Boolean = true, + val notificationDotVisible: Boolean = false, ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 239f36dc..066c14c6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -76,7 +76,7 @@ pluginManagement { } plugins { - id("me.proton.core.gradle-plugins.include-core-build") version "1.2.0" + id("me.proton.core.gradle-plugins.include-core-build") version "1.3.0" id("com.gradle.enterprise") version "3.12.6" }