This commit is contained in:
Damir Mihaljinec
2024-04-04 09:53:13 +02:00
parent 79fad0c221
commit d2d7a64960
412 changed files with 38353 additions and 1375 deletions
+93 -8
View File
@@ -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"
+9
View File
@@ -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)
+3
View File
@@ -40,6 +40,7 @@
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/ProtonTheme.Splash.Drive.StatusBarFix">
<intent-filter>
@@ -66,6 +67,8 @@
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
@@ -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,7 +29,9 @@ 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
@@ -55,7 +57,7 @@ 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.QuotaDatabase
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
@@ -138,6 +140,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
@@ -196,7 +201,7 @@ abstract class DriveDatabaseBindsModule {
abstract fun provideBackupDatabase(db: DriveDatabase): BackupDatabase
@Binds
abstract fun provideQuotaDatabase(db: DriveDatabase): QuotaDatabase
abstract fun provideUserMessageDatabase(db: DriveDatabase): UserMessageDatabase
@Binds
abstract fun provideObservabilityDatabase(db: DriveDatabase): ObservabilityDatabase
@@ -233,4 +238,7 @@ abstract class DriveDatabaseBindsModule {
@Binds
abstract fun provideDeviceDatabase(appDatabase: DriveDatabase): DeviceDatabase
@Binds
abstract fun provideBaseDatabase(appDatabase: DriveDatabase): BaseDatabase
}
@@ -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,18 @@
package me.proton.android.drive.extension
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.map
fun NavHostController.runFromRoute(route: String, block: () -> Unit) = takeIf {
currentDestination?.route == route
}?.let {
block()
}
@Composable
fun NavHostController.isCurrentDestination(route: String) = currentBackStack.map { entries ->
entries.lastOrNull()?.destination?.route == route
}.collectAsState(initial = false)
@@ -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
@@ -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<Unit> {
@@ -52,6 +52,7 @@ class MainInitializer : Initializer<Unit> {
BackupInitializer::class.java,
TelemetryInitializer::class.java,
SelectionInitializer::class.java,
PingActiveUserInitializer::class.java,
)
companion object {
@@ -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<Unit> {
}
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<Unit> {
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)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Unit> {
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<Class<out Initializer<*>>> = listOf(
LoggerInitializer::class.java,
)
@EntryPoint
@InstallIn(SingletonComponent::class)
interface PingActiveUserInitializerEntryPoint {
val accountManager: AccountManager
val appLifecycleProvider: AppLifecycleProvider
val pingActiveUser: PingActiveUser
}
}
@@ -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<String> = 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"
}
}
@@ -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)
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ComputerOptionsViewModel>()
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<DeviceOptionEntry>,
modifier: Modifier = Modifier,
) {
DeviceOptions(
device = device,
entries = entries,
modifier = modifier,
)
}
object ComputerOptionsTestTag {
const val computerOptions = "computer options context menu"
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.effect
sealed interface PhotosEffect {
data object ShowUpsell : PhotosEffect
}
@@ -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<Unit>,
deepLinkIntent: SharedFlow<Intent>,
defaultStartDestination: String,
locked: Flow<Boolean>,
primaryAccount: Flow<Account?>,
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<String>(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<String>(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<String>(Screen.Files.SHARE_ID)
val currentFolderId = navBackStackEntry.get<String>(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,
)
}
)
}
@@ -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<String>(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<String>(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<String>(Screen.Files.SHARE_ID)
val argFolderId = navBackStackEntry.get<String>(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<String>(Screen.Computers.USER_ID)?.let { userId ->
val argShareId = navBackStackEntry.get<String>(Screen.Files.SHARE_ID)
val argFolderId = navBackStackEntry.get<String>(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
}
}
}
}
@@ -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 {
@@ -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<ComputersViewModel>()
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"
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<GetMoreFreeStorageViewModel>()
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<GetMoreFreeStorageViewState.Action>,
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"
}
@@ -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,
)
}
}
@@ -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<Boolean>,
navigateToHomeScreen: (userId: UserId) -> Unit,
modifier: Modifier = Modifier,
) {
val launcherViewModel = hiltViewModel<LauncherViewModel>()
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<Boolean>,
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)
}
}
@@ -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"
}
@@ -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<PhotosViewModel>()
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<PhotosUpsellViewModel>()
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(),
)
}
}
@@ -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"
}
@@ -24,4 +24,5 @@ interface ComputersViewEvent {
val onTopAppBarNavigation: () -> Unit get() = {}
val onDevice: (Device) -> Unit get() = { _ -> }
val onRefresh: () -> Unit get() = {}
val onMoreOptions: (Device) -> Unit get() = { _ -> }
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Device?> = getDecryptedDevice(
userId = userId,
deviceId = deviceId,
)
.mapSuccessValueOrNull()
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun entries(
runAction: (suspend () -> Unit) -> Unit,
navigateToRenameComputer: (DeviceId, FolderId) -> Unit,
): List<DeviceOptionEntry> = listOf(
RenameDeviceOption { device ->
runAction { navigateToRenameComputer(device.id, device.rootLinkId) }
}
)
companion object {
const val KEY_DEVICE_ID = "deviceId"
}
}
@@ -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<HomeEffect>()
override val homeEffect: Flow<HomeEffect>
@@ -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<ComputersViewState> = 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
@@ -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<String>(Screen.Files.SHARE_ID)
private val folderId = savedStateHandle.get<String>(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<ListEffect>
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<GetMoreFreeStorageViewState> = 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,
)
}
}
}
}
@@ -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<Map<out HomeTab, NavigationTab>> = 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<String?>(null)
fun setCurrentDestination(route: String) {
currentDestination.value = route
}
@@ -86,7 +89,7 @@ class HomeViewModel @Inject constructor(
val viewState: Flow<HomeViewState> =
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<out HomeTab, NavigationTab>,
@@ -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,
)
)
}
@@ -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<Share?> =
(shareId?.let {
getShare(shareId, flowOf(false))
.mapSuccessValueOrNull()
} ?: flowOf(null))
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val driveLinksToMove: StateFlow<List<DriveLink>> = 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
@@ -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<Boolean>
companion object {
operator fun invoke(shouldUpgradeStorage: ShouldUpgradeStorage) =
object : NotificationDotViewModel {
override val notificationDotRequested: Flow<Boolean> =
shouldUpgradeStorage().map { it.asBoolean }
}
}
}
@@ -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
),
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}
@@ -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<Int?>(null)
private val forceStatusExpand = MutableStateFlow(false)
private val _photosEffect = MutableSharedFlow<PhotosEffect>()
private val photosEffectShowUpsell = showUpsell(userId).transformLatest { show ->
if (show) {
CoreLogger.d(PHOTO, "photosEffectShowUpsell")
emit(PhotosEffect.ShowUpsell)
}
}
val photosEffect: Flow<PhotosEffect> = 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<PhotosViewState> = 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
@@ -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,
@@ -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<HomeEffect>()
private val refreshTrigger = MutableSharedFlow<Unit>(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)
@@ -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,
)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Action>,
) {
data class Action(
@DrawableRes val iconResId: Int,
@StringRes val titleResId: Int,
val getDescription: @Composable () -> AnnotatedString,
val isDone: Boolean,
val onSubtitleClick: (Int) -> Unit = {},
)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
@@ -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<Intent>, accountViewModel: AccountViewModel) =
operator fun invoke(
intent: Intent,
deepLinkIntent: MutableSharedFlow<Intent>,
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<Intent>,
) {
deepLinkIntent.emit(intent)
}
private suspend fun actionSend(
deepLinkIntent: MutableSharedFlow<Intent>,
accountViewModel: AccountViewModel,
@@ -176,6 +191,12 @@ inline fun <T> Intent.onActionSendMultiple(block: (intent: Intent) -> T): T? = t
block(this)
}
inline fun <T> 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 <reified T : Parcelable> getParcelableExtra(
intent: Intent,
+19
View File
@@ -1,7 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<resources>
<bool name="core_feature_key_transparency_enabled">false</bool>
<bool name="core_feature_account_recovery_enabled">true</bool>
<bool name="core_feature_event_manager_worker_repeat_internal_background_by_bucket">true</bool>
<bool name="core_feature_notifications_enabled">true</bool>
<!-- Show system Notification permission request dialog after login. -->
<bool name="core_feature_notifications_permission_request_enabled">false</bool>
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="primary-user-files"
android:enabled="true"
android:icon="@drawable/ic_proton_folder"
android:shortcutShortLabel="@string/title_files"
android:shortcutLongLabel="@string/title_files">
<intent
android:action="android.intent.action.VIEW"
android:data="drive://proton.me/launcher?redirection=files"/>
</shortcut>
<shortcut
android:shortcutId="primary-user-link"
android:enabled="true"
android:icon="@drawable/ic_proton_link"
android:shortcutShortLabel="@string/title_shared"
android:shortcutLongLabel="@string/title_shared">
<intent
android:action="android.intent.action.VIEW"
android:data="drive://proton.me/launcher?redirection=shared"/>
</shortcut>
<shortcut
android:shortcutId="primary-user-computers"
android:enabled="true"
android:icon="@drawable/ic_proton_tv"
android:shortcutShortLabel="@string/computers_title"
android:shortcutLongLabel="@string/computers_title">
<intent
android:action="android.intent.action.VIEW"
android:data="drive://proton.me/launcher?redirection=computers"/>
</shortcut>
</shortcuts>
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:enabled="true"
android:icon="@drawable/ic_proton_folder">
<intent
android:action="android.intent.action.VIEW"
android:data="drive://proton.me/launcher?redirection=files"/>
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_proton_link">
<intent
android:action="android.intent.action.VIEW"
android:data="drive://proton.me/launcher?redirection=shared"/>
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_proton_tv">
<intent
android:action="android.intent.action.VIEW"
android:data="drive://proton.me/launcher?redirection=computers"/>
</shortcut>
</shortcuts>
@@ -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 = "",
@@ -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 = "",
@@ -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",
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<GetDecryptedDevicesSortedByName>()
private val savedStateHandle: SavedStateHandle = mockk<SavedStateHandle>()
private val refreshDevices: RefreshDevices = mockk<RefreshDevices>()
private val broadcastMessages: BroadcastMessages = mockk<BroadcastMessages>()
private val configurationProvider: ConfigurationProvider = mockk<ConfigurationProvider>()
private val shouldUpgradeStorage: ShouldUpgradeStorage = mockk<ShouldUpgradeStorage>()
@Before
fun setup() {
coEvery { savedStateHandle.get<String>(any()) } returns "saved_state_handle_key_value"
coEvery { getDevices.invoke(any()) } returns flowOf(emptyList<Device>().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" }
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, FeatureFlag.State>()
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
@@ -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 }
enum class QuotaUnit { KB, MB, GB, TB, PB }
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<File>) {
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)
}
})
}
}
@@ -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<String, String>? = 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)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
@@ -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 {
@@ -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 <T : Robot> deeplinkTo(route: String, robot: T): T {
ActivityScenario.launch<MainActivity>(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 <T : Robot> 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<Context>()
ActivityScenario.launch<MainActivity>(Intent(context, MainActivity::class.java).apply {
ActivityScenario.launch<MainActivity>(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<File>): UploadToRobot {
val context = ApplicationProvider.getApplicationContext<Context>()
ActivityScenario.launch<MainActivity>(Intent(context, MainActivity::class.java).apply {
ActivityScenario.launch<MainActivity>(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))
@@ -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 <T : Robot> 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)
@@ -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()
}
@@ -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() {
@@ -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 <T: Robot> clickNotNow(goesTo: T) = notNowButton.clickTo(goesTo)
@@ -51,4 +53,4 @@ object PhotosNoPermissionsRobot: Robot {
settingsButton.await { assertIsDisplayed() }
allowAccessImage.await { assertIsDisplayed() }
}
}
}
@@ -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()
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
@@ -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)
}
}
}
}
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)
}
}
}
@@ -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() }
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 <T : Robot> clickDismiss(goesTo: T) = dismissButton.clickTo(goesTo)
override fun robotDisplayed() {
title.await { assertIsDisplayed() }
}
}
@@ -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() }
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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() }
}
}
@@ -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 <T : Robot> 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() }
}
@@ -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
}
@@ -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 <T> User.updateWith(value: T?, block: User.(T) -> User) = value?.let {
block(it)
} ?: this
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
@@ -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<MainActivity>()
@Suppress("unused")
protected fun signIn(existingUser: User) {
AddAccountRobot
.clickSignIn()
.login(existingUser)
}
}
@@ -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()
}
@@ -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()
@@ -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() }
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
)
)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
}
@@ -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()
@@ -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)
@@ -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()
@@ -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 <https://www.gnu.org/licenses/>.
*/
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() }
}
}
@@ -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)
@@ -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)
@@ -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 {
@@ -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)
}
}
}
@@ -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)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}
@@ -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)
}
}
}
@@ -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()
@@ -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)
@@ -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()
@@ -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()
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More