diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b3083e27..82b05fd8 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -12,10 +12,10 @@ variables:
workflow:
rules:
+ - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "candidate"
- - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "candidate"
before_script:
# We must keep these variables here. We can't do it inside the entrypoint, as idk how but
@@ -262,10 +262,12 @@ drive-sorting-presentation-firebase-tests:
allow_failure: true
rules:
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:all/'
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"
when: manual
+
test:firebase:e2e:smoke:
extends: .app-firebase-tests
when: manual
@@ -277,67 +279,128 @@ test:firebase:e2e:smoke:
test:firebase:e2e:account:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:account/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.account"
+
+test:firebase:e2e:computers:
+ extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:computers/'
+ - !reference [.app-firebase-tests, rules]
+ variables:
+ TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.computers"
+
test:firebase:e2e:creatingFolder:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:creatingFolder/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.creatingFolder"
+test:firebase:e2e:deeplink:
+ extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:deeplink/'
+ - !reference [.app-firebase-tests, rules]
+ variables:
+ TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.deeplink"
+
test:firebase:e2e:details:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:details/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.details"
test:firebase:e2e:move:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:move/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.move"
DEVICE_CONFIG: "quickTest-2"
test:firebase:e2e:offline:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:offline/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.offline"
+test:firebase:e2e:photos:
+ extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:photos/'
+ - !reference [.app-firebase-tests, rules]
+ variables:
+ TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.photos"
+
+test:firebase:e2e:preview:
+ extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:preview/'
+ - !reference [.app-firebase-tests, rules]
+ variables:
+ TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.preview"
+
test:firebase:e2e:rename:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:rename/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.rename"
test:firebase:e2e:settings:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:settings/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.settings"
DEVICE_CONFIG: "quickTest-3"
test:firebase:e2e:share:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:share/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.share"
DEVICE_CONFIG: "quickTest-2"
test:firebase:e2e:subscription:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:subscription/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.subscription"
test:firebase:e2e:trash:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:trash/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.trash"
test:firebase:e2e:upload:
extends: .app-firebase-tests
+ rules:
+ - if: '$CI_MERGE_REQUEST_LABELS =~ /scope:upload/'
+ - !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.upload"
-test:firebase:e2e:photos:
- extends: .app-firebase-tests
- variables:
- TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.photos"
-
coverage report:
stage: report
tags:
@@ -404,10 +467,32 @@ publish to firebase app distribution:
--release-notes-file "app/src/main/play/release-notes/en-US/default.txt"
--groups "qa-team, dev-team, management-team"
rules:
- - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"
- when: manual
+ - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
allow_failure: true
+distribute:debug:
+ stage: publish
+ image: $CI_REGISTRY/tpe/test-scripts
+ needs:
+ - job: "build:dev:debug"
+ artifacts: true
+ rules:
+ - if: '$CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH'
+ allow_failure: true
+ script:
+ - upload_to_nexus.py
+ --path app/build/outputs/apk/dev/debug/ProtonDrive-*-dev-debug.apk
+ --repository "TestData/builds/Drive/Android/LatestDevDebug/"
+ --filename "latest-drive-dev-debug.apk"
+ - upload_to_nexus.py
+ --path app/build/outputs/apk/androidTest/dev/debug/ProtonDrive-*-dev-debug-androidTest.apk
+ --repository "TestData/builds/Drive/Android/LatestTestDevDebug"
+ --filename "latest-drive-test-dev-debug.apk"
+ # Get latest commit information, save it to commit_info.json file and upload to Nexus.
+ - upload_mr_description_to_nexus.py
+ --token $PRIVATE_TOKEN_GITLAB_API_PROTON_CI
+ --repository TestData/builds/Drive/Android/LatestDevDebug
+
startReview:
needs:
- job: "prepare-build"
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b4afec5e..706ab8f7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -122,6 +122,15 @@ android {
}
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
+ managedDevices {
+ localDevices {
+ create("pixel2api30") {
+ device = "Pixel 2"
+ apiLevel = 30
+ systemImageSource = "aosp"
+ }
+ }
+ }
}
val gitHash = "git rev-parse --short HEAD".runCommand(workingDir = rootDir)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2cf670c7..8b0845d1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -40,6 +40,7 @@
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTop"
+ android:taskAffinity=""
android:theme="@style/ProtonTheme.Splash.Drive.StatusBarFix">
@@ -66,6 +67,8 @@
+
Unit) = takeIf {
currentDestination?.route == route
}?.let {
block()
}
+
+@Composable
+fun NavHostController.isCurrentDestination(route: String) = currentBackStack.map { entries ->
+ entries.lastOrNull()?.destination?.route == route
+}.collectAsState(initial = false)
diff --git a/app/src/main/kotlin/me/proton/android/drive/extension/ShouldUpgradeStorage.kt b/app/src/main/kotlin/me/proton/android/drive/extension/ShouldUpgradeStorage.kt
new file mode 100644
index 00000000..3b05ba90
--- /dev/null
+++ b/app/src/main/kotlin/me/proton/android/drive/extension/ShouldUpgradeStorage.kt
@@ -0,0 +1,5 @@
+package me.proton.android.drive.extension
+
+import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage
+
+internal val ShouldUpgradeStorage.Result.asBoolean: Boolean get() = this != ShouldUpgradeStorage.Result.NoUpgrade
diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/EventManagerInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/EventManagerInitializer.kt
index 943e93ef..bf051863 100644
--- a/app/src/main/kotlin/me/proton/android/drive/initializer/EventManagerInitializer.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/initializer/EventManagerInitializer.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021-2023 Proton AG.
+ * Copyright (c) 2021-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -24,7 +24,7 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
-import me.proton.core.drive.eventmanager.DriveEventManager
+import me.proton.core.drive.eventmanager.presentation.DriveEventManager
@Suppress("unused")
class EventManagerInitializer : Initializer {
diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt
index 63a0e40c..6eaeb290 100644
--- a/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/initializer/MainInitializer.kt
@@ -52,6 +52,7 @@ class MainInitializer : Initializer {
BackupInitializer::class.java,
TelemetryInitializer::class.java,
SelectionInitializer::class.java,
+ PingActiveUserInitializer::class.java,
)
companion object {
diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/NotificationChannelInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/NotificationChannelInitializer.kt
index eefd1b5d..1b8bd292 100644
--- a/app/src/main/kotlin/me/proton/android/drive/initializer/NotificationChannelInitializer.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/initializer/NotificationChannelInitializer.kt
@@ -28,6 +28,7 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.launch
import me.proton.android.drive.usecase.CancelAllForegroundServices
+import me.proton.core.account.domain.entity.Account
import me.proton.core.accountmanager.domain.AccountManager
import me.proton.core.accountmanager.presentation.observe
import me.proton.core.accountmanager.presentation.onAccountReady
@@ -56,7 +57,7 @@ class NotificationChannelInitializer : Initializer {
}
accountManager.observe(appLifecycleProvider.lifecycle, Lifecycle.State.STARTED)
.onAccountReady { account ->
- createNotificationChannels(account.userId, account.username)
+ createNotificationChannels(account.userId, account.name)
}
.onAccountRemoved { account ->
cancelAllForegroundServices(account.userId)
@@ -79,4 +80,7 @@ class NotificationChannelInitializer : Initializer {
val removeNotificationChannels: RemoveNotificationChannels
val cancelAllForegroundServices: CancelAllForegroundServices
}
+
+ // We should not have an Account without username yet we'll have a fallback
+ private val Account.name get() = username ?: userId.id.take(6)
}
diff --git a/app/src/main/kotlin/me/proton/android/drive/initializer/PingActiveUserInitializer.kt b/app/src/main/kotlin/me/proton/android/drive/initializer/PingActiveUserInitializer.kt
new file mode 100644
index 00000000..8bfe996e
--- /dev/null
+++ b/app/src/main/kotlin/me/proton/android/drive/initializer/PingActiveUserInitializer.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2024 Proton AG.
+ * This file is part of Proton Drive.
+ *
+ * Proton Drive is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Proton Drive is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Proton Drive. If not, see .
+ */
+
+package me.proton.android.drive.initializer
+
+import android.content.Context
+import androidx.lifecycle.coroutineScope
+import androidx.startup.Initializer
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import me.proton.android.drive.extension.log
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.drive.base.domain.log.LogTag
+import me.proton.core.drive.data.domain.usecase.PingActiveUser
+import me.proton.core.presentation.app.AppLifecycleProvider
+
+class PingActiveUserInitializer : Initializer {
+ override fun create(context: Context) {
+ with (
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ PingActiveUserInitializerEntryPoint::class.java
+ )
+ ) {
+ combine(
+ appLifecycleProvider.state,
+ accountManager.getPrimaryUserId(),
+ ) { state, primaryUserId ->
+ takeIf { state == AppLifecycleProvider.State.Foreground }
+ ?.let {
+ primaryUserId
+ }
+ }
+ .distinctUntilChanged()
+ .filterNotNull()
+ .onEach { primaryUserId ->
+ pingActiveUser(primaryUserId)
+ .onFailure { error ->
+ error.log(LogTag.TELEMETRY, "Ping active user failed")
+ }
+ }
+ .launchIn(appLifecycleProvider.lifecycle.coroutineScope)
+ }
+ }
+
+ override fun dependencies(): List>> = listOf(
+ LoggerInitializer::class.java,
+ )
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface PingActiveUserInitializerEntryPoint {
+ val accountManager: AccountManager
+ val appLifecycleProvider: AppLifecycleProvider
+ val pingActiveUser: PingActiveUser
+ }
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/settings/DebugSettings.kt b/app/src/main/kotlin/me/proton/android/drive/settings/DebugSettings.kt
index b4c55a47..d784842f 100644
--- a/app/src/main/kotlin/me/proton/android/drive/settings/DebugSettings.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/settings/DebugSettings.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -21,6 +21,7 @@ package me.proton.android.drive.settings
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -48,6 +49,7 @@ class DebugSettings(
private val prefsAllowBackupDeletedFilesEnabled = booleanPreferencesKey(ALLOW_BACKUP_DELETED_FILES_ENABLED)
private val prefsFeatureFlagFreshDuration = longPreferencesKey(FEATURE_FLAG_FRESH_DURATION)
private val prefsUseVerifier = booleanPreferencesKey(USE_VERIFIER)
+ private val prefsPhotosUpsellPhotoCount = intPreferencesKey(PHOTOS_UPSELL_PHOTO_COUNT)
val baseUrlFlow: Flow = prefsKeyBaseUrl.asFlow(
dataStore = context.dataStore,
default = buildConfig.baseUrl
@@ -130,6 +132,11 @@ class DebugSettings(
key = prefsUseVerifier,
default = buildConfig.useVerifier,
)
+ override var photosUpsellPhotoCount by Delegate(
+ dataStore = context.dataStore,
+ key = prefsPhotosUpsellPhotoCount,
+ default = buildConfig.photosUpsellPhotoCount,
+ )
fun reset(coroutineScope: CoroutineScope) {
coroutineScope.launch {
@@ -149,5 +156,6 @@ class DebugSettings(
const val ALLOW_BACKUP_DELETED_FILES_ENABLED = "allow_backup_deleted_files_enabled"
const val FEATURE_FLAG_FRESH_DURATION = "feature_flag_fresh_duration"
const val USE_VERIFIER = "use_verifier"
+ const val PHOTOS_UPSELL_PHOTO_COUNT = "photos_upsell_photo_count"
}
}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt b/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt
index 5ba7ad86..a889b6b8 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/MainActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -73,6 +73,7 @@ import me.proton.android.drive.lock.data.provider.BiometricPromptProvider
import me.proton.android.drive.lock.domain.manager.AppLockManager
import me.proton.android.drive.log.DriveLogTag
import me.proton.android.drive.ui.navigation.AppNavGraph
+import me.proton.android.drive.ui.navigation.Screen
import me.proton.android.drive.ui.provider.LocalSnackbarPadding
import me.proton.android.drive.ui.provider.ProvideLocalSnackbarPadding
import me.proton.android.drive.ui.viewmodel.AccountViewModel
@@ -156,6 +157,11 @@ class MainActivity : FragmentActivity() {
deepLinkBaseUrl = this@MainActivity.deepLinkBaseUrl,
clearBackstackTrigger = clearBackstackTrigger,
deepLinkIntent = deepLinkIntent,
+ defaultStartDestination = if (configurationProvider.photosFeatureFlag) {
+ Screen.Photos.route
+ } else {
+ Screen.Files.route
+ },
locked = appLockManager.locked,
primaryAccount = accountViewModel.primaryAccount,
exitApp = { finish() },
@@ -304,7 +310,7 @@ class MainActivity : FragmentActivity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.let {
- processIntent(intent, deepLinkIntent, accountViewModel)
+ processIntent(intent, deepLinkIntent, accountViewModel, true)
}
}
}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/dialog/ComputerOptions.kt b/app/src/main/kotlin/me/proton/android/drive/ui/dialog/ComputerOptions.kt
new file mode 100644
index 00000000..55d377bb
--- /dev/null
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/dialog/ComputerOptions.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2024 Proton AG.
+ * This file is part of Proton Drive.
+ *
+ * Proton Drive is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Proton Drive is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Proton Drive. If not, see .
+ */
+
+package me.proton.android.drive.ui.dialog
+
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.hilt.navigation.compose.hiltViewModel
+import me.proton.android.drive.ui.viewmodel.ComputerOptionsViewModel
+import me.proton.core.compose.flow.rememberFlowWithLifecycle
+import me.proton.core.drive.base.presentation.component.RunAction
+import me.proton.core.drive.device.domain.entity.Device
+import me.proton.core.drive.device.domain.entity.DeviceId
+import me.proton.core.drive.drivelink.device.presentation.component.DeviceOptions
+import me.proton.core.drive.drivelink.device.presentation.options.DeviceOptionEntry
+import me.proton.core.drive.link.domain.entity.FolderId
+
+@Composable
+fun ComputerOptions(
+ runAction: RunAction,
+ navigateToRenameComputer: (deviceId: DeviceId, folderId: FolderId) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val viewModel = hiltViewModel()
+ val viewModelDevice by rememberFlowWithLifecycle(viewModel.device).collectAsState(initial = null)
+ viewModelDevice?.let { device ->
+ val entries = viewModel.entries(
+ runAction,
+ navigateToRenameComputer,
+ )
+ ComputerOptions(
+ device = device,
+ entries = entries,
+ modifier = modifier
+ .navigationBarsPadding()
+ .testTag(ComputerOptionsTestTag.computerOptions),
+ )
+ }
+}
+
+@Composable
+fun ComputerOptions(
+ device: Device,
+ entries: List,
+ modifier: Modifier = Modifier,
+) {
+ DeviceOptions(
+ device = device,
+ entries = entries,
+ modifier = modifier,
+ )
+}
+
+object ComputerOptionsTestTag {
+ const val computerOptions = "computer options context menu"
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/effect/PhotosEffect.kt b/app/src/main/kotlin/me/proton/android/drive/ui/effect/PhotosEffect.kt
new file mode 100644
index 00000000..decf9eb8
--- /dev/null
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/effect/PhotosEffect.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 Proton AG.
+ * This file is part of Proton Drive.
+ *
+ * Proton Drive is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Proton Drive is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Proton Drive. If not, see .
+ */
+
+package me.proton.android.drive.ui.effect
+
+sealed interface PhotosEffect {
+ data object ShowUpsell : PhotosEffect
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt
index 7cf04c36..11bf2bff 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/AppNavGraph.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -52,6 +52,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import me.proton.android.drive.extension.get
+import me.proton.android.drive.extension.isCurrentDestination
+import me.proton.android.drive.extension.log
import me.proton.android.drive.extension.require
import me.proton.android.drive.extension.requireArguments
import me.proton.android.drive.extension.requireSerializable
@@ -60,6 +62,7 @@ import me.proton.android.drive.lock.presentation.component.AppLock
import me.proton.android.drive.log.DriveLogTag
import me.proton.android.drive.photos.presentation.component.PhotosPermissionRationale
import me.proton.android.drive.ui.dialog.AutoLockDurations
+import me.proton.android.drive.ui.dialog.ComputerOptions
import me.proton.android.drive.ui.dialog.ConfirmDeletionDialog
import me.proton.android.drive.ui.dialog.ConfirmEmptyTrashDialog
import me.proton.android.drive.ui.dialog.ConfirmSkipIssuesDialog
@@ -82,11 +85,13 @@ import me.proton.android.drive.ui.options.OptionsFilter
import me.proton.android.drive.ui.screen.AppAccessScreen
import me.proton.android.drive.ui.screen.BackupIssuesScreen
import me.proton.android.drive.ui.screen.FileInfoScreen
+import me.proton.android.drive.ui.screen.GetMoreFreeStorageScreen
import me.proton.android.drive.ui.screen.HomeScreen
import me.proton.android.drive.ui.screen.LauncherScreen
import me.proton.android.drive.ui.screen.MoveToFolder
import me.proton.android.drive.ui.screen.OfflineScreen
import me.proton.android.drive.ui.screen.PhotosBackupScreen
+import me.proton.android.drive.ui.screen.PhotosUpsellScreen
import me.proton.android.drive.ui.screen.PreviewScreen
import me.proton.android.drive.ui.screen.SettingsScreen
import me.proton.android.drive.ui.screen.SigningOutScreen
@@ -97,6 +102,8 @@ import me.proton.core.account.domain.entity.Account
import me.proton.core.compose.component.bottomsheet.ModalBottomSheetViewState
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
import me.proton.core.domain.entity.UserId
+import me.proton.core.drive.device.domain.entity.DeviceId
+import me.proton.core.drive.drivelink.device.presentation.component.RenameDevice
import me.proton.core.drive.drivelink.rename.presentation.Rename
import me.proton.core.drive.drivelink.shared.presentation.component.DiscardChangesDialog
import me.proton.core.drive.drivelink.shared.presentation.component.ShareViaLink
@@ -117,6 +124,7 @@ fun AppNavGraph(
deepLinkBaseUrl: String,
clearBackstackTrigger: SharedFlow,
deepLinkIntent: SharedFlow,
+ defaultStartDestination: String,
locked: Flow,
primaryAccount: Flow,
exitApp: () -> Unit,
@@ -160,8 +168,12 @@ fun AppNavGraph(
deepLinkIntent
.collectLatest { intent ->
CoreLogger.d(DriveLogTag.UI, "Deep link intent received")
- navController.handleDeepLink(intent)
- homeNavController = createNavController(localContext)
+ if (!navController.handleDeepLink(intent)) {
+ // clear query params with user information before logging
+ val uri = intent.data?.buildUpon()?.apply { clearQuery() }?.build()
+ IllegalStateException("Invalid deep link: $uri").log(DriveLogTag.UI)
+ homeNavController = createNavController(localContext)
+ }
}
}
AppLock(locked = locked, primaryAccount = primaryAccount) {
@@ -169,6 +181,7 @@ fun AppNavGraph(
navController = navController,
homeNavController = homeNavController,
deepLinkBaseUrl = deepLinkBaseUrl,
+ defaultStartDestination = defaultStartDestination,
exitApp = exitApp,
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
@@ -184,6 +197,7 @@ fun AppNavGraph(
navController: NavHostController,
homeNavController: NavHostController,
deepLinkBaseUrl: String,
+ defaultStartDestination: String,
exitApp: () -> Unit,
navigateToBugReport: () -> Unit,
navigateToSubscription: () -> Unit,
@@ -194,13 +208,17 @@ fun AppNavGraph(
startDestination = Screen.Launcher.route,
modifier = Modifier.fillMaxSize()
) {
- addLauncher(navController)
+ addLauncher(
+ navController = navController,
+ deepLinkBaseUrl = deepLinkBaseUrl,
+ )
addSignOutConfirmationDialog(navController)
addSigningOut()
addHome(
navController = navController,
homeNavController = homeNavController,
deepLinkBaseUrl = deepLinkBaseUrl,
+ defaultStartDestination = defaultStartDestination,
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
onDrawerStateChanged = onDrawerStateChanged,
@@ -238,6 +256,7 @@ fun AppNavGraph(
onDrawerStateChanged = onDrawerStateChanged,
)
addPhotosIssues(navController)
+ addPhotosUpsell(navigateToSubscription)
addConfirmSkipIssues(navController)
addConfirmStopSyncFolderDialog(navController)
addPhotosPermissionRationale(navController)
@@ -265,18 +284,35 @@ fun AppNavGraph(
addSystemAccessDialog(navController)
addAutoLockDurations(navController)
addPhotosBackup(navController)
+ addComputerOptions(navController)
+ addRenameComputerDialog(navController)
+ addGetMoreFreeStorage(navController)
}
}
@ExperimentalCoroutinesApi
@ExperimentalAnimationApi
-fun NavGraphBuilder.addLauncher(navController: NavHostController) = composable(
+fun NavGraphBuilder.addLauncher(
+ navController: NavHostController,
+ deepLinkBaseUrl: String,
+) = composable(
route = Screen.Launcher.route,
-) {
+ arguments = listOf(
+ navArgument(Screen.Launcher.REDIRECTION) {
+ type = NavType.StringType
+ nullable = true
+ },
+ ),
+ deepLinks = listOf(
+ navDeepLink { uriPattern = Screen.Launcher.deepLink(deepLinkBaseUrl) }
+ )
+) { navBackStackEntry ->
+ val redirection = navBackStackEntry.get(Screen.Launcher.REDIRECTION)
LauncherScreen(
+ foregroundState = navController.isCurrentDestination(route = Screen.Launcher.route),
navigateToHomeScreen = { userId ->
navController.runFromRoute(route = Screen.Launcher.route) {
- navController.navigate(Screen.Home(userId)) {
+ navController.navigate(Screen.Home(userId, redirection)) {
popUpTo(Screen.Launcher.route) { inclusive = true }
}
}
@@ -544,7 +580,7 @@ internal fun NavGraphBuilder.addHome(
homeNavController: NavHostController,
deepLinkBaseUrl: String,
route: String,
- startDestination: String,
+ defaultStartDestination: String,
navigateToBugReport: () -> Unit,
navigateToSubscription: () -> Unit,
onDrawerStateChanged: (Boolean) -> Unit,
@@ -558,6 +594,13 @@ internal fun NavGraphBuilder.addHome(
arguments = arguments,
) { navBackStackEntry ->
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
+ val startDestination = when (navBackStackEntry.get(Screen.Home.TAB)) {
+ Screen.Home.TAB_FILES -> Screen.Files.route
+ Screen.Home.TAB_PHOTOS -> Screen.Photos.route
+ Screen.Home.TAB_COMPUTERS -> Screen.Computers.route
+ Screen.Home.TAB_SHARED -> Screen.Shared.route
+ else -> defaultStartDestination
+ }
val shareId = navBackStackEntry.get(Screen.Files.SHARE_ID)
val currentFolderId = navBackStackEntry.get(Screen.Files.FOLDER_ID)?.let { folderId ->
shareId?.let {
@@ -613,6 +656,11 @@ internal fun NavGraphBuilder.addHome(
Screen.BackupIssues.invoke(folderId)
)
},
+ navigateToPhotosUpsell = {
+ navController.navigate(
+ Screen.Photos.Upsell(userId)
+ )
+ },
navigateToBackupSettings = {
navController.navigate(
Screen.Settings.PhotosBackup(userId)
@@ -623,6 +671,16 @@ internal fun NavGraphBuilder.addHome(
Screen.PhotosPermissionRationale(userId)
)
},
+ navigateToComputerOptions = { deviceId ->
+ navController.navigate(
+ Screen.ComputerOptions.invoke(userId, deviceId)
+ )
+ },
+ navigateToGetMoreFreeStorage = {
+ navController.navigate(
+ Screen.GetMoreFreeStorage(userId)
+ )
+ },
modifier = Modifier.fillMaxSize(),
)
}
@@ -633,6 +691,7 @@ fun NavGraphBuilder.addHome(
navController: NavHostController,
homeNavController: NavHostController,
deepLinkBaseUrl: String,
+ defaultStartDestination: String,
navigateToBugReport: () -> Unit,
navigateToSubscription: () -> Unit,
onDrawerStateChanged: (Boolean) -> Unit,
@@ -641,10 +700,19 @@ fun NavGraphBuilder.addHome(
homeNavController = homeNavController,
deepLinkBaseUrl = deepLinkBaseUrl,
route = Screen.Home.route,
- startDestination = Screen.Files.route,
+ defaultStartDestination = defaultStartDestination,
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
- onDrawerStateChanged= onDrawerStateChanged
+ onDrawerStateChanged= onDrawerStateChanged,
+ arguments = listOf(
+ navArgument(Screen.Home.USER_ID) {
+ type = NavType.StringType
+ },
+ navArgument(Screen.Home.TAB) {
+ type = NavType.StringType
+ nullable = true
+ },
+ ),
)
@ExperimentalAnimationApi
@@ -661,7 +729,7 @@ fun NavGraphBuilder.addHomeFiles(
homeNavController = homeNavController,
deepLinkBaseUrl = deepLinkBaseUrl,
route = Screen.Files.route,
- startDestination = Screen.Files.route,
+ defaultStartDestination = Screen.Files.route,
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
onDrawerStateChanged= onDrawerStateChanged,
@@ -694,7 +762,7 @@ fun NavGraphBuilder.addHomeShared(
homeNavController = homeNavController,
deepLinkBaseUrl = deepLinkBaseUrl,
route = Screen.Shared.route,
- startDestination = Screen.Shared.route,
+ defaultStartDestination = Screen.Shared.route,
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
onDrawerStateChanged = onDrawerStateChanged
@@ -714,7 +782,7 @@ fun NavGraphBuilder.addHomePhotos(
homeNavController = homeNavController,
deepLinkBaseUrl = deepLinkBaseUrl,
route = Screen.Photos.route,
- startDestination = Screen.Photos.route,
+ defaultStartDestination = Screen.Photos.route,
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
onDrawerStateChanged = onDrawerStateChanged
@@ -734,7 +802,7 @@ fun NavGraphBuilder.addHomeComputers(
homeNavController = homeNavController,
deepLinkBaseUrl = deepLinkBaseUrl,
route = Screen.Computers.route,
- startDestination = Screen.Computers.route,
+ defaultStartDestination = Screen.Computers.route,
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
onDrawerStateChanged = onDrawerStateChanged
@@ -1334,3 +1402,79 @@ fun NavGraphBuilder.addPhotosBackup(navController: NavHostController) = composab
},
)
}
+
+@ExperimentalAnimationApi
+fun NavGraphBuilder.addPhotosUpsell(
+ navigateToSubscription: () -> Unit,
+) = modalBottomSheet(
+ route = Screen.Photos.Upsell.route,
+ arguments = listOf(
+ navArgument(Screen.Settings.USER_ID) { type = NavType.StringType },
+ ),
+) { _, runAction ->
+ PhotosUpsellScreen(
+ runAction = runAction,
+ navigateToSubscription = navigateToSubscription,
+ )
+}
+
+fun NavGraphBuilder.addComputerOptions(
+ navController: NavHostController,
+) = modalBottomSheet(
+ route = Screen.ComputerOptions.route,
+ viewState = ModalBottomSheetViewState(dismissOnAction = false),
+ arguments = listOf(
+ navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
+ navArgument(Screen.ComputerOptions.DEVICE_ID) { type = NavType.StringType },
+ ),
+) { navBackStackEntry, runAction ->
+ val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
+ ComputerOptions(
+ runAction = runAction,
+ navigateToRenameComputer = { deviceId: DeviceId, folderId: FolderId ->
+ navController.navigate(Screen.Dialogs.RenameComputer.invoke(userId, deviceId, folderId)) {
+ popUpTo(route = Screen.ComputerOptions.route) { inclusive = true }
+ }
+ },
+ )
+}
+
+@ExperimentalCoroutinesApi
+fun NavGraphBuilder.addRenameComputerDialog(navController: NavHostController) = dialog(
+ route = Screen.Dialogs.RenameComputer.route,
+ arguments = listOf(
+ navArgument(Screen.USER_ID) { type = NavType.StringType },
+ navArgument(Screen.Dialogs.RenameComputer.FOLDER_ID) { type = NavType.StringType },
+ navArgument(Screen.Dialogs.RenameComputer.DEVICE_ID) { type = NavType.StringType },
+ ),
+) {
+ RenameDevice(
+ onDismiss = {
+ navController.popBackStack(
+ route = Screen.Dialogs.RenameComputer.route,
+ inclusive = true,
+ )
+ }
+ )
+}
+
+@ExperimentalAnimationApi
+fun NavGraphBuilder.addGetMoreFreeStorage(navController: NavHostController) = composable(
+ route = Screen.GetMoreFreeStorage.route,
+ enterTransition = defaultEnterSlideTransition(towards = AnimatedContentTransitionScope.SlideDirection.Up) { true },
+ exitTransition = { ExitTransition.None },
+ popEnterTransition = { EnterTransition.None },
+ popExitTransition = defaultPopExitSlideTransition(towards = AnimatedContentTransitionScope.SlideDirection.Down) { true },
+ arguments = listOf(
+ navArgument(Screen.Settings.USER_ID) { type = NavType.StringType },
+ ),
+) {
+ GetMoreFreeStorageScreen(
+ navigateBack = {
+ navController.popBackStack(
+ route = Screen.GetMoreFreeStorage.route,
+ inclusive = true,
+ )
+ }
+ )
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/HomeNavGraph.kt b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/HomeNavGraph.kt
index 7e6b6fa8..d2f9b6eb 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/HomeNavGraph.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/HomeNavGraph.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -30,7 +30,6 @@ import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import kotlinx.coroutines.ExperimentalCoroutinesApi
import me.proton.android.drive.extension.get
-import me.proton.android.drive.extension.require
import me.proton.android.drive.ui.navigation.animation.defaultEnterSlideTransition
import me.proton.android.drive.ui.navigation.animation.defaultExitSlideTransition
import me.proton.android.drive.ui.navigation.animation.defaultPopEnterSlideTransition
@@ -44,6 +43,7 @@ import me.proton.android.drive.ui.screen.SharedScreen
import me.proton.android.drive.ui.screen.SyncedFoldersScreen
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
import me.proton.core.domain.entity.UserId
+import me.proton.core.drive.device.domain.entity.DeviceId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
@@ -68,7 +68,9 @@ fun HomeNavGraph(
navigateToPhotosPermissionRationale: () -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
+ navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
+ navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
) = DriveNavHost(
navController = homeNavController,
startDestination = startDestination
@@ -85,13 +87,18 @@ fun HomeNavGraph(
navigateToParentFolderOptions,
)
addShared(
- homeScaffoldState,
homeNavController,
+ deepLinkBaseUrl,
+ arguments,
+ homeScaffoldState,
{ fileId -> navigateToPreview(fileId, PagerType.SINGLE, OptionsFilter.FILES) },
navigateToSorting,
{ linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
)
addPhotos(
+ homeNavController,
+ deepLinkBaseUrl,
+ arguments,
homeScaffoldState,
navigateToPhotosPermissionRationale,
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO, OptionsFilter.PHOTOS) },
@@ -101,16 +108,20 @@ fun HomeNavGraph(
},
navigateToSubscription = navigateToSubscription,
navigateToPhotosIssues = navigateToPhotosIssues,
+ navigateToPhotosUpsell = navigateToPhotosUpsell,
navigateToBackupSettings = navigateToBackupSettings,
)
addComputers(
- homeScaffoldState,
homeNavController,
+ deepLinkBaseUrl,
+ arguments,
+ homeScaffoldState,
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) },
navigateToSorting,
{ linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES) },
navigateToParentFolderOptions,
+ navigateToComputerOptions,
)
}
@@ -186,13 +197,14 @@ fun NavGraphBuilder.addFiles(
}
}
}
-
}
@ExperimentalAnimationApi
fun NavGraphBuilder.addShared(
- homeScaffoldState: HomeScaffoldState,
navController: NavHostController,
+ deepLinkBaseUrl: String,
+ arguments: Bundle,
+ homeScaffoldState: HomeScaffoldState,
navigateToPreview: (linkId: FileId) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
@@ -205,23 +217,40 @@ fun NavGraphBuilder.addShared(
nullable = true
defaultValue = null
},
+ ),
+ deepLinks = listOf(
+ navDeepLink { uriPattern = Screen.Shared.deepLink(deepLinkBaseUrl) }
)
) { navBackStackEntry ->
- val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
- SharedScreen(
- homeScaffoldState = homeScaffoldState,
- navigateToFiles = { folderId, folderName ->
- navController.navigate(Screen.Files(userId, folderId, folderName))
- },
- navigateToPreview = navigateToPreview,
- navigateToSortingDialog = navigateToSorting,
- navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
- )
+ navBackStackEntry.get(Screen.Shared.USER_ID)?.let { userId ->
+ SharedScreen(
+ homeScaffoldState = homeScaffoldState,
+ navigateToFiles = { folderId, folderName ->
+ navController.navigate(Screen.Files(UserId(userId), folderId, folderName))
+ },
+ navigateToPreview = navigateToPreview,
+ navigateToSortingDialog = navigateToSorting,
+ navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
+ )
+ } ?: let {
+ val userId = UserId(requireNotNull(arguments.getString(Screen.Shared.USER_ID)))
+ val shareId = arguments.getString(Screen.Shared.SHARE_ID)?.let { shareId ->
+ ShareId(userId, shareId)
+ }
+ navController.navigate(Screen.Shared(userId, shareId)) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = true
+ }
+ }
+ }
}
@ExperimentalAnimationApi
fun NavGraphBuilder.addPhotos(
+ navController: NavHostController,
+ deepLinkBaseUrl: String,
+ arguments: Bundle,
homeScaffoldState: HomeScaffoldState,
navigateToPhotosPermissionRationale: () -> Unit,
navigateToPhotosPreview: (fileId: FileId) -> Unit,
@@ -229,6 +258,7 @@ fun NavGraphBuilder.addPhotos(
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
+ navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
) = composable(
route = Screen.Photos.route,
@@ -239,30 +269,49 @@ fun NavGraphBuilder.addPhotos(
nullable = true
defaultValue = null
},
+ ),
+ deepLinks = listOf(
+ navDeepLink { uriPattern = Screen.Photos.deepLink(deepLinkBaseUrl) }
)
-) {
- PhotosScreen(
- homeScaffoldState = homeScaffoldState,
- navigateToPhotosPermissionRationale = navigateToPhotosPermissionRationale,
- navigateToPhotosPreview = navigateToPhotosPreview,
- navigateToPhotosOptions = navigateToPhotosOptions,
- navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions,
- navigateToSubscription = navigateToSubscription,
- navigateToPhotosIssues = navigateToPhotosIssues,
- navigateToBackupSettings = navigateToBackupSettings,
- )
+) { navBackStackEntry ->
+ navBackStackEntry.get(Screen.Photos.USER_ID)?.let { _ ->
+ PhotosScreen(
+ homeScaffoldState = homeScaffoldState,
+ navigateToPhotosPermissionRationale = navigateToPhotosPermissionRationale,
+ navigateToPhotosPreview = navigateToPhotosPreview,
+ navigateToPhotosOptions = navigateToPhotosOptions,
+ navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions,
+ navigateToSubscription = navigateToSubscription,
+ navigateToPhotosIssues = navigateToPhotosIssues,
+ navigateToPhotosUpsell = navigateToPhotosUpsell,
+ navigateToBackupSettings = navigateToBackupSettings,
+ )
+ } ?: let {
+ val userId = UserId(requireNotNull(arguments.getString(Screen.Photos.USER_ID)))
+ val shareId = arguments.getString(Screen.Photos.SHARE_ID)?.let { shareId ->
+ ShareId(userId, shareId)
+ }
+ navController.navigate(Screen.Photos(userId, shareId)) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = true
+ }
+ }
+ }
}
@ExperimentalCoroutinesApi
@ExperimentalAnimationApi
fun NavGraphBuilder.addComputers(
- homeScaffoldState: HomeScaffoldState,
navController: NavHostController,
+ deepLinkBaseUrl: String,
+ arguments: Bundle,
+ homeScaffoldState: HomeScaffoldState,
navigateToPreview: (linkId: FileId) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToMultipleFileOrFolderOptions: (SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
+ navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
) = composable(
route = Screen.Computers.route,
enterTransition = defaultEnterSlideTransition {
@@ -301,47 +350,87 @@ fun NavGraphBuilder.addComputers(
nullable = false
defaultValue = false
}
- )
+ ),
+ deepLinks = listOf(
+ navDeepLink { uriPattern = Screen.Computers.deepLink(deepLinkBaseUrl) }
+ ),
) { navBackStackEntry ->
- val userId = UserId(navBackStackEntry.require(Screen.Computers.USER_ID))
- val argShareId = navBackStackEntry.get(Screen.Files.SHARE_ID)
- val argFolderId = navBackStackEntry.get(Screen.Files.FOLDER_ID)
- val argSyncedFolders = navBackStackEntry.arguments?.getBoolean(Screen.Computers.SYNCED_FOLDERS, false) ?: false
- if (argShareId != null && argFolderId != null) {
- if (argSyncedFolders) {
- SyncedFoldersScreen(
+ navBackStackEntry.get(Screen.Computers.USER_ID)?.let { userId ->
+ val argShareId = navBackStackEntry.get(Screen.Files.SHARE_ID)
+ val argFolderId = navBackStackEntry.get(Screen.Files.FOLDER_ID)
+ val argSyncedFolders =
+ navBackStackEntry.arguments?.getBoolean(Screen.Computers.SYNCED_FOLDERS, false) ?: false
+ if (argShareId != null && argFolderId != null) {
+ if (argSyncedFolders) {
+ SyncedFoldersScreen(
+ homeScaffoldState = homeScaffoldState,
+ navigateToFiles = { folderId, folderName ->
+ navController.navigate(
+ Screen.Computers(
+ userId = UserId(userId),
+ folderId = folderId,
+ folderName = folderName,
+ syncedFolders = false
+ )
+ )
+ },
+ navigateToSortingDialog = navigateToSorting,
+ navigateBack = {
+ navController.popBackStack(
+ route = Screen.Computers.route,
+ inclusive = true,
+ )
+ },
+ )
+ } else {
+ FilesScreen(
+ homeScaffoldState = homeScaffoldState,
+ navigateToFiles = { folderId, folderName ->
+ navController.navigate(
+ Screen.Computers(
+ UserId(userId),
+ folderId,
+ folderName,
+ false
+ )
+ )
+ },
+ navigateToPreview = navigateToPreview,
+ navigateToSortingDialog = navigateToSorting,
+ navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
+ navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions,
+ navigateToParentFolderOptions = navigateToParentFolderOptions,
+ navigateBack = { navController.popBackStack() },
+ )
+ }
+ } else {
+ ComputersScreen(
homeScaffoldState = homeScaffoldState,
- navigateToFiles = { folderId, folderName ->
- navController.navigate(Screen.Computers(userId, folderId, folderName, false))
- },
- navigateToSortingDialog = navigateToSorting,
- navigateBack = {
- navController.popBackStack(
- route = Screen.Computers.route,
- inclusive = true,
+ navigateToSyncedFolders = { folderId, folderName ->
+ navController.navigate(
+ Screen.Computers(
+ UserId(userId),
+ folderId,
+ folderName,
+ true
+ )
)
},
- )
- } else {
- FilesScreen(
- homeScaffoldState = homeScaffoldState,
- navigateToFiles = { folderId, folderName ->
- navController.navigate(Screen.Computers(userId, folderId, folderName, false))
- },
- navigateToPreview = navigateToPreview,
- navigateToSortingDialog = navigateToSorting,
- navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
- navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions,
- navigateToParentFolderOptions = navigateToParentFolderOptions,
- navigateBack = { navController.popBackStack() },
+ navigateToComputerOptions = navigateToComputerOptions,
)
}
- } else {
- ComputersScreen(
- homeScaffoldState = homeScaffoldState,
- navigateToSyncedFolders = { folderId, folderName ->
- navController.navigate(Screen.Computers(userId, folderId, folderName, true))
- },
- )
+ } ?: let {
+ val userId = UserId(requireNotNull(arguments.getString(Screen.Files.USER_ID)))
+ val folderId = arguments.getString(Screen.Files.SHARE_ID)?.let { shareId ->
+ arguments.getString(Screen.Files.FOLDER_ID)?.let { folderId ->
+ FolderId(ShareId(userId, shareId), folderId)
+ }
+ }
+ val folderName = arguments.getString(Screen.Files.FOLDER_NAME)
+ navController.navigate(Screen.Computers(userId, folderId, folderName, false)) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = true
+ }
+ }
}
}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt
index f64e05fd..1e202dd2 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/navigation/Screen.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -27,6 +27,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.proton.android.drive.ui.options.OptionsFilter
+import me.proton.android.drive.ui.viewmodel.ComputerOptionsViewModel
import me.proton.android.drive.ui.viewmodel.FileOrFolderOptionsViewModel
import me.proton.android.drive.ui.viewmodel.MoveToFolderViewModel
import me.proton.android.drive.ui.viewmodel.MultipleFileOrFolderOptionsViewModel
@@ -34,7 +35,9 @@ import me.proton.android.drive.ui.viewmodel.ParentFolderOptionsViewModel
import me.proton.android.drive.ui.viewmodel.UploadToViewModel
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
-import me.proton.core.drive.drivelink.rename.presentation.RenameViewModel
+import me.proton.core.drive.device.domain.entity.DeviceId
+import me.proton.core.drive.drivelink.device.presentation.viewmodel.RenameDeviceViewModel
+import me.proton.core.drive.drivelink.rename.presentation.viewmodel.RenameViewModel
import me.proton.core.drive.drivelink.shared.presentation.viewmodel.SharedDriveLinkViewModel
import me.proton.core.drive.folder.create.presentation.CreateFolderViewModel
import me.proton.core.drive.link.domain.entity.FileId
@@ -48,7 +51,9 @@ import me.proton.core.drive.sorting.domain.entity.Direction
sealed class Screen(val route: String) {
open fun deepLink(baseUrl: String): String? = "$baseUrl/$route"
- data object Launcher : Screen("launcher")
+ data object Launcher : Screen("launcher?redirection={redirection}") {
+ const val REDIRECTION = "redirection"
+ }
data object SigningOut : Screen("signingOut/{userId}") {
operator fun invoke(userId: UserId) = "signingOut/${userId.id}"
@@ -56,10 +61,20 @@ sealed class Screen(val route: String) {
const val USER_ID = Screen.USER_ID
}
- data object Home : Screen("home/{userId}") {
- operator fun invoke(userId: UserId) = "home/${userId.id}"
+ data object Home : Screen("home/{userId}?tab={tab}") {
+ operator fun invoke(userId: UserId, tab: String? = null) = buildString {
+ append("home/${userId.id}")
+ if (tab != null) {
+ append("?tab=$tab")
+ }
+ }
const val USER_ID = Screen.USER_ID
+ const val TAB = "tab"
+ const val TAB_FILES = "files"
+ const val TAB_PHOTOS = "photos"
+ const val TAB_COMPUTERS = "computers"
+ const val TAB_SHARED = "shared"
}
data object Sorting :
@@ -123,6 +138,18 @@ sealed class Screen(val route: String) {
const val SHARE_ID = ParentFolderOptionsViewModel.KEY_SHARE_ID
}
+ data object ComputerOptions : Screen(
+ "options/computer/{userId}/devices/{deviceId}"
+ ) {
+
+ operator fun invoke(
+ userId: UserId,
+ deviceId: DeviceId,
+ ) = "options/computer/${userId.id}/devices/${deviceId.id}"
+
+ const val DEVICE_ID = ComputerOptionsViewModel.KEY_DEVICE_ID
+ }
+
data object Info : Screen("info/{userId}/shares/{shareId}/files?linkId={linkId}") {
operator fun invoke(
userId: UserId,
@@ -252,6 +279,10 @@ sealed class Screen(val route: String) {
operator fun invoke(userId: UserId, shareId: ShareId?) = "home/${userId.id}/photos/${shareId?.id}"
+ object Upsell : Screen("home/{userId}/photos/upsell"){
+ operator fun invoke(userId: UserId) = "home/${userId.id}/photos/upsell"
+ }
+
const val USER_ID = Screen.USER_ID
const val SHARE_ID = "shareId"
}
@@ -291,8 +322,9 @@ sealed class Screen(val route: String) {
override fun invoke(userId: UserId) =
filesBrowsableBuildRoute("computers", userId, null, null)
- operator fun invoke(userId: UserId, folderId: FolderId, folderName: String?, syncedFolders: Boolean) =
- filesBrowsableBuildRoute("computers", userId, folderId, folderName) + "&syncedFolders=${syncedFolders}"
+ operator fun invoke(userId: UserId, folderId: FolderId?, folderName: String?, syncedFolders: Boolean) =
+ filesBrowsableBuildRoute("computers", userId, folderId, folderName) +
+ folderId?.let{"&syncedFolders=${syncedFolders}"}.orEmpty()
const val USER_ID = Screen.USER_ID
const val SYNCED_FOLDERS = "syncedFolders"
@@ -421,6 +453,28 @@ sealed class Screen(val route: String) {
const val USER_ID = Screen.USER_ID
}
+
+ data object RenameComputer : Screen(
+ "rename/{userId}/shares/{shareId}/files?fileId={fileId}&folderId={folderId}&deviceId={deviceId}"
+ ) {
+
+ operator fun invoke(
+ userId: UserId,
+ deviceId: DeviceId,
+ folderId: FolderId,
+ ) = "rename/${userId.id}/shares/${folderId.shareId.id}/files?folderId=${folderId.id}&deviceId=${deviceId.id}"
+
+ const val FOLDER_ID = RenameViewModel.KEY_FOLDER_ID
+ const val SHARE_ID = RenameViewModel.KEY_SHARE_ID
+ const val DEVICE_ID = RenameDeviceViewModel.KEY_DEVICE_ID
+ }
+ }
+
+ data object GetMoreFreeStorage : Screen("storage/{userId}/getMoreFree") {
+
+ operator fun invoke(userId: UserId) = "storage/${userId.id}/getMoreFree"
+
+ const val USER_ID = Screen.USER_ID
}
companion object {
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/ComputersScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/ComputersScreen.kt
index 608b31b6..46046768 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/ComputersScreen.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/ComputersScreen.kt
@@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.hilt.navigation.compose.hiltViewModel
import me.proton.android.drive.ui.effect.HandleHomeEffect
@@ -40,6 +41,7 @@ import me.proton.core.compose.flow.rememberFlowWithLifecycle
import me.proton.core.drive.base.presentation.component.ProtonPullToRefresh
import me.proton.core.drive.base.presentation.extension.conditional
import me.proton.core.drive.device.domain.entity.Device
+import me.proton.core.drive.device.domain.entity.DeviceId
import me.proton.core.drive.drivelink.device.presentation.component.DevicesContent
import me.proton.core.drive.drivelink.device.presentation.component.DevicesEmpty
import me.proton.core.drive.drivelink.device.presentation.component.DevicesError
@@ -56,13 +58,17 @@ import me.proton.core.drive.base.presentation.component.TopAppBar as BaseTopAppB
fun ComputersScreen(
homeScaffoldState: HomeScaffoldState,
navigateToSyncedFolders: (FolderId, String?) -> Unit,
+ navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel()
val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState)
.collectAsState(initial = viewModel.initialViewState)
val viewEvent = remember {
- viewModel.viewEvent(navigateToSyncedFolders)
+ viewModel.viewEvent(
+ navigateToSyncedFolders,
+ navigateToComputerOptions,
+ )
}
val devices by rememberFlowWithLifecycle(viewModel.devices).collectAsState(initial = null)
viewModel.HandleHomeEffect(homeScaffoldState)
@@ -101,9 +107,13 @@ fun Computers(
devices = devices,
modifier = modifier.fillMaxSize(),
onError = {},
- ) { device ->
- viewEvent.onDevice(device)
- }
+ onDevice = { device ->
+ viewEvent.onDevice(device)
+ },
+ onMoreOptions = { device ->
+ viewEvent.onMoreOptions(device)
+ },
+ )
}
}
@@ -114,6 +124,7 @@ fun Computers(
modifier: Modifier = Modifier,
onError: () -> Unit,
onDevice: (Device) -> Unit,
+ onMoreOptions: (Device) -> Unit,
) {
val scrollState = rememberScrollState()
Box(
@@ -146,6 +157,9 @@ fun Computers(
DevicesContent(
devices = devices,
onClick = onDevice,
+ onMoreOptions = onMoreOptions,
+ modifier = Modifier
+ .testTag(ComputersTestTag.content)
)
}
}
@@ -160,7 +174,12 @@ private fun TopAppBar(
navigationIcon = if (viewState.navigationIconResId != 0) {
painterResource(id = viewState.navigationIconResId)
} else null,
+ notificationDotVisible = viewState.notificationDotVisible,
onNavigationIcon = viewEvent.onTopAppBarNavigation,
title = viewState.title,
)
}
+
+object ComputersTestTag {
+ const val content = "computers content"
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/GetMoreFreeStorageScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/GetMoreFreeStorageScreen.kt
new file mode 100644
index 00000000..ce952fcc
--- /dev/null
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/GetMoreFreeStorageScreen.kt
@@ -0,0 +1,342 @@
+/*
+ * Copyright (c) 2024 Proton AG.
+ * This file is part of Proton Drive.
+ *
+ * Proton Drive is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Proton Drive is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Proton Drive. If not, see .
+ */
+
+package me.proton.android.drive.ui.screen
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import me.proton.android.drive.ui.viewmodel.GetMoreFreeStorageViewModel
+import me.proton.android.drive.ui.viewstate.GetMoreFreeStorageViewState
+import me.proton.core.compose.flow.rememberFlowWithLifecycle
+import me.proton.core.compose.theme.ProtonDimens
+import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing
+import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing
+import me.proton.core.compose.theme.ProtonDimens.MediumSpacing
+import me.proton.core.compose.theme.ProtonDimens.SmallSpacing
+import me.proton.core.compose.theme.ProtonTheme
+import me.proton.core.compose.theme.defaultHighlightNorm
+import me.proton.core.compose.theme.defaultSmallNorm
+import me.proton.core.compose.theme.defaultWeak
+import me.proton.core.compose.theme.headlineNorm
+import me.proton.core.drive.base.domain.extension.GiB
+import me.proton.core.drive.base.presentation.component.TopAppBar
+import me.proton.core.drive.base.presentation.extension.asHumanReadableString
+import me.proton.core.drive.base.presentation.extension.isLandscape
+import me.proton.core.drive.base.presentation.R as BasePresentation
+import me.proton.core.presentation.R as CorePresentation
+import me.proton.core.drive.i18n.R as I18N
+
+@Composable
+fun GetMoreFreeStorageScreen(
+ modifier: Modifier = Modifier,
+ navigateBack: () -> Unit,
+) {
+ val viewModel = hiltViewModel()
+ val viewState by rememberFlowWithLifecycle(viewModel.viewState).collectAsState(
+ initial = viewModel.initialViewState
+ )
+ GetMoreFreeStorage(
+ viewState = viewState,
+ navigateBack = navigateBack,
+ modifier = modifier.fillMaxSize()
+ )
+}
+
+@Composable
+fun GetMoreFreeStorage(
+ viewState: GetMoreFreeStorageViewState,
+ modifier: Modifier = Modifier,
+ navigateBack: () -> Unit,
+) {
+ Column(
+ modifier = modifier
+ .navigationBarsPadding()
+ .fillMaxSize()
+ .testTag(GetMoreFreeStorage.screen),
+ verticalArrangement = Arrangement.Top,
+ ) {
+ TopAppBar(navigateBack = navigateBack)
+ Content(
+ viewState = viewState,
+ modifier = Modifier
+ .fillMaxSize(),
+ )
+ }
+}
+
+@Composable
+private fun TopAppBar(
+ modifier: Modifier = Modifier,
+ navigateBack: () -> Unit,
+) {
+ TopAppBar(
+ navigationIcon = painterResource(id = CorePresentation.drawable.ic_proton_close),
+ onNavigationIcon = navigateBack,
+ title = {},
+ modifier = modifier.statusBarsPadding(),
+ )
+}
+
+@Composable
+private fun Content(
+ viewState: GetMoreFreeStorageViewState,
+ modifier: Modifier = Modifier,
+) {
+ if (isLandscape) {
+ Row(
+ modifier = modifier
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize()
+ .padding(DefaultSpacing),
+ ) {
+ TitlePart(
+ viewState = viewState,
+ modifier = Modifier
+ .padding(horizontal = SmallSpacing)
+ .weight(1f),
+ )
+ ActionsPart(
+ actions = viewState.actions,
+ modifier = Modifier
+ .padding(horizontal = SmallSpacing)
+ .weight(1f),
+ )
+ }
+ } else {
+ Column(
+ modifier = modifier
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize()
+ .padding(DefaultSpacing),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ TitlePart(
+ viewState = viewState,
+ )
+ ActionsPart(
+ actions = viewState.actions,
+ )
+ }
+ }
+}
+
+@Composable
+private fun TitlePart(
+ viewState: GetMoreFreeStorageViewState,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(painter = painterResource(id = viewState.imageResId), contentDescription = null)
+ Text(
+ text = viewState.title,
+ style = ProtonTheme.typography.headlineNorm,
+ modifier = Modifier.padding(top = MediumSpacing)
+ )
+ Text(
+ text = stringResource(id = viewState.descriptionResId),
+ style = ProtonTheme.typography.defaultWeak,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = SmallSpacing, bottom = DefaultSpacing)
+ )
+ }
+}
+
+@Composable
+private fun ActionsPart(
+ actions: List,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ actions.forEach { action ->
+ ListItem(action = action)
+ }
+ }
+}
+
+@Composable
+private fun ListItem(
+ action: GetMoreFreeStorageViewState.Action,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(top = SmallSpacing),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ActionIcon(
+ iconResId = action.iconResId,
+ iconTintColor = if (action.isDone) ProtonTheme.colors.notificationSuccess else ProtonTheme.colors.iconAccent,
+ bgColor = if (action.isDone) ProtonTheme.colors.notificationSuccess.copy(alpha = 0.06F) else ProtonTheme.colors.backgroundSecondary
+ )
+ ActionDetails(
+ titleResId = action.titleResId,
+ titleTextStyle = ProtonTheme.typography.defaultHighlightNorm.copy(
+ color = if (action.isDone) ProtonTheme.colors.textWeak else ProtonTheme.colors.textNorm
+ ),
+ subtitle = action.getDescription(),
+ subtitleTextStyle = ProtonTheme.typography.defaultSmallNorm,
+ onSubtitleClick = action.onSubtitleClick,
+ )
+ }
+}
+
+@Composable
+private fun ActionIcon(
+ iconResId: Int,
+ iconTintColor: Color,
+ bgColor: Color,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .sizeIn(minHeight = 40.dp, minWidth = 40.dp)
+ .background(
+ color = bgColor,
+ shape = RoundedCornerShape(ProtonDimens.ExtraLargeCornerRadius),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(id = iconResId),
+ tint = iconTintColor,
+ contentDescription = null,
+ )
+ }
+}
+
+@Composable
+private fun ActionDetails(
+ titleResId: Int,
+ titleTextStyle: TextStyle,
+ subtitle: AnnotatedString,
+ subtitleTextStyle: TextStyle,
+ modifier: Modifier = Modifier,
+ onSubtitleClick: (Int) -> Unit,
+) {
+ Column(
+ modifier = modifier
+ .sizeIn(minHeight = 64.dp)
+ .padding(start = DefaultSpacing),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.Start,
+ ) {
+ Text(
+ text = stringResource(id = titleResId),
+ style = titleTextStyle,
+ )
+ ClickableText(
+ text = subtitle,
+ style = subtitleTextStyle,
+ onClick = onSubtitleClick,
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewGetMoreFreeStorage() {
+ ProtonTheme {
+ GetMoreFreeStorage(
+ viewState = GetMoreFreeStorageViewState(
+ imageResId = BasePresentation.drawable.img_free_storage,
+ title = stringResource(
+ id = I18N.string.get_more_free_storage_title,
+ 5.GiB.asHumanReadableString(LocalContext.current, numberOfDecimals = 0),
+ ),
+ descriptionResId = I18N.string.get_more_free_storage_description,
+ actions = listOf(
+ GetMoreFreeStorageViewState.Action(
+ iconResId = CorePresentation.drawable.ic_proton_arrow_up_line,
+ titleResId = I18N.string.get_more_free_storage_action_upload_title,
+ getDescription = {
+ AnnotatedString(
+ stringResource(id = I18N.string.get_more_free_storage_action_upload_subtitle)
+ )
+ },
+ isDone = false,
+ ),
+ GetMoreFreeStorageViewState.Action(
+ iconResId = CorePresentation.drawable.ic_proton_checkmark,
+ titleResId = I18N.string.get_more_free_storage_action_link_title,
+ getDescription = {
+ AnnotatedString(
+ "Select any file of folder. Open the options menu and press Get link."
+ )
+ },
+ isDone = true,
+ ),
+ GetMoreFreeStorageViewState.Action(
+ iconResId = CorePresentation.drawable.ic_proton_key,
+ titleResId = I18N.string.get_more_free_storage_action_recovery_title,
+ getDescription = {
+ AnnotatedString(
+ text = "Sign in at account.proton.me, then go to Settings -> Recovery."
+ )
+ },
+ isDone = false,
+ ),
+ ),
+ ),
+ navigateBack = {},
+ )
+ }
+}
+
+object GetMoreFreeStorage {
+ const val screen = "GetMoreFreeStorage screen"
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/HomeScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/HomeScreen.kt
index 17351af1..8c1eba0c 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/HomeScreen.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/HomeScreen.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -64,6 +64,7 @@ import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.presentation.component.BottomNavigation
import me.proton.core.drive.base.presentation.component.ModalBottomSheet
+import me.proton.core.drive.device.domain.entity.DeviceId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
@@ -93,8 +94,11 @@ fun HomeScreen(
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
+ navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
navigateToPhotosPermissionRationale: () -> Unit,
+ navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
+ navigateToGetMoreFreeStorage: () -> Unit,
modifier: Modifier = Modifier,
) {
setLocalSnackbarPadding(BottomNavigationHeight)
@@ -125,7 +129,9 @@ fun HomeScreen(
navigateToPhotosPermissionRationale = navigateToPhotosPermissionRationale,
navigateToSubscription = navigateToSubscription,
navigateToPhotosIssues = navigateToPhotosIssues,
+ navigateToPhotosUpsell = navigateToPhotosUpsell,
navigateToBackupSettings = navigateToBackupSettings,
+ navigateToComputerOptions = navigateToComputerOptions,
arguments = arguments,
viewState = currentViewState,
viewEvent = homeViewModel.viewEvent(
@@ -133,7 +139,7 @@ fun HomeScreen(
navigateToTrash = navigateToTrash,
navigateToTab = { route ->
homeNavController.navigate(route) {
- popUpTo(Screen.Files.route) { inclusive = route == Screen.Files(userId) }
+ popUpTo(Screen.Photos.route) { inclusive = route == Screen.Photos(userId) }
launchSingleTop = true
}
},
@@ -141,6 +147,7 @@ fun HomeScreen(
navigateToSettings = navigateToSettings,
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
+ navigateToGetMoreFreeStorage = navigateToGetMoreFreeStorage,
),
modifier = modifier
.navigationBarsPadding()
@@ -170,7 +177,9 @@ internal fun Home(
navigateToPhotosPermissionRationale: () -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
+ navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
+ navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
) {
val homeScaffoldState = rememberHomeScaffoldState()
val isDrawerOpen = with(homeScaffoldState.scaffoldState.drawerState) {
@@ -262,7 +271,9 @@ internal fun Home(
navigateToPhotosPermissionRationale,
navigateToSubscription,
navigateToPhotosIssues,
+ navigateToPhotosUpsell,
navigateToBackupSettings,
+ navigateToComputerOptions,
)
}
}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/LauncherScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/LauncherScreen.kt
index 668c8140..e47a2706 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/LauncherScreen.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/LauncherScreen.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -22,8 +22,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -37,38 +39,52 @@ import me.proton.core.domain.entity.UserId
@Composable
@ExperimentalCoroutinesApi
fun LauncherScreen(
+ foregroundState: State,
navigateToHomeScreen: (userId: UserId) -> Unit,
modifier: Modifier = Modifier,
) {
val launcherViewModel = hiltViewModel()
Box(
- modifier = modifier.fillMaxSize()
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
) {
- Launcher(launcherViewModel, navigateToHomeScreen)
+ Launcher(
+ foregroundState = foregroundState,
+ viewModel = launcherViewModel,
+ navigateToHomeScreen = navigateToHomeScreen,
+ )
}
}
@Composable
@ExperimentalCoroutinesApi
internal fun Launcher(
+ foregroundState: State,
viewModel: LauncherViewModel,
navigateToHomeScreen: (userId: UserId) -> Unit,
modifier: Modifier = Modifier,
) {
val viewState by rememberFlowWithLifecycle(viewModel.viewState)
.collectAsState(initial = LauncherViewState.initialValue)
- Launcher(viewState, navigateToHomeScreen, modifier)
+ val foreground by foregroundState
+ Launcher(
+ foreground = foreground,
+ viewState = viewState,
+ navigateToHomeScreen = navigateToHomeScreen,
+ modifier = modifier,
+ )
}
@Composable
internal fun Launcher(
+ foreground: Boolean,
viewState: LauncherViewState,
navigateToHomeScreen: (userId: UserId) -> Unit,
modifier: Modifier = Modifier,
) {
val state = viewState.primaryAccountState
- LaunchedEffect(state) {
- if (state is PrimaryAccountState.SignedIn) {
+ LaunchedEffect(state, foreground) {
+ if (state is PrimaryAccountState.SignedIn && foreground) {
navigateToHomeScreen(state.userId)
}
}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt
index e50e19fb..240458db 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/MoveToFolder.kt
@@ -146,7 +146,9 @@ fun MoveToFolder(
}
Button(
- modifier = Modifier.padding(start = SmallSpacing),
+ modifier = Modifier
+ .padding(start = SmallSpacing)
+ .testTag(MoveToFolderScreenTestTag.moveButton),
enabled = viewState.isMoveButtonEnabled,
onClick = viewEvent.move,
) {
@@ -193,4 +195,5 @@ fun Title(
object MoveToFolderScreenTestTag {
const val screen = "move to folder screen"
const val plusFolderButton = "move to folder plus folder button"
+ const val moveButton = "move button"
}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosScreen.kt
index 1ed7f37d..f3139954 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosScreen.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosScreen.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -31,10 +31,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.PagingData
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import me.proton.android.drive.photos.presentation.component.BackupPermissions
import me.proton.android.drive.photos.presentation.component.Photos
import me.proton.android.drive.photos.presentation.component.PhotosStatesIndicator
@@ -42,6 +47,7 @@ import me.proton.android.drive.photos.presentation.state.PhotosItem
import me.proton.android.drive.photos.presentation.viewevent.PhotosViewEvent
import me.proton.android.drive.photos.presentation.viewstate.PhotosViewState
import me.proton.android.drive.ui.effect.HandleHomeEffect
+import me.proton.android.drive.ui.effect.PhotosEffect
import me.proton.android.drive.ui.viewmodel.PhotosViewModel
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
import me.proton.core.compose.flow.rememberFlowWithLifecycle
@@ -64,6 +70,7 @@ fun PhotosScreen(
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
+ navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
) {
val viewModel = hiltViewModel()
@@ -76,12 +83,23 @@ fun PhotosScreen(
navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions,
navigateToSubscription = navigateToSubscription,
navigateToPhotosIssues = navigateToPhotosIssues,
+ navigateToPhotosUpsell = navigateToPhotosUpsell,
navigateToBackupSettings = navigateToBackupSettings,
)
}
val photos = rememberFlowWithLifecycle(flow = viewModel.driveLinks)
val listEffect = rememberFlowWithLifecycle(flow = viewModel.listEffect)
+ LaunchedEffect(viewModel, LocalContext.current) {
+ viewModel.photosEffect.onEach { effect ->
+ when (effect) {
+ PhotosEffect.ShowUpsell -> launch(Dispatchers.Main) {
+ viewEvent.onShowUpsell()
+ }
+ }
+ }.launchIn(this)
+ }
+
viewModel.HandleHomeEffect(homeScaffoldState)
PhotosScreen(
@@ -161,6 +179,7 @@ fun TopAppBar(
navigationIcon = if (viewState.navigationIconResId != 0) {
painterResource(id = viewState.navigationIconResId)
} else null,
+ notificationDotVisible = viewState.notificationDotVisible,
onNavigationIcon = viewEvent.onTopAppBarNavigation,
title = viewState.title,
actions = actions
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosUpsellScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosUpsellScreen.kt
new file mode 100644
index 00000000..d450c933
--- /dev/null
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/PhotosUpsellScreen.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright (c) 2023-2024 Proton AG.
+ * This file is part of Proton Drive.
+ *
+ * Proton Drive is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Proton Drive is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Proton Drive. If not, see .
+ */
+
+package me.proton.android.drive.ui.screen
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import me.proton.android.drive.photos.presentation.viewevent.PhotosUpsellViewEvent
+import me.proton.android.drive.ui.viewmodel.PhotosUpsellViewModel
+import me.proton.core.compose.component.ProtonSolidButton
+import me.proton.core.compose.component.ProtonTextButton
+import me.proton.core.compose.theme.ProtonDimens
+import me.proton.core.compose.theme.ProtonTheme
+import me.proton.core.drive.base.presentation.common.getThemeDrawableId
+import me.proton.core.drive.base.presentation.component.IllustratedMessage
+import me.proton.core.drive.base.presentation.component.RunAction
+import me.proton.core.drive.base.presentation.extension.conditional
+import me.proton.core.drive.base.presentation.extension.isLandscape
+import me.proton.core.drive.base.presentation.extension.isPortrait
+import me.proton.core.drive.base.presentation.R as BasePresentation
+import me.proton.core.drive.i18n.R as I18N
+
+@Composable
+fun PhotosUpsellScreen(
+ modifier: Modifier = Modifier,
+ runAction: RunAction,
+ navigateToSubscription: () -> Unit,
+) {
+ val viewModel = hiltViewModel()
+ val viewEvent = remember {
+ viewModel.viewEvent(
+ runAction = runAction,
+ navigateToSubscription = navigateToSubscription,
+ )
+ }
+ DisposableEffect(Unit){
+ onDispose {
+ viewEvent.onDismiss()
+ }
+ }
+ PhotosUpsell(
+ viewEvent = viewEvent,
+ modifier = modifier
+ .systemBarsPadding(),
+ )
+}
+
+@Composable
+fun PhotosUpsell(
+ viewEvent: PhotosUpsellViewEvent,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.padding(horizontal = ProtonDimens.DefaultSpacing),
+ verticalArrangement = Arrangement.spacedBy(ProtonDimens.DefaultSpacing),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ IllustratedMessage(
+ imageResId = getThemeDrawableId(
+ light = BasePresentation.drawable.img_upsell_drive_light,
+ dark = BasePresentation.drawable.img_upsell_drive_dark,
+ dayNight = BasePresentation.drawable.img_upsell_drive_daynight,
+ ),
+ titleResId = I18N.string.photos_upsell_title,
+ descriptionResId = I18N.string.photos_upsell_description,
+ )
+ val buttonModifier = Modifier
+ .conditional(isPortrait) {
+ fillMaxWidth()
+ }
+ .conditional(isLandscape) {
+ widthIn(min = ButtonMinWidth)
+ }
+ .heightIn(min = ProtonDimens.ListItemHeight)
+ ProtonSolidButton(
+ onClick = { viewEvent.onMoreStorage() },
+ modifier = buttonModifier,
+ ) {
+ Text(
+ text = stringResource(id = I18N.string.photos_upsell_get_storage_action),
+ modifier = Modifier.padding(horizontal = ProtonDimens.DefaultSpacing)
+ )
+ }
+ ProtonTextButton(
+ onClick = { viewEvent.onCancel() },
+ modifier = buttonModifier
+ ) {
+ Text(
+ text = stringResource(id = I18N.string.photos_upsell_dismiss_action),
+ modifier = Modifier.padding(horizontal = ProtonDimens.DefaultSpacing)
+ )
+ }
+ }
+}
+
+private val ButtonMinWidth = 300.dp
+
+@Preview
+@Preview(widthDp = 600, heightDp = 360)
+@Composable
+private fun PhotosUpsellPreview() {
+ ProtonTheme {
+ PhotosUpsell(
+ viewEvent = object : PhotosUpsellViewEvent {},
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/screen/SyncedFoldersScreen.kt b/app/src/main/kotlin/me/proton/android/drive/ui/screen/SyncedFoldersScreen.kt
index fb9bfff4..166d32ff 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/screen/SyncedFoldersScreen.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/screen/SyncedFoldersScreen.kt
@@ -26,6 +26,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.hilt.navigation.compose.hiltViewModel
import me.proton.android.drive.ui.effect.HandleHomeEffect
@@ -62,7 +63,9 @@ fun SyncedFoldersScreen(
driveLinks = PagingList(viewModel.driveLinks, viewModel.listEffect),
viewState = viewState,
viewEvent = viewEvent,
- modifier = modifier.fillMaxSize(),
+ modifier = modifier
+ .fillMaxSize()
+ .testTag(SyncedFoldersTestTag.screen),
onRefresh = viewModel::refresh,
)
}
@@ -114,3 +117,7 @@ private fun TopAppBar(
isTitleEncrypted = viewState.isTitleEncrypted,
)
}
+
+object SyncedFoldersTestTag {
+ const val screen = "computer synced folders screen"
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/ComputersViewEvent.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/ComputersViewEvent.kt
index 28f928ee..c270ce3e 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/ComputersViewEvent.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewevent/ComputersViewEvent.kt
@@ -24,4 +24,5 @@ interface ComputersViewEvent {
val onTopAppBarNavigation: () -> Unit get() = {}
val onDevice: (Device) -> Unit get() = { _ -> }
val onRefresh: () -> Unit get() = {}
+ val onMoreOptions: (Device) -> Unit get() = { _ -> }
}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputerOptionsViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputerOptionsViewModel.kt
new file mode 100644
index 00000000..7f60d044
--- /dev/null
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputerOptionsViewModel.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2024 Proton AG.
+ * This file is part of Proton Drive.
+ *
+ * Proton Drive is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Proton Drive is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Proton Drive. If not, see .
+ */
+
+package me.proton.android.drive.ui.viewmodel
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.stateIn
+import me.proton.core.domain.arch.mapSuccessValueOrNull
+import me.proton.core.drive.base.presentation.extension.require
+import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
+import me.proton.core.drive.device.domain.entity.Device
+import me.proton.core.drive.device.domain.entity.DeviceId
+import me.proton.core.drive.drivelink.device.domain.usecase.GetDecryptedDevice
+import me.proton.core.drive.drivelink.device.presentation.options.DeviceOptionEntry
+import me.proton.core.drive.drivelink.device.presentation.options.RenameDeviceOption
+import me.proton.core.drive.link.domain.entity.FolderId
+import javax.inject.Inject
+
+@HiltViewModel
+class ComputerOptionsViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ getDecryptedDevice: GetDecryptedDevice,
+) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
+ private val deviceId = DeviceId(savedStateHandle.require(KEY_DEVICE_ID))
+ val device: Flow = getDecryptedDevice(
+ userId = userId,
+ deviceId = deviceId,
+ )
+ .mapSuccessValueOrNull()
+ .stateIn(viewModelScope, SharingStarted.Eagerly, null)
+
+ fun entries(
+ runAction: (suspend () -> Unit) -> Unit,
+ navigateToRenameComputer: (DeviceId, FolderId) -> Unit,
+ ): List = listOf(
+ RenameDeviceOption { device ->
+ runAction { navigateToRenameComputer(device.id, device.rootLinkId) }
+ }
+ )
+
+ companion object {
+ const val KEY_DEVICE_ID = "deviceId"
+ }
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModel.kt
index e2c35c96..e9ec41de 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModel.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/ComputersViewModel.kt
@@ -45,12 +45,15 @@ import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.presentation.common.getThemeDrawableId
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.device.domain.entity.Device
+import me.proton.core.drive.device.domain.entity.DeviceId
+import me.proton.core.drive.device.domain.extension.name
import me.proton.core.drive.device.domain.usecase.RefreshDevices
import me.proton.core.drive.drivelink.device.domain.usecase.GetDecryptedDevicesSortedByName
import me.proton.core.drive.files.presentation.state.ListContentState
import me.proton.core.drive.i18n.R
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
+import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage
import javax.inject.Inject
import me.proton.core.drive.drivelink.device.presentation.R as DriveLinkDevicePresentation
import me.proton.core.drive.i18n.R as I18N
@@ -63,7 +66,11 @@ class ComputersViewModel @Inject constructor(
private val refreshDevices: RefreshDevices,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
-) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle), HomeTabViewModel {
+ shouldUpgradeStorage: ShouldUpgradeStorage,
+) : ViewModel(),
+ UserViewModel by UserViewModel(savedStateHandle),
+ HomeTabViewModel,
+ NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) {
private val _homeEffect = MutableSharedFlow()
override val homeEffect: Flow
@@ -85,6 +92,7 @@ class ComputersViewModel @Inject constructor(
val initialViewState = ComputersViewState(
title = appContext.getString(R.string.computers_title),
navigationIconResId = me.proton.core.presentation.R.drawable.ic_proton_hamburger,
+ notificationDotVisible = false,
listContentState = listContentState.value,
isRefreshEnabled = listContentState.value != ListContentState.Loading
)
@@ -92,8 +100,10 @@ class ComputersViewModel @Inject constructor(
val viewState: Flow = combine(
listContentState,
isRefreshing,
- ) { state, refreshing ->
+ notificationDotRequested
+ ) { state, refreshing, notificationDotRequested ->
initialViewState.copy(
+ notificationDotVisible = notificationDotRequested,
listContentState = when (state) {
is ListContentState.Content -> state.copy(isRefreshing = refreshing)
is ListContentState.Empty -> state.copy(isRefreshing = refreshing)
@@ -134,6 +144,7 @@ class ComputersViewModel @Inject constructor(
fun viewEvent(
navigateToSyncedFolders: (FolderId, String?) -> Unit,
+ navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
): ComputersViewEvent = object : ComputersViewEvent {
override val onTopAppBarNavigation = {
@@ -146,6 +157,10 @@ class ComputersViewModel @Inject constructor(
navigateToSyncedFolders(device.rootLinkId, name)
}
+ override val onMoreOptions = { device: Device ->
+ navigateToComputerOptions(device.id)
+ }
+
override val onRefresh = {
viewModelScope.launch {
isRefreshing.value = true
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt
index 40f8f235..d96077f8 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/FilesViewModel.kt
@@ -84,6 +84,7 @@ import me.proton.core.drive.sorting.domain.entity.Sorting
import me.proton.core.drive.sorting.domain.usecase.GetSorting
import me.proton.core.drive.upload.domain.usecase.CancelUploadFile
import me.proton.core.drive.upload.domain.usecase.GetUploadProgress
+import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage
import me.proton.core.util.kotlin.CoreLogger
import me.proton.drive.android.settings.domain.entity.LayoutType
import me.proton.drive.android.settings.domain.usecase.GetLayoutType
@@ -115,7 +116,10 @@ class FilesViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getSorting: GetSorting,
private val configurationProvider: ConfigurationProvider,
-) : SelectionViewModel(savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks), HomeTabViewModel {
+ shouldUpgradeStorage: ShouldUpgradeStorage,
+) : SelectionViewModel(savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks),
+ HomeTabViewModel,
+ NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) {
private val shareId = savedStateHandle.get(Screen.Files.SHARE_ID)
private val folderId = savedStateHandle.get(Screen.Files.FOLDER_ID)?.let { folderId ->
@@ -196,7 +200,8 @@ class FilesViewModel @Inject constructor(
listContentAppendingState,
layoutType,
selected,
- ) { driveLink, sorting, contentState, appendingState, layoutType, selected ->
+ notificationDotRequested
+ ) { driveLink, sorting, contentState, appendingState, layoutType, selected, notificationDotRequested ->
val listContentState = when (contentState) {
is ListContentState.Empty -> contentState.copy(
imageResId = emptyStateImageResId,
@@ -208,6 +213,7 @@ class FilesViewModel @Inject constructor(
} else {
topBarActions.value = setOf(selectAllAction, selectedOptionsAction)
}
+ val showHamburgerMenuIcon = isRootFolder && selected.isEmpty()
initialViewState.copy(
title = if (selected.isNotEmpty()) {
appContext.quantityString(
@@ -220,7 +226,7 @@ class FilesViewModel @Inject constructor(
}
},
isTitleEncrypted = selected.isEmpty() && isRootFolder.not() && driveLink.isNameEncrypted,
- navigationIconResId = if (isRootFolder && selected.isEmpty()) {
+ navigationIconResId = if (showHamburgerMenuIcon) {
CorePresentation.drawable.ic_proton_hamburger
} else if (selected.isNotEmpty()) {
CorePresentation.drawable.ic_proton_cross
@@ -233,6 +239,7 @@ class FilesViewModel @Inject constructor(
isGrid = layoutType == LayoutType.GRID,
isRefreshEnabled = selected.isEmpty(),
drawerGesturesEnabled = isRootFolder && selected.isEmpty(),
+ notificationDotVisible = showHamburgerMenuIcon && notificationDotRequested
)
}
val listEffect: Flow
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/GetMoreFreeStorageViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/GetMoreFreeStorageViewModel.kt
new file mode 100644
index 00000000..2b9abd0c
--- /dev/null
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/GetMoreFreeStorageViewModel.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright (c) 2024 Proton AG.
+ * This file is part of Proton Drive.
+ *
+ * Proton Drive is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Proton Drive is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Proton Drive. If not, see .
+ */
+
+package me.proton.android.drive.ui.viewmodel
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.transform
+import me.proton.android.drive.ui.viewstate.GetMoreFreeStorageViewState
+import me.proton.core.compose.theme.ProtonTheme
+import me.proton.core.domain.arch.DataResult
+import me.proton.core.drive.base.domain.provider.ConfigurationProvider
+import me.proton.core.drive.base.domain.usecase.BroadcastMessages
+import me.proton.core.drive.base.presentation.extension.asHumanReadableString
+import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
+import me.proton.core.drive.folder.domain.usecase.HasAnyCachedFolderChildren
+import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
+import me.proton.core.drive.share.domain.entity.Share
+import me.proton.core.drive.share.domain.usecase.GetShares
+import javax.inject.Inject
+import me.proton.core.drive.base.presentation.R as BasePresentation
+import me.proton.core.drive.i18n.R as I18N
+import me.proton.core.presentation.R as CorePresentation
+
+@HiltViewModel
+class GetMoreFreeStorageViewModel @Inject constructor(
+ @ApplicationContext private val appContext: Context,
+ configurationProvider: ConfigurationProvider,
+ getShares: GetShares,
+ private val hasAnyCachedFolderChildren: HasAnyCachedFolderChildren,
+ private val broadcastMessages: BroadcastMessages,
+ savedStateHandle: SavedStateHandle,
+) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
+ private val uploadAFile = GetMoreFreeStorageViewState.Action(
+ iconResId = uploadAFileIconResId(false),
+ titleResId = I18N.string.get_more_free_storage_action_upload_title,
+ getDescription = { AnnotatedString(appContext.getString(I18N.string.get_more_free_storage_action_upload_subtitle)) },
+ isDone = false,
+ )
+
+ private val createAShareLink = GetMoreFreeStorageViewState.Action(
+ iconResId = createAShareLinkIconResId(false),
+ titleResId = I18N.string.get_more_free_storage_action_link_title,
+ getDescription = { createAShareLinkDescription },
+ isDone = false,
+ )
+
+ private val setARecoveryMethod = GetMoreFreeStorageViewState.Action(
+ iconResId = setARecoveryMethodIconResId(false),
+ titleResId = I18N.string.get_more_free_storage_action_recovery_title,
+ getDescription = { setARecoveryMethodDescription(ProtonTheme.colors.textAccent) },
+ isDone = false,
+ onSubtitleClick = ::onSetRecoverMethodSubtitleClick
+ )
+
+ val initialViewState = GetMoreFreeStorageViewState(
+ imageResId = BasePresentation.drawable.img_free_storage,
+ title = appContext.getString(
+ I18N.string.get_more_free_storage_title,
+ configurationProvider.maxFreeSpace.asHumanReadableString(appContext, numberOfDecimals = 0),
+ ),
+ descriptionResId = I18N.string.get_more_free_storage_description,
+ actions = listOf(uploadAFile, createAShareLink, setARecoveryMethod),
+ )
+
+ val viewState: Flow = getShares(userId, Share.Type.STANDARD, flowOf(false))
+ .distinctUntilChanged()
+ .transform { result ->
+ when (result) {
+ is DataResult.Success -> emit(
+ initialViewState.copy(
+ actions = listOf(
+ hasAnyCachedFolderChildren(userId, filesOnly = true).let {
+ uploadAFile.copy(
+ iconResId = uploadAFileIconResId(false),
+ isDone = false,
+ )
+ },
+ result.value.isNotEmpty().let {
+ createAShareLink.copy(
+ iconResId = createAShareLinkIconResId(false),
+ isDone = false,
+ )
+ },
+ setARecoveryMethod,
+ )
+ )
+ )
+ else -> Unit
+ }
+
+ }
+
+ private val isDoneIconResId = CorePresentation.drawable.ic_proton_checkmark
+
+ @Suppress("SameParameterValue")
+ private fun uploadAFileIconResId(isDone: Boolean): Int =
+ if (isDone) isDoneIconResId else CorePresentation.drawable.ic_proton_arrow_up_line
+
+ @Suppress("SameParameterValue")
+ private fun createAShareLinkIconResId(isDone: Boolean): Int =
+ if (isDone) isDoneIconResId else CorePresentation.drawable.ic_proton_link
+
+ @Suppress("SameParameterValue")
+ private fun setARecoveryMethodIconResId(isDone: Boolean): Int =
+ if (isDone) isDoneIconResId else CorePresentation.drawable.ic_proton_key
+
+ private val createAShareLinkDescription: AnnotatedString get() {
+ val getLink = appContext.getString(I18N.string.common_get_link_action)
+ val description = appContext.getString(I18N.string.get_more_free_storage_action_link_subtitle).format(getLink)
+ val start = description.indexOf(getLink)
+ val spanStyles = listOf(
+ AnnotatedString.Range(
+ SpanStyle(fontWeight = FontWeight.Bold),
+ start = start,
+ end = start + getLink.length
+ )
+ )
+ return AnnotatedString(text = description, spanStyles = spanStyles)
+ }
+
+ private fun setARecoveryMethodDescription(linkColor: Color): AnnotatedString {
+ val urlString = appContext.getString(I18N.string.get_more_free_storage_action_recovery_subtitle_url_string)
+ val description = appContext
+ .getString(I18N.string.get_more_free_storage_action_recovery_subtitle)
+ .format(urlString)
+ val start = description.indexOf(urlString)
+ val spanStyles = listOf(
+ AnnotatedString.Range(
+ SpanStyle(color = linkColor),
+ start = start,
+ end = start + urlString.length
+ )
+ )
+ return AnnotatedString(text = description, spanStyles = spanStyles)
+ }
+
+ private fun onSetRecoverMethodSubtitleClick(offset: Int) {
+ val urlString = appContext.getString(I18N.string.get_more_free_storage_action_recovery_subtitle_url_string)
+ val description = appContext
+ .getString(I18N.string.get_more_free_storage_action_recovery_subtitle)
+ .format(urlString)
+ val start = description.indexOf(urlString)
+ val linkRange = IntRange(
+ start = start,
+ endInclusive = start + urlString.length,
+ )
+ if (offset in linkRange) {
+ try {
+ val url = appContext
+ .getString(I18N.string.get_more_free_storage_action_recovery_subtitle_url)
+ .format(urlString)
+ appContext.startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ } catch (ignored: ActivityNotFoundException) {
+ broadcastMessages(
+ userId = userId,
+ message = appContext.getString(I18N.string.common_error_no_browser_available),
+ type = BroadcastMessage.Type.ERROR,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/HomeViewModel.kt b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/HomeViewModel.kt
index 5f6cca26..a90e00a9 100644
--- a/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/HomeViewModel.kt
+++ b/app/src/main/kotlin/me/proton/android/drive/ui/viewmodel/HomeViewModel.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Proton AG.
+ * Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
@@ -29,12 +29,14 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.shareIn
import me.proton.android.drive.BuildConfig
import me.proton.android.drive.ui.navigation.HomeTab
import me.proton.android.drive.ui.navigation.Screen
import me.proton.android.drive.ui.viewevent.HomeViewEvent
import me.proton.android.drive.ui.viewstate.HomeViewState
+import me.proton.android.drive.usecase.CanGetMoreFreeStorage
import me.proton.core.domain.entity.SessionUserId
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.presentation.component.NavigationTab
@@ -53,6 +55,7 @@ class HomeViewModel @Inject constructor(
userManager: UserManager,
savedStateHandle: SavedStateHandle,
configurationProvider: ConfigurationProvider,
+ private val canGetMoreFreeStorage: CanGetMoreFreeStorage,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val tabs: StateFlow