This commit is contained in:
Damir Mihaljinec
2025-05-13 13:21:46 +02:00
parent d526c96ce6
commit a33d29abf3
766 changed files with 74971 additions and 2001 deletions
@@ -21,5 +21,6 @@ package me.proton.drive.android.settings.domain.entity
sealed interface UserOverlay {
data object Onboarding : UserOverlay
data class WhatsNew(val key: WhatsNewKey) : UserOverlay
data class Subcription(val key: String) : UserOverlay
data object RatingBooster : UserOverlay
}
@@ -21,6 +21,5 @@ package me.proton.drive.android.settings.domain.entity
import me.proton.core.drive.base.domain.entity.TimestampS
enum class WhatsNewKey(val limit: TimestampS) {
PUBLIC_SHARING(limit = TimestampS(1743465600)), // end of march 2025
ALBUMS(limit = TimestampS(1753920000)), // end of July 2025
}
+1
View File
@@ -70,6 +70,7 @@ driveModule(
implementation(libs.bundles.accompanist)
implementation(libs.bundles.core)
implementation(libs.google.play.review)
implementation(libs.lottie.compose)
implementation(libs.material)
implementation(libs.okhttp)
implementation(libs.plumber)
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.test.AbstractBaseTest
import me.proton.android.drive.ui.test.BaseTest
import me.proton.core.accountmanager.domain.AccountManager
import me.proton.core.accountmanager.domain.getPrimaryAccount
@@ -42,8 +41,13 @@ import me.proton.core.drive.share.user.domain.usecase.ConvertExternalInvitation
import me.proton.core.drive.share.user.domain.usecase.GetExternalInvitationsFlow
import me.proton.core.drive.share.user.domain.usecase.GetInvitationsFlow
import me.proton.core.drive.share.user.domain.usecase.InviteMembers
import me.proton.core.test.quark.Quark.GenKeys
import me.proton.core.test.quark.data.User
import me.proton.core.test.quark.v2.command.userCreate
import me.proton.core.test.quark.response.CreateUserQuarkResponse
import me.proton.core.test.quark.v2.QuarkCommand
import me.proton.core.test.quark.v2.command.CreateAddress
import me.proton.core.test.quark.v2.command.USERS_CREATE
import me.proton.core.test.quark.v2.toEncodedArgs
import me.proton.core.test.rule.annotation.PrepareUser
import me.proton.core.util.kotlin.random
import org.junit.Assert.assertEquals
@@ -130,3 +134,31 @@ class ExternalInvitationTest : BaseTest() {
)
}
}
// Remove with version 33 of core
fun QuarkCommand.userCreate(
user: User = User(),
createAddress: CreateAddress? = CreateAddress.WithKey(GenKeys.Curve25519)
): CreateUserQuarkResponse {
val args = listOf(
"--external" to if (user.isExternal) "true" else "",
"--external-email" to if (user.isExternal) user.email else "",
"-N" to user.name,
"-p" to user.password,
"-m" to user.passphrase,
"-r" to user.recoveryEmail,
"-c" to if (createAddress is CreateAddress.NoKey) "true" else "",
"-k" to if (createAddress is CreateAddress.WithKey) createAddress.genKeys.name else "",
"--format" to "json"
).toEncodedArgs(ignoreEmpty = true)
val response =
route(USERS_CREATE)
.args(args)
.build()
.let {
client.executeQuarkRequest(it)
}
return json.decodeFromString(response.body!!.string())
}
@@ -23,8 +23,10 @@ import androidx.test.rule.GrantPermissionRule
import kotlinx.coroutines.runBlocking
import me.proton.android.drive.initializer.MainInitializer
import me.proton.android.drive.ui.MainActivity
import me.proton.android.drive.ui.rules.OverlayRule
import me.proton.android.drive.ui.rules.SlowTestRule
import me.proton.android.drive.ui.test.AbstractBaseTest
import me.proton.core.domain.entity.UserId
import me.proton.core.test.rule.ProtonRule
import me.proton.core.test.rule.extension.protonAndroidComposeRule
import org.junit.Rule
@@ -44,18 +46,20 @@ open class ConfigurableTest : AbstractBaseTest() {
additionalRules = linkedSetOf(
IntentsRule(),
SlowTestRule(),
configurationRule
configurationRule,
OverlayRule(this),
),
beforeHilt = {
configureFusion()
},
afterHilt = {
MainInitializer.init(it.targetContext)
setOverlaysDisplayStateAfterLogin()
},
logoutBefore = false
)
override val mainUserId: UserId get() = configurationRule.mainUserId
@get:Rule(order = 2)
val ruleChain: RuleChain = RuleChain
.outerRule(permissionRule)
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import me.proton.android.drive.ui.test.AbstractBaseTest.Companion.loginTestHelper
import me.proton.core.domain.entity.UserId
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@@ -30,6 +31,7 @@ import org.junit.runners.model.Statement
class ConfigurationRule : TestRule {
lateinit var configuration: ConfigurationRoot
lateinit var mainUserId: UserId
fun getArgString(key: String): String {
return configuration.info.args.getValue(key).jsonPrimitive.content
@@ -61,7 +63,7 @@ class ConfigurationRule : TestRule {
object : Statement() {
override fun evaluate() {
configuration.user.run {
loginTestHelper.login(username, password)
mainUserId = loginTestHelper.login(username, password).userId
}
base.evaluate()
@@ -23,10 +23,12 @@ import me.proton.android.drive.lock.domain.exception.LockException
import me.proton.core.drive.base.data.entity.LoggerLevel
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.exception.DriveException
import me.proton.core.drive.link.domain.exception.LinksResultException
import me.proton.core.drive.share.domain.entity.Share
import me.proton.core.drive.share.domain.exception.ShareException
import me.proton.android.drive.lock.presentation.extension.getDefaultMessage as lockGetDefaultMessage
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.drive.link.presentation.extension.getDefaultMessage as linkGetDefaultMessage
fun DriveException.getDefaultMessage(context: Context): String = when (val exception = this) {
is ShareException.ShareLocked -> when (exception.shareType) {
@@ -42,6 +44,7 @@ fun DriveException.getDefaultMessage(context: Context): String = when (val excep
else -> context.getString(I18N.string.error_creating_share_not_allowed)
}
is LockException -> exception.lockGetDefaultMessage(context)
is LinksResultException -> exception.linkGetDefaultMessage(context)
else -> error("Default message for exception is missing")
}
@@ -63,6 +63,7 @@ class MainInitializer : Initializer<Unit> {
AccountReadyObserverInitializer::class.java,
FirstAppUsageInitializer::class.java,
UploadInitializer::class.java,
PhotoShareMigrationManagerInitializer::class.java,
)
companion object {
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.initializer
import android.content.Context
import androidx.lifecycle.Lifecycle
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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import me.proton.core.accountmanager.domain.AccountManager
import me.proton.core.accountmanager.presentation.observe
import me.proton.core.accountmanager.presentation.onAccountReady
import me.proton.core.accountmanager.presentation.onAccountRemoved
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.log.LogTag
import me.proton.core.drive.drivelink.photo.domain.manager.PhotoShareMigrationManager
import me.proton.core.presentation.app.AppLifecycleProvider
import me.proton.core.util.kotlin.CoreLogger
class PhotoShareMigrationManagerInitializer : Initializer<Unit> {
private val scopes = mutableMapOf<UserId, CoroutineScope>()
override fun create(context: Context) {
EntryPointAccessors.fromApplication(
context.applicationContext,
PhotoShareMigrationManagerInitializerEntryPoint::class.java
).run {
accountManager.observe(appLifecycleProvider.lifecycle, Lifecycle.State.RESUMED)
.onAccountReady { account ->
val userId = account.userId
val scope = scopes.getOrPut(userId) {
CoroutineScope(Dispatchers.IO + Job())
}
photoShareMigrationManager.initialize(
userId,
scope,
appLifecycleProvider.state.map { state -> state == AppLifecycleProvider.State.Foreground },
)
photoShareMigrationManager
.status
.onEach { status ->
CoreLogger.d(LogTag.PHOTO, "On migration status: ${status.name}")
}
.launchIn(scope)
}
.onAccountRemoved { account ->
scopes.remove(account.userId)?.cancel()
}
}
}
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(
LoggerInitializer::class.java,
)
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface PhotoShareMigrationManagerInitializerEntryPoint {
val accountManager: AccountManager
val appLifecycleProvider: AppLifecycleProvider
val photoShareMigrationManager: PhotoShareMigrationManager
}
@@ -32,7 +32,7 @@ class DriveApiClient @Inject constructor(
override val appVersionHeader: String
get() = configurationProvider.appVersionHeader
override val enableDebugLogging: Boolean
get() = true
get() = BuildConfig.DEBUG || BuildConfig.FLAVOR == BuildConfig.FLAVOR_ALPHA
override val shouldUseDoh: Boolean
get() = false
override val userAgent: String
@@ -28,7 +28,7 @@ import me.proton.core.drive.base.domain.entity.ClientUid
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
import me.proton.core.drive.base.domain.log.logId
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.ParentId
import me.proton.core.drive.share.domain.entity.Share
import me.proton.core.drive.share.domain.usecase.GetShare
import javax.inject.Inject
@@ -40,17 +40,17 @@ class BridgeFindDuplicatesRepository @Inject constructor(
private val photo: PhotoFindDuplicatesRepository,
) : FindDuplicatesRepository {
override suspend fun findDuplicates(
folderId: FolderId,
parentId: ParentId,
nameHashes: List<String>,
clientUids: List<ClientUid>,
): List<BackupDuplicate> {
val share = getShare(folderId.shareId)
val share = getShare(parentId.shareId)
.filterSuccessOrError().mapSuccessValueOrNull().first()
requireNotNull(share) { "Cannot find share for folder: ${folderId.id.logId()}" }
requireNotNull(share) { "Cannot find share for folder: ${parentId.id.logId()}" }
return if (share.type == Share.Type.PHOTO) {
photo.findDuplicates(folderId, nameHashes, clientUids)
photo.findDuplicates(parentId, nameHashes, clientUids)
} else {
folder.findDuplicates(folderId, nameHashes, clientUids)
folder.findDuplicates(parentId, nameHashes, clientUids)
}.let { backupDuplicates ->
if (configurationProvider.allowBackupDeletedFilesEnabled) {
backupDuplicates.filterNot { duplicate ->
@@ -67,6 +67,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import me.proton.android.drive.extension.deepLinkBaseUrl
import me.proton.android.drive.lock.data.provider.BiometricPromptProvider
@@ -91,7 +92,6 @@ import me.proton.core.crypto.common.keystore.KeyStoreCrypto
import me.proton.core.drive.announce.event.domain.usecase.AnnounceEvent
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.ListenToBroadcastMessages
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
import me.proton.core.drive.messagequeue.domain.ActionProvider
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.thumbnail.presentation.coil.ThumbnailEnabled
@@ -120,7 +120,6 @@ class MainActivity : FragmentActivity() {
@Inject lateinit var activityLauncher: ActivityLauncher
@Inject lateinit var announceEvent: AnnounceEvent
@Inject lateinit var showRatingBooster: ShowRatingBooster
@Inject lateinit var albumsFeatureFlag: AlbumsFeatureFlag
lateinit var configurationProvider: ConfigurationProvider
private val accountViewModel: AccountViewModel by viewModels()
@@ -183,6 +182,7 @@ class MainActivity : FragmentActivity() {
navigateToBugReport = bugReportViewModel::sendBugReport,
navigateToSubscription = plansViewModel::showCurrentPlans,
navigateToRatingBooster = { showRatingBooster(this@MainActivity) },
navigateToUpgradePlan = plansViewModel::startUpgrade,
) { isOpen ->
isDrawerOpen = isOpen
}
@@ -241,17 +241,10 @@ class MainActivity : FragmentActivity() {
@Composable
private fun photosRoute(): State<String?> =
remember {
accountViewModel.primaryAccount.flatMapLatest { account ->
accountViewModel.primaryAccount.mapLatest { account ->
account?.let {
albumsFeatureFlag(account.userId)
.map { enabled ->
if (enabled) {
Screen.PhotosAndAlbums.route
} else {
Screen.Photos.route
}
}
} ?: flowOf(null)
Screen.PhotosAndAlbums.route
}
}
}.collectAsState(initial = null)
@@ -19,14 +19,16 @@ package me.proton.android.drive.ui.common
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
fun DriveLink.onClick(
navigateToFolder: (FolderId, String?) -> Unit,
navigateToPreview: (FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit
) = when (this) {
is DriveLink.Folder -> navigateToFolder(id, if (isNameEncrypted) null else name)
is DriveLink.File -> navigateToPreview(id)
is DriveLink.Album -> error("TODO") // navigateToAlbum?
is DriveLink.Album -> navigateToAlbum(id)
}
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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.drive.base.presentation.component.IllustratedMessage
import me.proton.core.drive.base.presentation.extension.conditional
import me.proton.core.drive.base.presentation.extension.isLandscape
import me.proton.core.drive.base.presentation.extension.isPortrait
@Composable
fun PromoContainer(
titleResId: Int,
descriptionResId: Int,
imageResId: Int,
actionText: String,
dismissActionText: String,
modifier: Modifier = Modifier,
onAction: () -> Unit,
onCancel: () -> Unit,
) {
PromoContainer(
modifier = modifier,
title = stringResource(id = titleResId),
description = stringResource(id = descriptionResId),
image = {
Image(painter = painterResource(id = imageResId), contentDescription = null)
},
actionText = actionText,
dismissActionText = dismissActionText,
onAction = onAction,
onCancel = onCancel
)
}
@Composable
fun PromoContainer(
modifier: Modifier = Modifier,
title: String,
description: String,
image: @Composable () -> Unit,
actionText: String,
dismissActionText: String,
onAction: () -> Unit,
onCancel: () -> Unit,
) {
PromoContainer(
actionText = actionText,
dismissActionText = dismissActionText,
bodyContent = {
IllustratedMessage(
imageContent = image,
title = title,
description = description,
)
},
onAction = onAction,
onCancel = onCancel,
modifier = modifier,
)
}
@Composable
fun PromoContainer(
actionText: String,
dismissActionText: String,
modifier: Modifier = Modifier,
bodyContent: @Composable () -> Unit,
onAction: () -> Unit,
onCancel: () -> Unit,
) {
Column(
modifier = modifier.padding(horizontal = ProtonDimens.DefaultSpacing),
verticalArrangement = Arrangement.spacedBy(ProtonDimens.DefaultSpacing),
horizontalAlignment = Alignment.CenterHorizontally,
) {
bodyContent()
val buttonModifier = Modifier
.conditional(isPortrait) {
fillMaxWidth()
}
.conditional(isLandscape) {
widthIn(min = ButtonMinWidth)
}
.heightIn(min = ProtonDimens.ListItemHeight)
ProtonSolidButton(
onClick = onAction,
modifier = buttonModifier,
) {
Text(
text = actionText,
modifier = Modifier.padding(horizontal = ProtonDimens.DefaultSpacing)
)
}
ProtonTextButton(
onClick = onCancel,
modifier = buttonModifier
) {
Text(
text = dismissActionText,
modifier = Modifier.padding(horizontal = ProtonDimens.DefaultSpacing)
)
}
}
}
private val ButtonMinWidth = 300.dp
@@ -18,8 +18,8 @@
package me.proton.android.drive.ui.dialog
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -36,7 +36,6 @@ import androidx.lifecycle.flowWithLifecycle
import me.proton.android.drive.photos.presentation.extension.details
import me.proton.android.drive.ui.viewmodel.AlbumOptionsViewModel
import me.proton.core.compose.component.bottomsheet.BottomSheetContent
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.drive.base.presentation.component.BottomSheetEntry
import me.proton.core.drive.base.presentation.component.RunAction
import me.proton.core.drive.drivelink.domain.entity.DriveLink
@@ -55,6 +54,7 @@ fun AlbumOptions(
navigateToManageAccess: (linkId: LinkId) -> Unit,
navigateToRename: (linkId: LinkId) -> Unit,
navigateToDelete: (AlbumId) -> Unit,
navigateToLeave: (AlbumId) -> Unit,
dismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -70,6 +70,7 @@ fun AlbumOptions(
navigateToManageAccess = navigateToManageAccess,
navigateToRename = navigateToRename,
navigateToDelete = navigateToDelete,
navigateToLeave = navigateToLeave,
dismiss = dismiss,
).flowWithLifecycle(
lifecycle = lifecycle,
@@ -153,10 +154,9 @@ fun AlbumOptionsHeader(
subtitle = details,
modifier = modifier,
) { contentModifier ->
Icon(
painter = painterResource(id = BasePresentation.drawable.ic_proton_images),
Image(
painter = painterResource(id = BasePresentation.drawable.ic_folder_album),
contentDescription = null,
tint = ProtonTheme.colors.iconNorm,
modifier = contentModifier,
)
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.dialog
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.android.drive.photos.presentation.component.ConfirmLeaveAlbumDialogContent
import me.proton.android.drive.ui.viewmodel.ConfirmLeaveAlbumDialogViewModel
@Composable
fun ConfirmLeaveAlbumDialog(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<ConfirmLeaveAlbumDialogViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(
initialValue = viewModel.initialViewState
)
val lifecycle = LocalLifecycleOwner.current.lifecycle
val viewEvent = remember(lifecycle) {
viewModel.viewEvent(onDismiss)
}
ConfirmLeaveAlbumDialogContent(
viewState = viewState,
viewEvent = viewEvent,
modifier = modifier
.testTag(ConfirmLeaveAlbumDialogTestTag.confirmLeaveAlbum),
)
}
object ConfirmLeaveAlbumDialogTestTag {
const val confirmLeaveAlbum = "confirm leave album"
}
@@ -159,7 +159,7 @@ fun FileOrFolderOptions(
.testTag(FileFolderOptionsDialogTestTag.folderOptions),
)
}
is DriveLink.Album -> error("TODO")// AlbumOptions
is DriveLink.Album -> error("On Album we should invoke AlbumOptions")
}
}
@@ -41,6 +41,7 @@ fun MultipleFileOrFolderOptions(
runAction: RunAction,
navigateToMove: (selectionId: SelectionId, folderId: FolderId?) -> Unit,
navigateToCreateNewAlbum: () -> Unit,
navigateToShareMultiplePhotosOptions: (SelectionId) -> Unit,
dismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -54,6 +55,7 @@ fun MultipleFileOrFolderOptions(
runAction = runAction,
navigateToMove = navigateToMove,
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
navigateToShareMultiplePhotosOptions = navigateToShareMultiplePhotosOptions,
dismiss = dismiss,
).flowWithLifecycle(
lifecycle = lifecycle,
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.dialog
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import me.proton.android.drive.ui.component.PromoContainer
import me.proton.android.drive.ui.viewmodel.PhotosImportantUpdatesViewModel
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.defaultHighlightNorm
import me.proton.core.drive.base.presentation.component.IllustratedMessage
import me.proton.core.drive.base.presentation.R as BasePresentation
import me.proton.core.drive.base.presentation.component.RunAction
import me.proton.core.drive.i18n.R as I18N
@Composable
fun PhotosImportantUpdates(
runAction: RunAction,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<PhotosImportantUpdatesViewModel>()
val viewEvent = remember {
viewModel.viewEvent(
runAction = runAction,
)
}
DisposableEffect(Unit){
onDispose {
viewEvent.onRemindMeLater()
}
}
PhotosImportantUpdates(
modifier = modifier
.navigationBarsPadding()
.systemBarsPadding(),
onStart = viewEvent.onStart,
onRemindMeLater = viewEvent.onRemindMeLater,
)
}
@Composable
fun PhotosImportantUpdates(
modifier: Modifier = Modifier,
onStart: () -> Unit,
onRemindMeLater: () -> Unit,
) {
Column {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(ProtonDimens.DefaultSpacing),
text = stringResource(I18N.string.photos_important_updates_title),
style = ProtonTheme.typography.headline,
)
PromoContainer(
bodyContent = { PhotosImportantUpdatesBody() },
actionText = stringResource(I18N.string.photos_important_updates_start_action),
dismissActionText = stringResource(I18N.string.photos_important_updates_cancel_action),
onAction = onStart,
onCancel = onRemindMeLater,
modifier = modifier,
)
}
}
@Composable
fun PhotosImportantUpdatesBody(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = ProtonDimens.DefaultSpacing),
) {
IllustratedMessage(
imageContent = { PhotosImportantUpdatesIllustration() },
titleContent = {
Text(
text = stringResource(I18N.string.photos_important_updates_subtitle),
style = ProtonTheme.typography.defaultHighlightNorm.copy(textAlign = TextAlign.Center),
modifier = Modifier.padding(
top = ProtonDimens.DefaultSpacing,
)
)
},
description = stringResource(I18N.string.photos_important_updates_description),
)
}
}
@Composable
fun PhotosImportantUpdatesIllustration(
modifier: Modifier = Modifier,
) {
val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes(BasePresentation.raw.file_transfer_animation)
)
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever,
)
Box(
modifier = modifier
.fillMaxWidth()
.padding(bottom = ProtonDimens.DefaultSpacing),
contentAlignment = Alignment.Center,
) {
LottieAnimation(
composition = composition,
progress = { progress },
)
}
}
@Preview
@Composable
private fun PhotosImportantUpdatesPreview() {
ProtonTheme {
PhotosImportantUpdates(
onStart = {},
onRemindMeLater = {},
)
}
}
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.dialog
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.android.drive.photos.presentation.component.AlbumListItem
import me.proton.android.drive.photos.presentation.component.AlbumOptionsSection
import me.proton.android.drive.photos.presentation.state.AlbumsItem
import me.proton.android.drive.ui.viewevent.ShareMultiplePhotosOptionsViewEvent
import me.proton.android.drive.ui.viewmodel.ShareMultiplePhotosOptionsViewModel
import me.proton.android.drive.ui.viewstate.ShareMultiplePhotosOptionsViewState
import me.proton.core.drive.base.presentation.component.BottomSheetEntry
import me.proton.core.drive.base.presentation.component.RunAction
import me.proton.core.drive.files.presentation.entry.OptionEntry
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.LinkId
@Composable
fun ShareMultiplePhotosOptions(
runAction: RunAction,
modifier: Modifier = Modifier,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
) {
val viewModel = hiltViewModel<ShareMultiplePhotosOptionsViewModel>()
val viewState = viewModel.initialViewState
val viewEvent = remember {
viewModel.viewEvent(
runAction = runAction,
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
navigateToAlbum = navigateToAlbum,
)
}
ShareMultiplePhotosOptions(
viewState = viewState,
viewEvent = viewEvent,
modifier = modifier
.testTag(ShareMultiplePhotosOptionsTestTag.screen)
.navigationBarsPadding(),
)
}
@Composable
fun ShareMultiplePhotosOptions(
viewState: ShareMultiplePhotosOptionsViewState,
viewEvent: ShareMultiplePhotosOptionsViewEvent,
modifier: Modifier = Modifier,
) {
val sharedAlbums by viewState.sharedAlbums.collectAsStateWithLifecycle(emptyList())
ShareMultiplePhotosOptions(
shareOptionsSectionTitle = stringResource(viewState.shareOptionsSectionTitleResId),
shareOptions = viewState.shareOptions,
sharedAlbumsSectionTitle = stringResource(viewState.sharedAlbumsSectionTitleResId),
sharedAlbums = sharedAlbums,
modifier = modifier,
onSharedAlbum = viewEvent.onSharedAlbum,
onScroll = viewEvent.onScroll,
)
}
@Composable
fun ShareMultiplePhotosOptions(
shareOptionsSectionTitle: String,
shareOptions: List<OptionEntry<Unit>>,
sharedAlbumsSectionTitle: String,
sharedAlbums: List<AlbumsItem.Listing>,
modifier: Modifier = Modifier,
onSharedAlbum: (AlbumId) -> Unit,
onScroll: (Set<LinkId>) -> Unit,
) {
val listState = rememberLazyListState()
val firstVisibleItemIndex by remember(
listState
) { derivedStateOf { listState.firstVisibleItemIndex } }
LaunchedEffect(firstVisibleItemIndex, sharedAlbums) {
onScroll(
sharedAlbums
.takeIf { list -> list.isNotEmpty() && list.size > firstVisibleItemIndex }
?.let { list ->
val sizeRange = IntRange(0, list.size - 1)
val fromIndex = (firstVisibleItemIndex - 10).coerceIn(sizeRange)
val toIndex = (firstVisibleItemIndex + 20).coerceIn(sizeRange)
list.subList(fromIndex, toIndex + 1)
.flatMap { albumListing ->
listOfNotNull(albumListing.id, albumListing.album?.coverLinkId)
}
.toSet()
} ?: emptySet(),
)
}
LazyColumn(
state = listState,
modifier = modifier
) {
item(
key = shareOptionsSectionTitle
) {
AlbumOptionsSection(
title = shareOptionsSectionTitle
)
}
items(
count = shareOptions.size,
key = { shareOptions[it].label },
) {
val option = shareOptions[it]
BottomSheetEntry(
leadingIcon = option.icon,
trailingIcon = null,
title = stringResource(option.label),
onClick = { option.onClick(Unit) },
)
}
if (sharedAlbums.isNotEmpty()) {
item(
key = sharedAlbumsSectionTitle
) {
AlbumOptionsSection(
title = sharedAlbumsSectionTitle
)
}
items(
count = sharedAlbums.size,
key = { "${sharedAlbums[it].id.shareId.id}.${sharedAlbums[it].id.id}" },
) {
val albumsItem = sharedAlbums[it]
AlbumListItem(
albumsItem = albumsItem,
onClick = onSharedAlbum,
)
}
}
}
}
object ShareMultiplePhotosOptionsTestTag {
const val screen = "share multiple photos options screen"
}
@@ -20,4 +20,5 @@ package me.proton.android.drive.ui.effect
sealed interface PhotosEffect {
data object ShowUpsell : PhotosEffect
data object ShowImportantUpdates: PhotosEffect
}
@@ -61,7 +61,6 @@ 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
import me.proton.android.drive.extension.runFromRoute
import me.proton.android.drive.lock.presentation.component.AppLock
import me.proton.android.drive.log.DriveLogTag
@@ -72,6 +71,7 @@ import me.proton.android.drive.ui.dialog.ComputerOptions
import me.proton.android.drive.ui.dialog.ConfirmDeleteAlbumDialog
import me.proton.android.drive.ui.dialog.ConfirmDeletionDialog
import me.proton.android.drive.ui.dialog.ConfirmEmptyTrashDialog
import me.proton.android.drive.ui.dialog.ConfirmLeaveAlbumDialog
import me.proton.android.drive.ui.dialog.ConfirmSkipIssuesDialog
import me.proton.android.drive.ui.dialog.ConfirmStopAllSharingDialog
import me.proton.android.drive.ui.dialog.ConfirmStopLinkSharingDialog
@@ -81,12 +81,14 @@ import me.proton.android.drive.ui.dialog.LogOptions
import me.proton.android.drive.ui.dialog.MultipleFileOrFolderOptions
import me.proton.android.drive.ui.dialog.Onboarding
import me.proton.android.drive.ui.dialog.ParentFolderOptions
import me.proton.android.drive.ui.dialog.PhotosImportantUpdates
import me.proton.android.drive.ui.dialog.ProtonDocsInsertImageOptions
import me.proton.android.drive.ui.dialog.SendFileDialog
import me.proton.android.drive.ui.dialog.ShareExternalInvitationOptions
import me.proton.android.drive.ui.dialog.ShareInvitationOptions
import me.proton.android.drive.ui.dialog.ShareLinkPermissions
import me.proton.android.drive.ui.dialog.ShareMemberOptions
import me.proton.android.drive.ui.dialog.ShareMultiplePhotosOptions
import me.proton.android.drive.ui.dialog.SortingList
import me.proton.android.drive.ui.dialog.SystemAccessDialog
import me.proton.android.drive.ui.dialog.WhatsNew
@@ -103,6 +105,7 @@ import me.proton.android.drive.ui.screen.AppAccessScreen
import me.proton.android.drive.ui.screen.BackupIssuesScreen
import me.proton.android.drive.ui.screen.CreateNewAlbumScreen
import me.proton.android.drive.ui.screen.DefaultHomeTabScreen
import me.proton.android.drive.ui.screen.SubscriptionPromoScreen
import me.proton.android.drive.ui.screen.FileInfoScreen
import me.proton.android.drive.ui.screen.GetMoreFreeStorageScreen
import me.proton.android.drive.ui.screen.HomeScreen
@@ -113,7 +116,7 @@ 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.PickerAlbumScreen
import me.proton.android.drive.ui.screen.PickerPhotosAndAlbumsScreen
import me.proton.android.drive.ui.screen.PickerPhotosScreen
import me.proton.android.drive.ui.screen.PreviewScreen
import me.proton.android.drive.ui.screen.SettingsScreen
import me.proton.android.drive.ui.screen.SigningOutScreen
@@ -169,6 +172,7 @@ fun AppNavGraph(
navigateToBugReport: () -> Unit,
navigateToSubscription: () -> Unit,
navigateToRatingBooster: () -> Unit,
navigateToUpgradePlan: () -> Unit,
onDrawerStateChanged: (Boolean) -> Unit,
) {
val navController = rememberAnimatedNavController(keyStoreCrypto)
@@ -234,6 +238,7 @@ fun AppNavGraph(
navigateToBugReport = navigateToBugReport,
navigateToSubscription = navigateToSubscription,
navigateToRatingBooster = navigateToRatingBooster,
navigateToUpgradePlan = navigateToUpgradePlan,
onDrawerStateChanged = onDrawerStateChanged,
)
}
@@ -256,6 +261,7 @@ fun AppNavGraph(
navigateToBugReport: () -> Unit,
navigateToSubscription: () -> Unit,
navigateToRatingBooster: () -> Unit,
navigateToUpgradePlan: () -> Unit,
onDrawerStateChanged: (Boolean) -> Unit,
) {
DriveNavHost(
@@ -388,8 +394,12 @@ fun AppNavGraph(
addAlbum(navController)
addAlbumOptions(navController)
addConfirmDeleteAlbumDialog(navController)
addPickerPhotosAndAlbums(navController)
addPickerPhotos(navController)
addPickerAlbum(navController)
addSubscriptionPromoScreen(navigateToUpgradePlan)
addConfirmLeaveAlbumDialog(navController)
addShareMultiplePhotosOptions(navController)
addPhotosImportantUpdates(navController)
}
}
@@ -493,7 +503,11 @@ fun NavGraphBuilder.addFileOrFolderOptions(
type = NavType.StringType
nullable = true
},
navArgument(Screen.FileOrFolderOptions.KEY_ALBUM_SHARE_ID) {
navArgument(Screen.FileOrFolderOptions.ALBUM_SHARE_ID) {
type = NavType.StringType
nullable = true
},
navArgument(Screen.FileOrFolderOptions.SELECTION_ID) {
type = NavType.StringType
nullable = true
},
@@ -587,6 +601,11 @@ fun NavGraphBuilder.addMultipleFileOrFolderOptions(
popUpTo(Screen.MultipleFileOrFolderOptions.route) { inclusive = true }
}
},
navigateToShareMultiplePhotosOptions = { selectionId ->
navController.navigate(Screen.ShareMultiplePhotosOptions(userId, selectionId)) {
popUpTo(Screen.MultipleFileOrFolderOptions.route) { inclusive = true }
}
},
dismiss = {
navController.popBackStack(
route = Screen.MultipleFileOrFolderOptions.route,
@@ -701,6 +720,7 @@ fun NavGraphBuilder.addUserInvitation(
route = Screen.UserInvitation.route,
arguments = listOf(
navArgument(Screen.UserInvitation.USER_ID) { type = NavType.StringType },
navArgument(Screen.UserInvitation.ALBUMS_ONLY) { type = NavType.BoolType },
),
) {
UserInvitationScreen (onBack = {
@@ -887,6 +907,9 @@ internal fun NavGraphBuilder.addHome(
navigateToPreview = { fileId, pagerType ->
navController.navigate(Screen.PagerPreview(pagerType, userId, fileId))
},
navigateToPreviewWithTag = { fileId, pagerType, photoTag ->
navController.navigate(Screen.PagerPreview(pagerType, userId, fileId, photoTag))
},
navigateToSorting = { sorting ->
navController.navigate(
Screen.Sorting(userId, currentFolderId, sorting.by, sorting.direction)
@@ -895,9 +918,13 @@ internal fun NavGraphBuilder.addHome(
navigateToSettings = {
navController.navigate(Screen.Settings(userId))
},
navigateToFileOrFolderOptions = { linkId ->
navigateToFileOrFolderOptions = { linkId, selectionId ->
navController.navigate(
Screen.FileOrFolderOptions(userId, linkId)
Screen.FileOrFolderOptions(
userId = userId,
linkId = linkId,
selectionId = selectionId,
)
)
},
navigateToMultipleFileOrFolderOptions = { selectionId ->
@@ -961,8 +988,8 @@ internal fun NavGraphBuilder.addHome(
)
)
},
navigateToUserInvitation = {
navController.navigate(Screen.UserInvitation(userId))
navigateToUserInvitation = { albumsOnly ->
navController.navigate(Screen.UserInvitation(userId, albumsOnly))
},
navigateToCreateNewAlbum = {
navController.navigate(Screen.PhotosAndAlbums.CreateNewAlbum(userId))
@@ -970,6 +997,14 @@ internal fun NavGraphBuilder.addHome(
navigateToAlbum = { albumId ->
navController.navigate(Screen.Album(albumId))
},
navigateToSubscriptionPromo = { key ->
navController.navigate(Screen.Promo.Subscription(userId, key))
},
navigateToPhotosImportantUpdates = {
navController.navigate(
Screen.Photos.ImportantUpdates(userId)
)
},
modifier = Modifier.fillMaxSize(),
)
}
@@ -1323,6 +1358,11 @@ fun NavGraphBuilder.addOffline(navController: NavHostController) = composable(
Screen.PagerPreview(pagerType, userId, fileId)
)
},
navigateToAlbum = { albumId ->
navController.navigate(
Screen.Album(albumId)
)
},
navigateBack = {
navController.popBackStack(
route = Screen.OfflineFiles.route,
@@ -1363,6 +1403,10 @@ fun NavGraphBuilder.addPagerPreview(navController: NavHostController) = composab
type = NavType.StringType
nullable = true
},
navArgument(Screen.PagerPreview.PHOTO_TAG) {
type = NavType.StringType
nullable = true
},
),
) { navBackStackEntry ->
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
@@ -1949,6 +1993,22 @@ fun NavGraphBuilder.addPhotosUpsell(
)
}
@ExperimentalAnimationApi
fun NavGraphBuilder.addSubscriptionPromoScreen(
navigateToSubscription: () -> Unit,
) = modalBottomSheet(
route = Screen.Promo.Subscription.route,
arguments = listOf(
navArgument(Screen.Settings.USER_ID) { type = NavType.StringType },
navArgument(Screen.Promo.Subscription.PROMO_KEY) { type = NavType.StringType },
),
) { _, runAction ->
SubscriptionPromoScreen(
runAction = runAction,
navigateToSubscription = navigateToSubscription,
)
}
fun NavGraphBuilder.addComputerOptions(
navController: NavHostController,
) = modalBottomSheet(
@@ -2166,10 +2226,12 @@ fun NavGraphBuilder.addCreateNewAlbum(navController: NavHostController) = compos
popEnterTransition = { EnterTransition.None },
popExitTransition = defaultPopExitSlideTransition { true },
arguments = listOf(
navArgument(Screen.Log.USER_ID) { type = NavType.StringType },
navArgument(Screen.PhotosAndAlbums.USER_ID) { type = NavType.StringType },
navArgument(Screen.PhotosAndAlbums.SHARED_ALBUM) { type = NavType.BoolType },
),
) { navBackStackEntry ->
val userId = UserId(navBackStackEntry.require(Screen.PhotosAndAlbums.USER_ID))
val sharedAlbum: Boolean = navBackStackEntry.get(Screen.PhotosAndAlbums.SHARED_ALBUM) ?: false
CreateNewAlbumScreen(
navigateBack = {
navController.popBackStack(
@@ -2179,11 +2241,16 @@ fun NavGraphBuilder.addCreateNewAlbum(navController: NavHostController) = compos
},
navigateToAlbum = { albumId: AlbumId ->
navController.navigate(Screen.Album(albumId)) {
popUpTo(route = Screen.PhotosAndAlbums.CreateNewAlbum.route) { inclusive = true }
popUpTo(route = Screen.PhotosAndAlbums.CreateNewAlbum.route) {
inclusive = true
}
}
if (sharedAlbum) {
navController.navigate(Screen.ShareViaInvitations(userId, albumId))
}
},
navigateToPicker = {
navController.navigate(Screen.Picker.PhotosAndAlbums(userId = userId))
navController.navigate(Screen.Picker.Photos(userId = userId))
},
)
}
@@ -2206,9 +2273,9 @@ fun NavGraphBuilder.addAlbum(navController: NavHostController) = composable(
navigateToAlbumOptions = { albumId ->
navController.navigate(Screen.AlbumOptions(userId, albumId))
},
navigateToPhotosOptions = { linkId, albumId ->
navigateToPhotosOptions = { linkId, albumId, selectionId ->
navController.navigate(
Screen.FileOrFolderOptions(userId, linkId, albumId)
Screen.FileOrFolderOptions(userId, linkId, albumId, selectionId)
)
},
navigateToMultiplePhotosOptions = { selectionId, albumId ->
@@ -2225,7 +2292,13 @@ fun NavGraphBuilder.addAlbum(navController: NavHostController) = composable(
))
},
navigateToPicker = { albumId ->
navController.navigate(Screen.Picker.PhotosAndAlbums(destinationAlbumId = albumId))
navController.navigate(Screen.Picker.Photos(destinationAlbumId = albumId))
},
navigateToShareViaInvitations = { albumId ->
navController.navigate(Screen.ShareViaInvitations(userId, albumId))
},
navigateToManageAccess = { albumId ->
navController.navigate(Screen.ManageAccess(userId, albumId))
},
navigateBack = {
navController.popBackStack(
@@ -2242,7 +2315,7 @@ fun NavGraphBuilder.addAlbumOptions(
route = Screen.AlbumOptions.route,
viewState = ModalBottomSheetViewState(dismissOnAction = false),
arguments = listOf(
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
navArgument(Screen.USER_ID) { type = NavType.StringType },
navArgument(Screen.AlbumOptions.SHARE_ID) { type = NavType.StringType },
navArgument(Screen.AlbumOptions.ALBUM_ID) { type = NavType.StringType },
),
@@ -2270,6 +2343,11 @@ fun NavGraphBuilder.addAlbumOptions(
popUpTo(Screen.AlbumOptions.route) { inclusive = true }
}
},
navigateToLeave = { albumId ->
navController.navigate(Screen.Album.Dialogs.ConfirmLeaveAlbum(albumId)) {
popUpTo(Screen.AlbumOptions.route) { inclusive = true }
}
},
dismiss = {
navController.popBackStack(
route = Screen.AlbumOptions.route,
@@ -2283,7 +2361,7 @@ fun NavGraphBuilder.addAlbumOptions(
fun NavGraphBuilder.addConfirmDeleteAlbumDialog(navController: NavHostController) = dialog(
route = Screen.Album.Dialogs.ConfirmDeleteAlbum.route,
arguments = listOf(
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
navArgument(Screen.USER_ID) { type = NavType.StringType },
navArgument(Screen.Album.Dialogs.ConfirmDeleteAlbum.ALBUM_ID) { type = NavType.StringType },
navArgument(Screen.Album.Dialogs.ConfirmDeleteAlbum.SHARE_ID) { type = NavType.StringType },
),
@@ -2299,8 +2377,8 @@ fun NavGraphBuilder.addConfirmDeleteAlbumDialog(navController: NavHostController
}
@ExperimentalAnimationApi
fun NavGraphBuilder.addPickerPhotosAndAlbums(navController: NavHostController) = composable(
route = Screen.Picker.PhotosAndAlbums.route,
fun NavGraphBuilder.addPickerPhotos(navController: NavHostController) = composable(
route = Screen.Picker.Photos.route,
enterTransition = defaultEnterSlideTransition { true },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
@@ -2318,18 +2396,10 @@ fun NavGraphBuilder.addPickerPhotosAndAlbums(navController: NavHostController) =
},
),
) {
PickerPhotosAndAlbumsScreen(
navigateToAlbum = { albumId, destinationAlbumId ->
val route = if (destinationAlbumId != null) {
Screen.Picker.Album(albumId, destinationAlbumId)
} else {
Screen.Picker.Album(albumId)
}
navController.navigate(route)
},
PickerPhotosScreen(
navigateBack = {
navController.popBackStack(
route = Screen.Picker.PhotosAndAlbums.route,
route = Screen.Picker.Photos.route,
inclusive = true,
)
}
@@ -2367,13 +2437,73 @@ fun NavGraphBuilder.addPickerAlbum(navController: NavHostController) = composabl
},
onAddToAlbumDone = {
navController.popBackStack(
route = Screen.Picker.PhotosAndAlbums.route,
route = Screen.Picker.Photos.route,
inclusive = true,
)
},
)
}
@ExperimentalCoroutinesApi
fun NavGraphBuilder.addConfirmLeaveAlbumDialog(navController: NavHostController) = dialog(
route = Screen.Album.Dialogs.ConfirmLeaveAlbum.route,
arguments = listOf(
navArgument(Screen.USER_ID) { type = NavType.StringType },
navArgument(Screen.Album.Dialogs.ConfirmLeaveAlbum.ALBUM_ID) { type = NavType.StringType },
navArgument(Screen.Album.Dialogs.ConfirmLeaveAlbum.SHARE_ID) { type = NavType.StringType },
),
) {
ConfirmLeaveAlbumDialog(
onDismiss = {
navController.popBackStack(
route = Screen.Album.Dialogs.ConfirmLeaveAlbum.route,
inclusive = true,
)
}
)
}
fun NavGraphBuilder.addShareMultiplePhotosOptions(
navController: NavHostController,
) = modalBottomSheet(
route = Screen.ShareMultiplePhotosOptions.route,
viewState = ModalBottomSheetViewState(dismissOnAction = false),
arguments = listOf(
navArgument(Screen.ShareMultiplePhotosOptions.USER_ID) { type = NavType.StringType },
navArgument(Screen.ShareMultiplePhotosOptions.SELECTION_ID) { type = NavType.StringType },
),
) { navBackStackEntry, runAction ->
val userId = UserId(navBackStackEntry.require(Screen.ShareMultiplePhotosOptions.USER_ID))
ShareMultiplePhotosOptions(
runAction = runAction,
navigateToCreateNewAlbum = {
navController.navigate(
Screen.PhotosAndAlbums.CreateNewAlbum(
userId = userId,
sharedAlbum = true,
)
)
},
navigateToAlbum = { albumId ->
navController.navigate(Screen.Album(albumId))
}
)
}
@ExperimentalAnimationApi
fun NavGraphBuilder.addPhotosImportantUpdates(
navController: NavHostController,
) = modalBottomSheet(
route = Screen.Photos.ImportantUpdates.route,
arguments = listOf(
navArgument(Screen.Photos.USER_ID) { type = NavType.StringType },
),
) { _, runAction ->
PhotosImportantUpdates(
runAction = runAction,
)
}
private suspend fun CoroutineScope.announceScreen(
announceEvent: AnnounceEvent,
primaryAccount: Flow<Account?>,
@@ -48,6 +48,7 @@ import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.entity.PhotoTag
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.sorting.domain.entity.Sorting
@@ -62,8 +63,9 @@ fun HomeNavGraph(
startDestination: String,
homeScaffoldState: HomeScaffoldState,
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
navigateToPreviewWithTag: (fileId: FileId, pagerType: PagerType, photoTag: PhotoTag) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToFileOrFolderOptions: (LinkId, SelectionId?) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
navigateToPhotosPermissionRationale: () -> Unit,
@@ -73,9 +75,10 @@ fun HomeNavGraph(
navigateToBackupSettings: () -> Unit,
navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToUserInvitation: () -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
) = DriveNavHost(
navController = homeNavController,
startDestination = startDestination
@@ -87,7 +90,7 @@ fun HomeNavGraph(
homeScaffoldState,
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
navigateToSorting,
{ linkId -> navigateToFileOrFolderOptions(linkId) },
{ linkId -> navigateToFileOrFolderOptions(linkId, null) },
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) },
navigateToParentFolderOptions,
)
@@ -97,8 +100,14 @@ fun HomeNavGraph(
arguments,
homeScaffoldState,
navigateToPhotosPermissionRationale,
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO) },
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId) },
navigateToPhotosPreview = { fileId, photoTag ->
if (photoTag == null) {
navigateToPreview(fileId, PagerType.PHOTO)
} else {
navigateToPreviewWithTag(fileId, PagerType.PHOTO, photoTag)
}
},
navigateToPhotosOptions = { fileId, selectionId -> navigateToFileOrFolderOptions(fileId, selectionId) },
navigateToMultiplePhotosOptions = { selectionId ->
navigateToMultipleFileOrFolderOptions(selectionId)
},
@@ -107,6 +116,7 @@ fun HomeNavGraph(
navigateToPhotosUpsell = navigateToPhotosUpsell,
navigateToBackupSettings = navigateToBackupSettings,
navigateToNotificationPermissionRationale = navigateToNotificationPermissionRationale,
navigateToPhotosImportantUpdates = navigateToPhotosImportantUpdates,
)
addPhotosAndAlbums(
homeNavController,
@@ -114,8 +124,14 @@ fun HomeNavGraph(
arguments,
homeScaffoldState,
navigateToPhotosPermissionRationale,
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO) },
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId) },
navigateToPhotosPreview = { fileId, photoTag ->
if (photoTag == null) {
navigateToPreview(fileId, PagerType.PHOTO)
} else {
navigateToPreviewWithTag(fileId, PagerType.PHOTO, photoTag)
}
},
navigateToPhotosOptions = { fileId, selectionId -> navigateToFileOrFolderOptions(fileId, selectionId) },
navigateToMultiplePhotosOptions = { selectionId ->
navigateToMultipleFileOrFolderOptions(selectionId)
},
@@ -126,6 +142,8 @@ fun HomeNavGraph(
navigateToNotificationPermissionRationale = navigateToNotificationPermissionRationale,
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
navigateToAlbum = navigateToAlbum,
navigateToUserInvitation = navigateToUserInvitation,
navigateToPhotosImportantUpdates = navigateToPhotosImportantUpdates,
)
addComputers(
homeNavController,
@@ -134,7 +152,7 @@ fun HomeNavGraph(
homeScaffoldState,
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
navigateToSorting,
{ linkId -> navigateToFileOrFolderOptions(linkId) },
{ linkId -> navigateToFileOrFolderOptions(linkId, null) },
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) },
navigateToParentFolderOptions,
navigateToComputerOptions,
@@ -146,8 +164,9 @@ fun HomeNavGraph(
homeScaffoldState = homeScaffoldState,
navigateToFolderPreview = { fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
navigateToSinglePreview = { fileId -> navigateToPreview(fileId, PagerType.SINGLE) },
navigateToAlbum = navigateToAlbum,
navigateToSorting = navigateToSorting,
navigateToFileOrFolderOptions = { linkId -> navigateToFileOrFolderOptions(linkId) },
navigateToFileOrFolderOptions = { linkId -> navigateToFileOrFolderOptions(linkId, null) },
navigateToMultipleFileOrFolderOptions = { selectionId ->
navigateToMultipleFileOrFolderOptions(selectionId)
},
@@ -165,7 +184,7 @@ fun NavGraphBuilder.addFiles(
homeScaffoldState: HomeScaffoldState,
navigateToPreview: (linkId: FileId) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToFileOrFolderOptions: (LinkId) -> Unit,
navigateToMultipleFileOrFolderOptions: (SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
) = composable(
@@ -237,14 +256,15 @@ fun NavGraphBuilder.addPhotos(
arguments: Bundle,
homeScaffoldState: HomeScaffoldState,
navigateToPhotosPermissionRationale: () -> Unit,
navigateToPhotosPreview: (fileId: FileId) -> Unit,
navigateToPhotosOptions: (fileId: FileId) -> Unit,
navigateToPhotosPreview: (fileId: FileId, photoTag: PhotoTag?) -> Unit,
navigateToPhotosOptions: (fileId: FileId, SelectionId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
) = composable(
route = Screen.Photos.route,
arguments = listOf(
@@ -271,6 +291,7 @@ fun NavGraphBuilder.addPhotos(
navigateToPhotosUpsell = navigateToPhotosUpsell,
navigateToBackupSettings = navigateToBackupSettings,
navigateToNotificationPermissionRationale = navigateToNotificationPermissionRationale,
navigateToPhotosImportantUpdates = navigateToPhotosImportantUpdates,
)
} ?: let {
val userId = UserId(requireNotNull(arguments.getString(Screen.Photos.USER_ID)))
@@ -292,8 +313,8 @@ fun NavGraphBuilder.addPhotosAndAlbums(
arguments: Bundle,
homeScaffoldState: HomeScaffoldState,
navigateToPhotosPermissionRationale: () -> Unit,
navigateToPhotosPreview: (fileId: FileId) -> Unit,
navigateToPhotosOptions: (fileId: FileId) -> Unit,
navigateToPhotosPreview: (fileId: FileId, PhotoTag?) -> Unit,
navigateToPhotosOptions: (fileId: FileId, SelectionId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
@@ -302,6 +323,8 @@ fun NavGraphBuilder.addPhotosAndAlbums(
navigateToNotificationPermissionRationale: () -> Unit,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
) = composable(
route = Screen.PhotosAndAlbums.route,
arguments = listOf(
@@ -325,6 +348,8 @@ fun NavGraphBuilder.addPhotosAndAlbums(
navigateToNotificationPermissionRationale = navigateToNotificationPermissionRationale,
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
navigateToAlbum = navigateToAlbum,
navigateToUserInvitation = navigateToUserInvitation,
navigateToPhotosImportantUpdates = navigateToPhotosImportantUpdates,
)
} ?: let {
val userId = UserId(requireNotNull(arguments.getString(Screen.PhotosAndAlbums.USER_ID)))
@@ -481,11 +506,12 @@ fun NavGraphBuilder.addSharedTabs(
homeScaffoldState: HomeScaffoldState,
navigateToFolderPreview: (fileId: FileId) -> Unit,
navigateToSinglePreview: (fileId: FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToMultipleFileOrFolderOptions: (SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
navigateToUserInvitation: () -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
) = composable(
route = Screen.SharedTabs.route,
enterTransition = defaultEnterSlideTransition {
@@ -560,6 +586,7 @@ fun NavGraphBuilder.addSharedTabs(
},
navigateToPreview = navigateToSinglePreview,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToAlbum = navigateToAlbum,
navigateToUserInvitation = navigateToUserInvitation,
)
}
@@ -30,16 +30,21 @@ import me.proton.android.drive.ui.viewmodel.AlbumOptionsViewModel
import me.proton.android.drive.ui.viewmodel.AlbumViewModel
import me.proton.android.drive.ui.viewmodel.ComputerOptionsViewModel
import me.proton.android.drive.ui.viewmodel.ConfirmDeleteAlbumDialogViewModel
import me.proton.android.drive.ui.viewmodel.ConfirmLeaveAlbumDialogViewModel
import me.proton.android.drive.ui.viewmodel.ConfirmStopAllSharingDialogViewModel
import me.proton.android.drive.ui.viewmodel.CreateNewAlbumViewModel
import me.proton.android.drive.ui.viewmodel.FileOrFolderOptionsViewModel
import me.proton.android.drive.ui.viewmodel.MoveToFolderViewModel
import me.proton.android.drive.ui.viewmodel.MultipleFileOrFolderOptionsViewModel
import me.proton.android.drive.ui.viewmodel.ParentFolderOptionsViewModel
import me.proton.android.drive.ui.viewmodel.PhotosPickerAndSelectionViewModel
import me.proton.android.drive.ui.viewmodel.PickerPhotosAndAlbumsViewModel
import me.proton.android.drive.ui.viewmodel.PickerPhotosViewModel
import me.proton.android.drive.ui.viewmodel.ShareInvitationOptionsViewModel
import me.proton.android.drive.ui.viewmodel.ShareMemberOptionsViewModel
import me.proton.android.drive.ui.viewmodel.SubscriptionPromoViewModel
import me.proton.android.drive.ui.viewmodel.ShareMultiplePhotosOptionsViewModel
import me.proton.android.drive.ui.viewmodel.UploadToViewModel
import me.proton.android.drive.ui.viewmodel.UserInvitationViewModel
import me.proton.android.drive.ui.viewmodel.WhatsNewViewModel
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
@@ -53,6 +58,7 @@ import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.entity.PhotoTag
import me.proton.core.drive.link.domain.extension.userId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.notification.presentation.viewmodel.NotificationPermissionRationaleViewModel
@@ -114,23 +120,33 @@ sealed class Screen(val route: String) {
}
data object FileOrFolderOptions : Screen(
"options/link/{userId}/shares/{shareId}/linkId={linkId}?albumShareId={albumShareId}&albumId={albumId}"
"options/link/{userId}/shares/{shareId}/linkId={linkId}?albumShareId={albumShareId}&albumId={albumId}&selectionId={selectionId}"
) {
operator fun invoke(
userId: UserId,
linkId: LinkId,
albumId: AlbumId? = null
albumId: AlbumId? = null,
selectionId: SelectionId? = null,
) = buildString {
append("options/link/${userId.id}/shares/${linkId.shareId.id}/linkId=${linkId.id}")
if (albumId != null) {
append("?albumShareId=${albumId.shareId.id}&albumId=${albumId.id}")
}
if (selectionId != null) {
if (albumId != null) {
append("&")
} else {
append("?")
}
append("selectionId=${selectionId.id}")
}
}
const val SHARE_ID = FileOrFolderOptionsViewModel.KEY_SHARE_ID
const val LINK_ID = FileOrFolderOptionsViewModel.KEY_LINK_ID
const val ALBUM_ID = FileOrFolderOptionsViewModel.KEY_ALBUM_ID
const val KEY_ALBUM_SHARE_ID = FileOrFolderOptionsViewModel.KEY_ALBUM_SHARE_ID
const val ALBUM_SHARE_ID = FileOrFolderOptionsViewModel.KEY_ALBUM_SHARE_ID
const val SELECTION_ID = FileOrFolderOptionsViewModel.KEY_SELECTION_ID
}
data object MultipleFileOrFolderOptions : Screen(
@@ -196,6 +212,18 @@ sealed class Screen(val route: String) {
const val ALBUM_ID = AlbumOptionsViewModel.KEY_ALBUM_ID
}
data object ShareMultiplePhotosOptions : Screen(
"options/multiple/sharePhotos/{userId}/selectionId={selectionId}"
) {
operator fun invoke(
userId: UserId,
selectionId: SelectionId,
) = "options/multiple/sharePhotos/${userId.id}/selectionId=${selectionId.id}"
const val USER_ID = Screen.USER_ID
const val SELECTION_ID = ShareMultiplePhotosOptionsViewModel.SELECTION_ID
}
data object Info : Screen("info/{userId}/shares/{shareId}/files?linkId={linkId}") {
operator fun invoke(
userId: UserId,
@@ -351,6 +379,10 @@ sealed class Screen(val route: String) {
operator fun invoke(userId: UserId) = "home/${userId.id}/photos/upsell"
}
data object ImportantUpdates : Screen("home/{userId}/photos/importantUpdates") {
operator fun invoke(userId: UserId) = "home/${userId.id}/photos/importantUpdates"
}
const val USER_ID = Screen.USER_ID
const val SHARE_ID = "shareId"
}
@@ -358,11 +390,15 @@ sealed class Screen(val route: String) {
override fun invoke(userId: UserId) = "home/${userId.id}/photos"
data object CreateNewAlbum : Screen("home/{userId}/photos/new_album") {
operator fun invoke(userId: UserId) = "home/${userId.id}/photos/new_album"
data object CreateNewAlbum : Screen("home/{userId}/photos/new_album?sharedAlbum={sharedAlbum}") {
operator fun invoke(
userId: UserId,
sharedAlbum: Boolean = false,
) = "home/${userId.id}/photos/new_album?sharedAlbum=${sharedAlbum}"
}
const val USER_ID = Screen.USER_ID
const val SHARED_ALBUM = CreateNewAlbumViewModel.SHARED_ALBUM
}
data object Album : Screen("photos/{userId}/shares/{shareId}/albums/{albumId}") {
@@ -371,12 +407,20 @@ sealed class Screen(val route: String) {
object Dialogs {
data object ConfirmDeleteAlbum : Screen("delete/photos/{userId}/shares/{shareId}/albums/{albumId}/confirm") {
operator fun invoke(albumId: LinkId) =
operator fun invoke(albumId: AlbumId) =
"delete/photos/${albumId.userId.id}/shares/${albumId.shareId.id}/albums/${albumId.id}/confirm"
const val SHARE_ID = ConfirmDeleteAlbumDialogViewModel.SHARE_ID
const val ALBUM_ID = ConfirmDeleteAlbumDialogViewModel.ALBUM_ID
}
data object ConfirmLeaveAlbum : Screen("leave/photos/{userId}/shares/{shareId}/albums/{albumId}/confirm") {
operator fun invoke(albumId: AlbumId) =
"leave/photos/${albumId.userId.id}/shares/${albumId.shareId.id}/albums/${albumId.id}/confirm"
const val SHARE_ID = ConfirmLeaveAlbumDialogViewModel.SHARE_ID
const val ALBUM_ID = ConfirmLeaveAlbumDialogViewModel.ALBUM_ID
}
}
const val USER_ID = Screen.USER_ID
@@ -385,7 +429,7 @@ sealed class Screen(val route: String) {
}
object Picker {
data object PhotosAndAlbums : Screen("picker/{userId}/photos/destination?inPickerMode={inPickerMode}&destinationShareId={destinationShareId}&destinationAlbumId={destinationAlbumId}") {
data object Photos : Screen("picker/{userId}/photos/destination?inPickerMode={inPickerMode}&destinationShareId={destinationShareId}&destinationAlbumId={destinationAlbumId}") {
operator fun invoke(userId: UserId) = "picker/${userId.id}/photos/destination?inPickerMode=true"
operator fun invoke(destinationAlbumId: AlbumId) =
@@ -403,8 +447,8 @@ sealed class Screen(val route: String) {
const val IN_PICKER_MODE = PhotosPickerAndSelectionViewModel.IN_PICKER_MODE
const val SHARE_ID = AlbumViewModel.SHARE_ID
const val ALBUM_ID = AlbumViewModel.ALBUM_ID
const val DESTINATION_SHARE_ID = PickerPhotosAndAlbumsViewModel.DESTINATION_SHARE_ID
const val DESTINATION_ALBUM_ID = PickerPhotosAndAlbumsViewModel.DESTINATION_ALBUM_ID
const val DESTINATION_SHARE_ID = PickerPhotosViewModel.DESTINATION_SHARE_ID
const val DESTINATION_ALBUM_ID = PickerPhotosViewModel.DESTINATION_ALBUM_ID
}
data object BackupIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}") {
@@ -462,12 +506,18 @@ sealed class Screen(val route: String) {
filesBrowsableBuildRoute("offline", userId, folderId, folderName)
}
data object PagerPreview : Screen("pager/{pagerType}/preview/{userId}/shares/{shareId}/files/{fileId}?albumShareId={albumShareId}&albumId={albumId}") {
data object PagerPreview : Screen("pager/{pagerType}/preview/{userId}/shares/{shareId}/files/{fileId}?photoTag={photoTag}&albumShareId={albumShareId}&albumId={albumId}") {
operator fun invoke(
pagerType: PagerType,
userId: UserId,
fileId: FileId,
) = "pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}"
operator fun invoke(
pagerType: PagerType,
userId: UserId,
fileId: FileId,
photoTag: PhotoTag,
) = "pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}?photoTag=${photoTag.value}"
operator fun invoke(
pagerType: PagerType,
userId: UserId,
@@ -480,6 +530,7 @@ sealed class Screen(val route: String) {
const val FILE_ID = "fileId"
const val ALBUM_SHARE_ID = "albumShareId"
const val ALBUM_ID = "albumId"
const val PHOTO_TAG = "photoTag"
const val PAGER_TYPE = "pagerType"
}
@@ -646,12 +697,14 @@ sealed class Screen(val route: String) {
const val MEMBER_ID = ShareMemberOptionsViewModel.KEY_MEMBER_ID
}
data object UserInvitation : Screen("shareViaInvitations/{userId}/invitations") {
data object UserInvitation : Screen("shareViaInvitations/{userId}/invitations?albumsOnly={albumsOnly}") {
operator fun invoke(
userId: UserId,
) = "shareViaInvitations/${userId.id}/invitations"
albumsOnly: Boolean = false,
) = "shareViaInvitations/${userId.id}/invitations?albumsOnly=$albumsOnly"
const val USER_ID = Screen.USER_ID
const val ALBUMS_ONLY = UserInvitationViewModel.ALBUMS_ONLY
}
data object ShareLinkPermissions : Screen("shareViaLink/{userId}/shares/{shareId}/linkId/{linkId}/permissions") {
@@ -788,6 +841,12 @@ sealed class Screen(val route: String) {
}
}
}
data object Promo {
data object Subscription : Screen("home/{userId}/promo/subscription?key={key}"){
operator fun invoke(userId: UserId, key: String) = "home/${userId.id}/promo/subscription?key=$key"
const val PROMO_KEY = SubscriptionPromoViewModel.PROMO_KEY
}
}
}
fun NavHostController.navigate(screen: Screen, builder: NavOptionsBuilder.() -> Unit) {
@@ -22,6 +22,7 @@ import me.proton.android.drive.ui.common.FolderEntry
import me.proton.android.drive.ui.common.folderEntry
import me.proton.core.compose.component.bottomsheet.RunAction
import me.proton.core.drive.base.domain.entity.Permissions
import me.proton.core.drive.base.domain.extension.isViewerOrEditorOnly
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.hasShareLink
import me.proton.core.drive.drivelink.domain.extension.isPhoto
@@ -34,6 +35,7 @@ import me.proton.core.drive.files.presentation.entry.DownloadEntry
import me.proton.core.drive.files.presentation.entry.DownloadFileEntry
import me.proton.core.drive.files.presentation.entry.FileInfoEntry
import me.proton.core.drive.files.presentation.entry.FileOptionEntry
import me.proton.core.drive.files.presentation.entry.LeaveAlbumEntry
import me.proton.core.drive.files.presentation.entry.ManageAccessEntry
import me.proton.core.drive.files.presentation.entry.MoveEntry
import me.proton.core.drive.files.presentation.entry.MoveFileEntry
@@ -42,9 +44,12 @@ import me.proton.core.drive.files.presentation.entry.RemoveFromAlbumEntry
import me.proton.core.drive.files.presentation.entry.RemoveFromAlbumFileEntry
import me.proton.core.drive.files.presentation.entry.RemoveMeEntry
import me.proton.core.drive.files.presentation.entry.RenameFileEntry
import me.proton.core.drive.files.presentation.entry.SaveSharePhotoEntry
import me.proton.core.drive.files.presentation.entry.SendFileEntry
import me.proton.core.drive.files.presentation.entry.SetAsAlbumCoverEntry
import me.proton.core.drive.files.presentation.entry.ShareMultiplePhotosEntry
import me.proton.core.drive.files.presentation.entry.ShareViaInvitationsEntry
import me.proton.core.drive.files.presentation.entry.ToggleFavoriteFileEntry
import me.proton.core.drive.files.presentation.entry.ToggleOfflineEntry
import me.proton.core.drive.files.presentation.entry.ToggleTrashEntry
import me.proton.core.drive.files.presentation.entry.TrashEntry
@@ -54,6 +59,7 @@ import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.extension.isProtonCloudFile
import me.proton.core.drive.link.domain.extension.isSharedUrlExpired
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
@@ -84,7 +90,7 @@ sealed class Option(
data object CreateFolder : Option(
ApplicableQuantity.Single,
setOf(ApplicableTo.FOLDER),
setOf(State.NOT_TRASHED, State.SHARED, State.NOT_SHARED),
setOf(State.NOT_TRASHED) + State.ANY_SHARED,
) {
fun build(
runAction: RunAction,
@@ -143,6 +149,19 @@ sealed class Option(
}
}
data object FavoriteToggle : Option(
ApplicableQuantity.Single,
setOf(ApplicableTo.FILE_PHOTO),
setOf(State.NOT_TRASHED) + State.ANY_SHARED,
) {
fun build(
runAction: RunAction,
toggleFavorite: suspend (DriveLink.File) -> Unit,
) = ToggleFavoriteFileEntry { driveLink ->
runAction { toggleFavorite(driveLink) }
} as FileOptionEntry<DriveLink>
}
data object Info : Option(
ApplicableQuantity.Single,
setOf(ApplicableTo.FOLDER) + ApplicableTo.ANY_FILE,
@@ -240,6 +259,22 @@ sealed class Option(
}
}
data object SaveSharePhoto : Option(
ApplicableQuantity.Single,
setOf(
ApplicableTo.FILE_PHOTO,
),
setOf(State.NOT_TRASHED) + State.ANY_SHARED,
) {
fun build(
runAction: RunAction,
saveSharePhoto: (link: DriveLink.File) -> Unit,
) = SaveSharePhotoEntry { driveLink ->
runAction { saveSharePhoto(driveLink) }
} as FileOptionEntry<DriveLink>
}
data object SendFile : Option(
ApplicableQuantity.Single,
ApplicableTo.ANY_DOWNLOADABLE_FILE,
@@ -336,6 +371,19 @@ sealed class Option(
}
}
data object ShareMultiplePhotos : Option(
ApplicableQuantity.All,
setOf(ApplicableTo.FILE_PHOTO),
setOf(State.NOT_TRASHED) + State.ANY_SHARED,
) {
fun build(
runAction: RunAction,
navigateToShareMultiplePhotosOptions: () -> Unit,
) = ShareMultiplePhotosEntry {
runAction { navigateToShareMultiplePhotosOptions() }
}
}
data object CreateAlbum : Option(
ApplicableQuantity.All,
setOf(ApplicableTo.FILE_PHOTO),
@@ -385,6 +433,21 @@ sealed class Option(
}
}
}
data object LeaveAlbum : Option(
ApplicableQuantity.Single,
setOf(ApplicableTo.ALBUM),
setOf(State.NOT_TRASHED) + State.ANY_SHARED,
) {
fun build(
runAction: RunAction,
leaveAlbum: suspend (DriveLink.Album) -> Unit,
) = LeaveAlbumEntry { album ->
runAction {
leaveAlbum(album)
}
}
}
}
sealed class ApplicableQuantity(open val quantity: Long) {
@@ -460,6 +523,7 @@ fun Iterable<Option>.filterRoot(driveLink: DriveLink, featureFlag: FeatureFlag)
fun Iterable<Option>.filterShareMember(isMember: Boolean) = filter { option ->
if (!isMember) {
when (option) {
Option.LeaveAlbum -> false
Option.RemoveMe -> false
else -> true
}
@@ -479,16 +543,20 @@ fun Iterable<Option>.filterPermissions(
Option.DeletePermanently -> permissions.canWrite
Option.Download -> permissions.canRead
Option.Info -> permissions.canRead
Option.LeaveAlbum -> permissions.canRead
Option.ManageAccess -> permissions.isAdmin
Option.Move -> permissions.canWrite
Option.OfflineToggle -> permissions.canRead
Option.FavoriteToggle -> permissions.canWrite
Option.OpenInBrowser -> permissions.canRead
Option.Rename -> permissions.canWrite
Option.RemoveFromAlbum -> permissions.isAdmin
Option.RemoveFromAlbum -> permissions.canWrite
Option.RemoveMe -> permissions.canRead
Option.SaveSharePhoto -> permissions.isViewerOrEditorOnly
Option.SendFile -> permissions.canRead
Option.SetAsAlbumCover -> permissions.isAdmin
Option.ShareViaInvitations -> permissions.isAdmin
Option.ShareMultiplePhotos -> permissions.isAdmin
Option.TakeAPhoto -> permissions.canWrite
Option.Trash -> permissions.isAdmin
Option.UploadFile -> permissions.canWrite
@@ -504,15 +572,40 @@ fun Iterable<Option>.filterProtonDocs(killSwitch: FeatureFlag) = filter { option
}
fun Iterable<Option>.filterAlbums(
featureFlagOn: Boolean,
isEnabled: Boolean,
killSwitch: FeatureFlag,
albumId: AlbumId? = null,
) = filter { option ->
val featureEnabled = featureFlagOn && killSwitch.off
val featureEnabled = isEnabled && killSwitch.off
when (option) {
Option.CreateAlbum -> featureEnabled
Option.CreateAlbum -> featureEnabled && albumId == null
Option.ShareMultiplePhotos -> featureEnabled && albumId == null
Option.RemoveFromAlbum -> featureEnabled && albumId != null
Option.SetAsAlbumCover -> featureEnabled && albumId != null
Option.Trash -> albumId == null
else -> true
}
}
fun Iterable<Option>.filterPhotoFavorite(
isEnabled: Boolean,
killSwitch: FeatureFlag,
) = filter { option ->
val featureEnabled = isEnabled && killSwitch.off
when (option) {
Option.FavoriteToggle -> featureEnabled
else -> true
}
}
fun Iterable<Option>.filterShare(
shareTempDisabledOn: Boolean,
albumId: AlbumId? = null,
) = filter { option ->
when (option) {
Option.ShareViaInvitations -> albumId == null && !shareTempDisabledOn
Option.ManageAccess -> albumId == null && !shareTempDisabledOn
Option.ShareMultiplePhotos -> !shareTempDisabledOn
else -> true
}
}
@@ -41,10 +41,12 @@ import me.proton.core.drive.link.selection.domain.entity.SelectionId
@Composable
fun AlbumScreen(
navigateToAlbumOptions: (AlbumId) -> Unit,
navigateToPhotosOptions: (FileId, AlbumId?) -> Unit,
navigateToPhotosOptions: (FileId, AlbumId?, SelectionId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId, AlbumId?) -> Unit,
navigateToPreview: (FileId, AlbumId) -> Unit,
navigateToPicker: (AlbumId) -> Unit,
navigateToShareViaInvitations: (AlbumId) -> Unit,
navigateToManageAccess: (AlbumId) -> Unit,
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -58,6 +60,8 @@ fun AlbumScreen(
navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions,
navigateToPreview = navigateToPreview,
navigateToPicker = navigateToPicker,
navigateToShareViaInvitations = navigateToShareViaInvitations,
navigateToManageAccess = navigateToManageAccess,
navigateBack = navigateBack,
lifecycle = lifecycle,
)
@@ -46,6 +46,7 @@ import me.proton.android.drive.photos.presentation.state.PhotosItem
import me.proton.android.drive.photos.presentation.viewevent.CreateNewAlbumViewEvent
import me.proton.android.drive.photos.presentation.viewstate.CreateNewAlbumViewState
import me.proton.android.drive.ui.viewmodel.CreateNewAlbumViewModel
import me.proton.core.compose.activity.KeepScreenOn
import me.proton.core.compose.component.ProtonTextButton
import me.proton.core.compose.component.protonTextButtonColors
import me.proton.core.compose.theme.ProtonTheme
@@ -95,6 +96,9 @@ fun CreateNewAlbumScreen(
driveLinksFlow: Flow<Map<LinkId, DriveLink>>,
modifier: Modifier = Modifier,
) {
if (viewState.isCreationInProgress) {
KeepScreenOn()
}
Column(
modifier = modifier,
) {
@@ -136,7 +140,7 @@ fun TopAppBar(
)
) {
Text(
text = stringResource(id = I18N.string.common_done_action),
text = stringResource(id = viewState.doneButtonLabelResId),
style = ProtonTheme.typography.headlineSmallNorm,
color = ProtonTheme.colors.interactionNorm(viewState.isDoneEnabled),
)
@@ -156,6 +160,7 @@ private fun CreateNewAlbumScreenPreview() {
isAlbumNameEnabled = true,
isAddEnabled = true,
isRemoveEnabled = true,
doneButtonLabelResId = I18N.string.common_done_action,
name = emptyFlow(),
hint = stringResource(I18N.string.albums_new_album_name_hint),
),
@@ -67,10 +67,10 @@ import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.entity.PhotoTag
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawer
import me.proton.core.drive.sorting.domain.entity.Sorting
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.drive.android.settings.domain.entity.WhatsNewKey
@Composable
@@ -88,9 +88,10 @@ fun HomeScreen(
navigateToTrash: () -> Unit,
navigateToOffline: () -> Unit,
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
navigateToPreviewWithTag: (fileId: FileId, pagerType: PagerType, photoTag: PhotoTag) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToSettings: () -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToFileOrFolderOptions: (LinkId, SelectionId?) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
navigateToSubscription: () -> Unit,
@@ -104,9 +105,11 @@ fun HomeScreen(
navigateToWhatsNew: (WhatsNewKey) -> Unit,
navigateToRatingBooster: () -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToUserInvitation: () -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToSubscriptionPromo: (String) -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
modifier: Modifier = Modifier,
) {
setLocalSnackbarPadding(BottomNavigationHeight)
@@ -140,6 +143,7 @@ fun HomeScreen(
navigateToOnboarding = navigateToOnboarding,
navigateToWhatsNew = navigateToWhatsNew,
navigateToRatingBooster = navigateToRatingBooster,
navigateToSubscriptionPromo = navigateToSubscriptionPromo,
)
}
viewState?.let { currentViewState ->
@@ -149,6 +153,7 @@ fun HomeScreen(
startDestination = startDestination,
onDrawerStateChanged = onDrawerStateChanged,
navigateToPreview = navigateToPreview,
navigateToPreviewWithTag = navigateToPreviewWithTag,
navigateToSorting = navigateToSorting,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToMultipleFileOrFolderOptions = navigateToMultipleFileOrFolderOptions,
@@ -163,6 +168,7 @@ fun HomeScreen(
navigateToUserInvitation = navigateToUserInvitation,
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
navigateToAlbum = navigateToAlbum,
navigateToPhotosImportantUpdates = navigateToPhotosImportantUpdates,
arguments = arguments,
viewState = currentViewState,
viewEvent = viewEvent,
@@ -186,8 +192,9 @@ internal fun Home(
startDestination: String,
onDrawerStateChanged: (Boolean) -> Unit,
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
navigateToPreviewWithTag: (fileId: FileId, pagerType: PagerType, photoTag: PhotoTag) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToFileOrFolderOptions: (LinkId, SelectionId?) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
arguments: Bundle,
@@ -201,9 +208,10 @@ internal fun Home(
navigateToBackupSettings: () -> Unit,
navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToUserInvitation: () -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
) {
val homeScaffoldState = rememberHomeScaffoldState()
val isDrawerOpen = with(homeScaffoldState.scaffoldState.drawerState) {
@@ -288,6 +296,7 @@ internal fun Home(
startDestination,
homeScaffoldState,
navigateToPreview,
navigateToPreviewWithTag,
navigateToSorting,
navigateToFileOrFolderOptions,
navigateToMultipleFileOrFolderOptions,
@@ -302,6 +311,7 @@ internal fun Home(
navigateToUserInvitation,
navigateToCreateNewAlbum,
navigateToAlbum,
navigateToPhotosImportantUpdates,
)
}
}
@@ -33,6 +33,7 @@ import me.proton.android.drive.ui.viewmodel.OfflineViewModel
import me.proton.core.compose.flow.rememberFlowWithLifecycle
import me.proton.core.drive.files.presentation.component.DriveLinksFlow
import me.proton.core.drive.files.presentation.component.Files
import me.proton.core.drive.link.domain.entity.AlbumId
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
@@ -43,6 +44,7 @@ import me.proton.core.drive.sorting.domain.entity.Sorting
fun OfflineScreen(
navigateToFiles: (folderId: FolderId, folderName: String?) -> Unit,
navigateToPreview: (pagerType: PagerType, fileId: FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToSortingDialog: (Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateBack: () -> Unit,
@@ -56,6 +58,7 @@ fun OfflineScreen(
viewModel.viewEvent(
navigateToFiles = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToAlbum = navigateToAlbum,
navigateToSortingDialog = navigateToSortingDialog,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateBack = navigateBack,
@@ -20,9 +20,14 @@ package me.proton.android.drive.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
@@ -41,6 +46,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@@ -88,14 +94,16 @@ import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.base.presentation.component.Title
import me.proton.core.drive.drivelink.shared.presentation.component.UserInvitationBanner
import me.proton.core.drive.link.domain.entity.PhotoTag
import me.proton.core.drive.i18n.R as I18N
@Composable
fun PhotosAndAlbumsScreen(
homeScaffoldState: HomeScaffoldState,
navigateToPhotosPermissionRationale: () -> Unit,
navigateToPhotosPreview: (fileId: FileId) -> Unit,
navigateToPhotosOptions: (fileId: FileId) -> Unit,
navigateToPhotosPreview: (fileId: FileId, photoTag: PhotoTag?) -> Unit,
navigateToPhotosOptions: (fileId: FileId, SelectionId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
@@ -104,6 +112,8 @@ fun PhotosAndAlbumsScreen(
navigateToNotificationPermissionRationale: () -> Unit,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<PhotosAndAlbumsViewModel>()
@@ -135,6 +145,7 @@ fun PhotosAndAlbumsScreen(
navigateToPhotosUpsell = navigateToPhotosUpsell,
navigateToBackupSettings = navigateToBackupSettings,
navigateToNotificationPermissionRationale = navigateToNotificationPermissionRationale,
navigateToPhotosImportantUpdates = navigateToPhotosImportantUpdates,
defaultTitle = defaultTitle,
)
@@ -142,6 +153,7 @@ fun PhotosAndAlbumsScreen(
homeScaffoldState = homeScaffoldState,
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
navigateToAlbum = navigateToAlbum,
navigateToUserInvitation = navigateToUserInvitation,
defaultTitle = defaultTitle,
)
}
@@ -153,6 +165,7 @@ fun AlbumsTab(
homeScaffoldState: HomeScaffoldState,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
modifier: Modifier = Modifier,
defaultTitle: @Composable (Modifier) -> Unit,
) {
@@ -165,8 +178,12 @@ fun AlbumsTab(
viewModel.viewEvent(
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
navigateToAlbum = navigateToAlbum,
navigateToUserInvitation = navigateToUserInvitation,
)
}
val userInvitationViewState by viewModel.userInvitationBannerViewState.collectAsStateWithLifecycle(
initialValue = null
)
viewModel.HandleHomeEffect(homeScaffoldState)
@@ -190,7 +207,25 @@ fun AlbumsTab(
viewEvent = viewEvent,
items = viewModel.albumItems,
modifier = modifier.fillMaxSize(),
)
) {
Box(Modifier.defaultMinSize(minHeight = 1.dp)) {
// minHeight: always draw to have the header visible in the lazy list
AnimatedVisibility(
visible = userInvitationViewState != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
userInvitationViewState?.let { viewState ->
UserInvitationBanner(
description = viewState.description,
showSectionHeader = false,
onClick = viewEvent.onUserInvitation,
modifier = Modifier.padding(bottom = ProtonDimens.DefaultSpacing)
)
}
}
}
}
}
@Composable
@@ -198,14 +233,15 @@ fun PhotosTab(
homeScaffoldState: HomeScaffoldState,
modifier: Modifier = Modifier,
navigateToPhotosPermissionRationale: () -> Unit,
navigateToPhotosPreview: (fileId: FileId) -> Unit,
navigateToPhotosOptions: (fileId: FileId) -> Unit,
navigateToPhotosPreview: (fileId: FileId, photoTag: PhotoTag?) -> Unit,
navigateToPhotosOptions: (fileId: FileId, SelectionId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
defaultTitle: @Composable (Modifier) -> Unit,
) {
val viewModel = hiltViewModel<PhotosViewModel>()
@@ -221,6 +257,7 @@ fun PhotosTab(
navigateToPhotosIssues = navigateToPhotosIssues,
navigateToPhotosUpsell = navigateToPhotosUpsell,
navigateToBackupSettings = navigateToBackupSettings,
navigateToPhotosImportantUpdates = navigateToPhotosImportantUpdates,
lifecycle = lifecycle,
)
}
@@ -236,6 +273,9 @@ fun PhotosTab(
PhotosEffect.ShowUpsell -> launch(Dispatchers.Main) {
viewEvent.onShowUpsell()
}
PhotosEffect.ShowImportantUpdates -> launch(Dispatchers.Main) {
viewEvent.onShowImportantUpdates()
}
}
}.launchIn(this)
}
@@ -334,23 +374,29 @@ fun Tabs(
verticalAlignment = Alignment.CenterVertically,
) {
TabItem(
modifier = Modifier.padding(end = ProtonDimens.SmallSpacing),
modifier = Modifier
.testTag(PhotosAndAlbumsScreenTestTag.photosTitleTab)
.padding(end = ProtonDimens.SmallSpacing),
tab = Tab.PHOTOS,
text = I18N.string.photos_title,
viewState = viewState,
viewEvent = viewEvent,
)
VerticalDivider(
modifier = Modifier.padding(vertical = ProtonDimens.DefaultSpacing),
color = ProtonTheme.colors.separatorNorm,
)
TabItem(
modifier = Modifier.padding(start = ProtonDimens.SmallSpacing),
tab = Tab.ALBUMS,
text = I18N.string.albums_title,
viewState = viewState,
viewEvent = viewEvent,
)
if (viewState.isAlbumsTabVisible) {
VerticalDivider(
modifier = Modifier.padding(vertical = ProtonDimens.DefaultSpacing),
color = ProtonTheme.colors.separatorNorm,
)
TabItem(
modifier = Modifier
.testTag(PhotosAndAlbumsScreenTestTag.albumsTitleTab)
.padding(start = ProtonDimens.SmallSpacing),
tab = Tab.ALBUMS,
text = I18N.string.albums_title,
viewState = viewState,
viewEvent = viewEvent,
)
}
}
}
@@ -403,8 +449,16 @@ fun TabsPreview() {
ProtonTheme {
Tabs(
modifier = Modifier.height(ProtonDimens.DefaultButtonMinHeight),
viewState = PhotosAndAlbumsViewState(selectedTab = Tab.PHOTOS),
viewState = PhotosAndAlbumsViewState(
selectedTab = Tab.PHOTOS,
isAlbumsTabVisible = true,
),
viewEvent = object : PhotosAndAlbumsViewEvent {}
)
}
}
object PhotosAndAlbumsScreenTestTag {
const val photosTitleTab = "photos title tab"
const val albumsTitleTab = "albums title tab"
}
@@ -59,6 +59,7 @@ import me.proton.core.drive.base.presentation.effect.ListEffect
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.entity.PhotoTag
import me.proton.core.drive.link.selection.domain.entity.SelectionId
@Composable
@@ -66,14 +67,15 @@ fun PhotosScreen(
homeScaffoldState: HomeScaffoldState,
modifier: Modifier = Modifier,
navigateToPhotosPermissionRationale: () -> Unit,
navigateToPhotosPreview: (fileId: FileId) -> Unit,
navigateToPhotosOptions: (fileId: FileId) -> Unit,
navigateToPhotosPreview: (fileId: FileId, photoTag: PhotoTag?) -> Unit,
navigateToPhotosOptions: (fileId: FileId, SelectionId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
) {
val viewModel = hiltViewModel<PhotosViewModel>()
val viewState by rememberFlowWithLifecycle(flow = viewModel.viewState)
@@ -88,6 +90,7 @@ fun PhotosScreen(
navigateToPhotosIssues = navigateToPhotosIssues,
navigateToPhotosUpsell = navigateToPhotosUpsell,
navigateToBackupSettings = navigateToBackupSettings,
navigateToPhotosImportantUpdates = navigateToPhotosImportantUpdates,
lifecycle = lifecycle,
)
}
@@ -100,6 +103,9 @@ fun PhotosScreen(
PhotosEffect.ShowUpsell -> launch(Dispatchers.Main) {
viewEvent.onShowUpsell()
}
PhotosEffect.ShowImportantUpdates -> launch(Dispatchers.Main) {
viewEvent.onShowImportantUpdates()
}
}
}.launchIn(this)
}
@@ -18,38 +18,22 @@
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.component.PromoContainer
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
import me.proton.core.drive.base.presentation.R as BasePresentation
@Composable
fun PhotosUpsellScreen(
@@ -70,70 +54,43 @@ fun PhotosUpsellScreen(
}
}
PhotosUpsell(
viewEvent = viewEvent,
modifier = modifier
.systemBarsPadding(),
modifier = modifier,
onMoreStorage = { viewEvent.onMoreStorage() },
onCancel = { viewEvent.onCancel() },
)
}
@Composable
fun PhotosUpsell(
viewEvent: PhotosUpsellViewEvent,
modifier: Modifier = Modifier,
private fun PhotosUpsell(
modifier: Modifier,
onMoreStorage: () -> Unit,
onCancel: () -> Unit
) {
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)
)
}
}
PromoContainer(
modifier = modifier.systemBarsPadding(),
titleResId = I18N.string.photos_upsell_title,
descriptionResId = I18N.string.photos_upsell_description,
imageResId = getThemeDrawableId(
light = BasePresentation.drawable.img_upsell_drive_light,
dark = BasePresentation.drawable.img_upsell_drive_dark,
dayNight = BasePresentation.drawable.img_upsell_drive_daynight,
),
actionText = stringResource(id = I18N.string.photos_upsell_get_storage_action),
dismissActionText = stringResource(id = I18N.string.photos_upsell_dismiss_action),
onAction = { onMoreStorage() },
onCancel = { onCancel() },
)
}
private val ButtonMinWidth = 300.dp
@Preview
@Preview(widthDp = 600, heightDp = 360)
@Composable
private fun PhotosUpsellPreview() {
ProtonTheme {
PhotosUpsell(
viewEvent = object : PhotosUpsellViewEvent {},
modifier = Modifier.fillMaxSize(),
onMoreStorage = {},
onCancel = {},
)
}
}
@@ -29,7 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.android.drive.ui.viewmodel.PickerPhotosAndAlbumsViewModel
import me.proton.android.drive.ui.viewmodel.PickerPhotosViewModel
import me.proton.core.drive.base.presentation.extension.shadow
@Composable
@@ -38,7 +38,7 @@ fun PickerAlbumScreen(
onAddToAlbumDone: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<PickerPhotosAndAlbumsViewModel>()
val viewModel = hiltViewModel<PickerPhotosViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(initialValue = null)
val lifecycle = LocalLifecycleOwner.current
val viewEvent = remember(lifecycle) {
@@ -77,10 +77,12 @@ fun PickerAlbumScreen(
) {
AlbumScreen(
navigateToAlbumOptions = {},
navigateToPhotosOptions = { _, _ -> },
navigateToPhotosOptions = { _, _, _ -> },
navigateToMultiplePhotosOptions = { _, _ -> },
navigateToPreview = { _, _ -> },
navigateToPicker = { _ -> },
navigateToShareViaInvitations = {},
navigateToManageAccess = {},
navigateBack = onBack,
)
BottomActions(
@@ -0,0 +1,240 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Spacer
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.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.android.drive.photos.presentation.component.AddToAlbumButton
import me.proton.android.drive.ui.viewmodel.PickerPhotosViewModel
import me.proton.android.drive.ui.viewstate.rememberHomeScaffoldState
import me.proton.core.compose.activity.KeepScreenOn
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.drive.base.presentation.extension.shadow
import me.proton.core.drive.base.presentation.component.TopAppBar as BaseTopAppBar
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
@Composable
fun PickerPhotosScreen(
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<PickerPhotosViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(initialValue = null)
val lifecycle = LocalLifecycleOwner.current
val viewEvent = remember(lifecycle) {
viewModel.viewEvent(
navigateBack = navigateBack,
onAddToAlbumDone = navigateBack,
)
}
viewState?.let { viewState ->
PickerPhotosScreen(
addToAlbumTitle = viewState.addToAlbumButtonTitle,
isAddToAlbumButtonEnabled = viewState.isAddToAlbumButtonEnabled,
isAddToAlbumInProgress = viewState.isAddingInProgress,
isResetButtonEnabled = viewState.isResetButtonEnabled,
onTopAppBarNavigationIcon = viewEvent.onBackPressed,
onReset = viewEvent.onReset,
onAddToAlbum = viewEvent.onAddToAlbum,
modifier = modifier.navigationBarsPadding(),
)
}
}
@Composable
fun PickerPhotosScreen(
addToAlbumTitle: String,
isAddToAlbumButtonEnabled: Boolean,
isAddToAlbumInProgress: Boolean,
isResetButtonEnabled: Boolean,
onTopAppBarNavigationIcon: () -> Unit,
onReset: () -> Unit,
onAddToAlbum: () -> Unit,
modifier: Modifier = Modifier,
) {
PickerPhotos(
title = { titleModifier ->
Text(
modifier = titleModifier,
text = stringResource(I18N.string.photos_title),
)
},
onTopAppBarNavigationIcon = onTopAppBarNavigationIcon,
onReset = onReset,
onAddToAlbum = onAddToAlbum,
addToAlbumTitle = addToAlbumTitle,
isAddToAlbumButtonEnabled = isAddToAlbumButtonEnabled,
isAddToAlbumInProgress = isAddToAlbumInProgress,
isResetButtonEnabled = isResetButtonEnabled,
modifier = modifier
.testTag(PickerPhotosScreenTestTag.screen),
)
}
@Composable
fun PickerPhotos(
title: @Composable (Modifier) -> Unit,
addToAlbumTitle: String,
isAddToAlbumButtonEnabled: Boolean,
isAddToAlbumInProgress: Boolean,
isResetButtonEnabled: Boolean,
onTopAppBarNavigationIcon: () -> Unit,
onReset: () -> Unit,
onAddToAlbum: () -> Unit,
modifier: Modifier = Modifier,
) {
if (isAddToAlbumInProgress) {
KeepScreenOn()
}
Column(
modifier = modifier.fillMaxSize(),
) {
TopAppBar(
title = title,
onNavigationIcon = onTopAppBarNavigationIcon,
)
Box(
modifier = Modifier.fillMaxSize(),
) {
val homeScaffoldState = rememberHomeScaffoldState().apply {
drawerGesturesEnabled.value = false
bottomNavigationEnabled.value = false
}
PhotosTab(
homeScaffoldState = homeScaffoldState,
navigateToPhotosPermissionRationale = {},
navigateToPhotosPreview = { _, _ -> },
navigateToPhotosOptions = { _, _ -> },
navigateToMultiplePhotosOptions = {},
navigateToSubscription = {},
navigateToPhotosIssues = {},
navigateToPhotosUpsell = {},
navigateToBackupSettings = {},
navigateToNotificationPermissionRationale = {},
navigateToPhotosImportantUpdates = {},
defaultTitle = {},
)
BottomActions(
addToAlbumTitle = addToAlbumTitle,
isAddToAlbumButtonEnabled = isAddToAlbumButtonEnabled,
isAddToAlbumInProgress = isAddToAlbumInProgress,
isResetButtonEnabled = isResetButtonEnabled,
onReset = onReset,
onAddToAlbum = onAddToAlbum,
modifier = Modifier
.align(Alignment.BottomCenter)
.shadow(),
)
}
}
}
@Composable
fun TopAppBar(
title: @Composable (Modifier) -> Unit,
onNavigationIcon: () -> Unit,
modifier: Modifier = Modifier,
) {
BaseTopAppBar(
navigationIcon = painterResource(CorePresentation.drawable.ic_proton_close),
onNavigationIcon = onNavigationIcon,
title = title,
modifier = modifier.statusBarsPadding(),
)
}
@Composable
fun BottomActions(
addToAlbumTitle: String,
isAddToAlbumButtonEnabled: Boolean,
isAddToAlbumInProgress: Boolean,
isResetButtonEnabled: Boolean,
onReset: () -> Unit,
onAddToAlbum: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.padding(all = ProtonDimens.DefaultSpacing)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
IconButton(
modifier = Modifier
.testTag(PickerPhotosScreenTestTag.resetButton)
.border(1.dp, ProtonTheme.colors.separatorNorm, CircleShape)
.background(ProtonTheme.colors.backgroundNorm, CircleShape),
onClick = onReset,
enabled = isResetButtonEnabled,
) {
Icon(
painter = painterResource(id = CorePresentation.drawable.ic_proton_close),
contentDescription = null,
modifier = modifier.padding(4.dp),
)
}
Spacer(modifier = Modifier.size(32.dp))
AddToAlbumButton(
addToAlbumTitle = addToAlbumTitle,
modifier = Modifier
.testTag(PickerPhotosScreenTestTag.addToAlbumButton)
.widthIn(min = 200.dp),
enabled = isAddToAlbumButtonEnabled,
loading = isAddToAlbumInProgress,
onClick = onAddToAlbum,
)
}
}
object PickerPhotosScreenTestTag {
const val screen = "picker photos and albums screen"
const val resetButton = "reset button"
const val addToAlbumButton = "add to album button"
}
@@ -31,6 +31,7 @@ import me.proton.android.drive.ui.viewmodel.SharedByMeViewModel
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
import me.proton.core.compose.flow.rememberFlowWithLifecycle
import me.proton.core.drive.drivelink.shared.presentation.component.Shared
import me.proton.core.drive.link.domain.entity.AlbumId
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
@@ -40,6 +41,7 @@ fun SharedByMeScreen(
homeScaffoldState: HomeScaffoldState,
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
modifier: Modifier = Modifier
) {
@@ -50,7 +52,9 @@ fun SharedByMeScreen(
viewModel.viewEvent(
navigateToFiles = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToAlbum = navigateToAlbum,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToUserInvitation = {},
lifecycle = lifecycle,
)
}
@@ -46,6 +46,7 @@ import me.proton.android.drive.ui.viewstate.SharedTab
import me.proton.android.drive.ui.viewstate.SharedTabsViewState
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.drive.base.presentation.component.ProtonTab
import me.proton.core.drive.link.domain.entity.AlbumId
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
@@ -56,8 +57,9 @@ fun SharedTabsScreen(
homeScaffoldState: HomeScaffoldState,
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToFileOrFolderOptions: (LinkId) -> Unit,
navigateToUserInvitation: () -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<SharedTabsViewModel>()
@@ -82,6 +84,7 @@ fun SharedTabsScreen(
modifier = modifier.fillMaxSize(),
navigateToFiles = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToAlbum = navigateToAlbum,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToUserInvitation = navigateToUserInvitation,
)
@@ -94,8 +97,9 @@ fun SharedTabs(
viewEvent: SharedTabsViewEvent,
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToFileOrFolderOptions: (LinkId) -> Unit,
navigateToUserInvitation: () -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@@ -124,6 +128,7 @@ fun SharedTabs(
homeScaffoldState = homeScaffoldState,
navigateToFiles = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToAlbum = navigateToAlbum,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToUserInvitation = navigateToUserInvitation,
)
@@ -131,6 +136,7 @@ fun SharedTabs(
homeScaffoldState = homeScaffoldState,
navigateToFiles = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToAlbum = navigateToAlbum,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
)
}
@@ -18,8 +18,12 @@
package me.proton.android.drive.ui.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -36,6 +40,7 @@ import me.proton.android.drive.ui.viewstate.HomeScaffoldState
import me.proton.core.compose.flow.rememberFlowWithLifecycle
import me.proton.core.drive.drivelink.shared.presentation.component.Shared
import me.proton.core.drive.drivelink.shared.presentation.component.UserInvitationBanner
import me.proton.core.drive.link.domain.entity.AlbumId
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
@@ -45,9 +50,10 @@ fun SharedWithMeScreen(
homeScaffoldState: HomeScaffoldState,
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToUserInvitation: () -> Unit,
modifier: Modifier = Modifier
navigateToUserInvitation: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<SharedWithMeViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(initialValue = viewModel.initialViewState)
@@ -59,7 +65,9 @@ fun SharedWithMeScreen(
viewModel.viewEvent(
navigateToFiles = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToAlbum = navigateToAlbum,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToUserInvitation = navigateToUserInvitation,
lifecycle = lifecycle,
)
}
@@ -77,11 +85,17 @@ fun SharedWithMeScreen(
headerContent = {
Box(Modifier.defaultMinSize(minHeight = 1.dp)) {
// minHeight: always draw to have the header visible in the lazy list
userInvitationViewState?.let { viewState ->
UserInvitationBanner(
description = viewState.description,
onClick = navigateToUserInvitation,
)
AnimatedVisibility(
visible = userInvitationViewState != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
userInvitationViewState?.let { viewState ->
UserInvitationBanner(
description = viewState.description,
onClick = viewEvent.onUserInvitation,
)
}
}
}
}
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.screen
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.android.drive.R
import me.proton.android.drive.ui.component.PromoContainer
import me.proton.android.drive.ui.viewmodel.SubscriptionPromoViewModel
import me.proton.android.drive.ui.viewstate.SubscriptionPromoViewState
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.drive.base.presentation.component.RunAction
import me.proton.core.drive.i18n.R as I18N
@Composable
fun SubscriptionPromoScreen(
modifier: Modifier = Modifier,
runAction: RunAction,
navigateToSubscription: () -> Unit,
) {
val viewModel = hiltViewModel<SubscriptionPromoViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(null)
val viewEvent = remember {
viewModel.viewEvent(
runAction = runAction,
navigateToSubscription = navigateToSubscription,
)
}
DisposableEffect(Unit) {
onDispose {
viewEvent.onDismiss()
}
}
viewState?.let { viewState ->
SubscriptionPromo(
modifier = modifier.testTag(SubscriptionPromoTestTag.content),
viewState = viewState,
onGetDriveLite = { viewEvent.onGetSubscription() },
onCancel = { viewEvent.onCancel() },
)
}
}
@Composable
private fun SubscriptionPromo(
modifier: Modifier,
viewState: SubscriptionPromoViewState,
onGetDriveLite: () -> Unit,
onCancel: () -> Unit
) {
PromoContainer(
modifier = modifier.systemBarsPadding(),
title = viewState.title,
description = viewState.description,
image = {
Image(
modifier = Modifier.defaultMinSize(minWidth = 320.dp),
painter = painterResource(id = viewState.image),
contentDescription = null,
contentScale = ContentScale.FillWidth
)
},
actionText = viewState.actionText,
dismissActionText = stringResource(id = I18N.string.photos_upsell_dismiss_action),
onAction = { onGetDriveLite() },
onCancel = { onCancel() },
)
}
@Preview
@Preview(widthDp = 600, heightDp = 360)
@Composable
private fun PhotosUpsellPreview() {
ProtonTheme {
SubscriptionPromo(
modifier = Modifier.fillMaxSize(),
viewState = SubscriptionPromoViewState(
image = R.drawable.img_drive_lite,
title ="More storage for only \$1.00",
description ="When you need a little more storage, but not a lot. Introducing Drive Lite, featuring 20 GB storage for only \$1.00 a month. ",
actionText ="Get Drive Lite",
),
onGetDriveLite = {},
onCancel = {},
)
}
}
object SubscriptionPromoTestTag {
const val content = "subscription promo content"
}
@@ -19,28 +19,46 @@
package me.proton.android.drive.ui.test
import androidx.annotation.RestrictTo
import androidx.datastore.preferences.core.edit
import me.proton.android.drive.usecase.MarkOnboardingAsShown
import me.proton.android.drive.usecase.MarkRatingBoosterAsShown
import me.proton.android.drive.usecase.MarkWhatsNewAsShown
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.data.datastore.GetUserDataStore
import me.proton.core.drive.base.data.datastore.GetUserDataStore.Keys.subscriptionLastUpdate
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.drive.android.settings.data.datastore.AppUiSettingsDataStore
import me.proton.drive.android.settings.domain.entity.WhatsNewKey
import java.util.Date
import javax.inject.Inject
@RestrictTo(RestrictTo.Scope.TESTS)
class UiTestHelper @Inject constructor(
val configurationProvider: ConfigurationProvider,
private val appUiSettingsDataStore: AppUiSettingsDataStore
private val appUiSettingsDataStore: AppUiSettingsDataStore,
private val getUserDataStore: GetUserDataStore,
) {
suspend fun doNotShowOnboardingAfterLogin() {
appUiSettingsDataStore.onboardingShown = 1L
}
suspend fun doNotShowWhatsNewAfterLogin() {
appUiSettingsDataStore.WhatsNew(WhatsNewKey.PUBLIC_SHARING.name).shown = 1L
appUiSettingsDataStore.WhatsNew(WhatsNewKey.ALBUMS.name).shown = 1L
}
suspend fun doNotShowRatingBoosterAfterLogin() {
appUiSettingsDataStore.ratingBooster = 1L
}
suspend fun doNotShowDrivePlusPromoAfterLogin(userId: UserId) {
getUserDataStore(userId).edit { preferences ->
preferences[subscriptionLastUpdate("drive2022")] = 1L
}
}
suspend fun doNotShowDriveLitePromoAfterLogin(userId: UserId) {
getUserDataStore(userId).edit { preferences ->
preferences[subscriptionLastUpdate("drivelite2024")] = 1L
}
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewevent
interface DriveLitePopupViewEvent {
val onGetSubscription: () -> Unit get() = {}
val onCancel: () -> Unit get() = {}
val onDismiss: () -> Unit get() = {}
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewevent
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.LinkId
interface ShareMultiplePhotosOptionsViewEvent {
val onSharedAlbum: (AlbumId) -> Unit
val onScroll: (Set<LinkId>) -> Unit
}
@@ -25,17 +25,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import me.proton.android.drive.ui.options.Option
import me.proton.android.drive.ui.options.filter
import me.proton.android.drive.ui.options.filterPermissions
import me.proton.android.drive.ui.options.filterRoot
import me.proton.android.drive.ui.options.filterShare
import me.proton.android.drive.ui.options.filterShareMember
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.domain.entity.Permissions
import me.proton.core.drive.base.domain.extension.mapWithPrevious
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.presentation.component.RunAction
@@ -43,6 +48,13 @@ import me.proton.core.drive.base.presentation.extension.require
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.isShareMember
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.NOT_FOUND
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbumsTempDisabledOnRelease
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveSharingDevelopment
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.files.presentation.entry.FileOptionEntry
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.LinkId
@@ -54,12 +66,17 @@ import javax.inject.Inject
class AlbumOptionsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getDriveLink: GetDecryptedDriveLink,
getFeatureFlagFlow: GetFeatureFlagFlow,
private val broadcastMessages: BroadcastMessages,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val albumId = AlbumId(
shareId = ShareId(userId, savedStateHandle.require(KEY_SHARE_ID)),
id = savedStateHandle.require(KEY_ALBUM_ID)
)
private val shareTempDisabled = getFeatureFlagFlow(driveAlbumsTempDisabledOnRelease(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveAlbumsTempDisabledOnRelease(userId), NOT_FOUND))
private val sharingDevelopment = getFeatureFlagFlow(driveSharingDevelopment(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveSharingDevelopment(userId), NOT_FOUND))
private var dismiss: (() -> Unit)? = null
val driveLink: StateFlow<DriveLink.Album?> = getDriveLink(albumId = albumId)
.mapSuccessValueOrNull()
@@ -89,33 +106,41 @@ class AlbumOptionsViewModel @Inject constructor(
navigateToManageAccess: (linkId: LinkId) -> Unit,
navigateToRename: (linkId: LinkId) -> Unit,
navigateToDelete: (AlbumId) -> Unit,
navigateToLeave: (AlbumId) -> Unit,
dismiss: () -> Unit,
): Flow<List<FileOptionEntry<DriveLink.Album>>> = driveLink
.filterNotNull()
.map { driveLink ->
options
.filter(driveLink)
//.filterShareMember(driveLink.isShareMember)
//.filterPermissions(driveLink.sharePermissions ?: Permissions.owner)
.map { option ->
when (option) {
is Option.OfflineToggle -> option.build(runAction) { driveLink ->
notYetImplemented()
}
is Option.ShareViaInvitations -> option.build(runAction, navigateToShareViaInvitations)
is Option.ManageAccess -> option.build(runAction, navigateToManageAccess)
is Option.Rename -> option.build(runAction, navigateToRename)
is Option.DeleteAlbum -> option.build(runAction = runAction) { albumId ->
navigateToDelete(albumId)
}
else -> error(
"Option ${option.javaClass.simpleName} is not found. Did you forget to add it?"
)
): Flow<List<FileOptionEntry<DriveLink.Album>>> = combine(
driveLink.filterNotNull(),
shareTempDisabled,
sharingDevelopment,
) { driveLink, shareTempDisabled, sharingDevelopment ->
options
.filter(driveLink)
.filterShareMember(driveLink.isShareMember)
.filterPermissions(driveLink.sharePermissions ?: Permissions.owner)
.filterShare(shareTempDisabled.on)
.filterRoot(driveLink, sharingDevelopment)
.map { option ->
when (option) {
is Option.OfflineToggle -> option.build(runAction) { driveLink ->
notYetImplemented()
}
}.also {
this.dismiss = dismiss
is Option.ShareViaInvitations -> option.build(runAction, navigateToShareViaInvitations)
is Option.ManageAccess -> option.build(runAction, navigateToManageAccess)
is Option.Rename -> option.build(runAction, navigateToRename)
is Option.DeleteAlbum -> option.build(runAction = runAction) { albumId ->
navigateToDelete(albumId)
}
is Option.LeaveAlbum -> option.build(runAction) { album ->
navigateToLeave(album.id)
}
else -> error(
"Option ${option.javaClass.simpleName} is not found. Did you forget to add it?"
)
}
}
}.also {
this.dismiss = dismiss
}
}
private fun notYetImplemented() = viewModelScope.launch {
broadcastMessages(
@@ -130,11 +155,12 @@ class AlbumOptionsViewModel @Inject constructor(
const val KEY_ALBUM_ID = "albumId"
private val options = setOf(
Option.OfflineToggle,
//Option.OfflineToggle,
Option.ShareViaInvitations,
Option.ManageAccess,
Option.Rename,
Option.DeleteAlbum,
Option.LeaveAlbum,
)
}
}
@@ -35,10 +35,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
@@ -48,26 +47,36 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import me.proton.android.drive.extension.getDefaultMessage
import me.proton.android.drive.photos.domain.usecase.AddPhotosToStream
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
import me.proton.android.drive.photos.domain.usecase.GetAddToAlbumPhotoListings
import me.proton.android.drive.photos.domain.usecase.GetPhotoListingCount
import me.proton.android.drive.photos.domain.usecase.RemoveFromAlbumInfo
import me.proton.android.drive.photos.presentation.extension.details
import me.proton.android.drive.photos.presentation.extension.processAddToStream
import me.proton.android.drive.photos.presentation.state.PhotosItem
import me.proton.android.drive.photos.presentation.viewevent.AlbumViewEvent
import me.proton.android.drive.photos.presentation.viewstate.AlbumViewState
import me.proton.android.drive.ui.common.onClick
import me.proton.android.drive.usecase.OnFilesDriveLinkError
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.domain.arch.onSuccess
import me.proton.core.drive.base.data.extension.getDefaultMessage
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.entity.Permissions
import me.proton.core.drive.base.domain.extension.asSuccess
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
import me.proton.core.drive.base.domain.extension.isViewerOrEditorOnly
import me.proton.core.drive.base.domain.extension.mapWithPrevious
import me.proton.core.drive.base.domain.extension.onFailure
import me.proton.core.drive.base.domain.log.LogTag
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.log.logId
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.presentation.common.Action
import me.proton.core.drive.base.presentation.common.getThemeDrawableId
import me.proton.core.drive.base.presentation.effect.ListEffect
import me.proton.core.drive.base.presentation.extension.quantityString
import me.proton.core.drive.base.presentation.state.ListContentAppendingState
@@ -75,10 +84,19 @@ import me.proton.core.drive.base.presentation.state.ListContentState
import me.proton.core.drive.base.presentation.viewmodel.onLoadState
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.isNameEncrypted
import me.proton.core.drive.drivelink.photo.domain.paging.PhotoDriveLinks
import me.proton.core.drive.drivelink.photo.domain.usecase.GetPagedAlbumPhotoListingsList
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
import me.proton.core.drive.drivelink.shared.presentation.extension.toViewState
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.NOT_FOUND
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbumsTempDisabledOnRelease
import me.proton.core.drive.feature.flag.domain.extension.off
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.LinkId
@@ -87,12 +105,16 @@ import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.photo.domain.entity.PhotoListing
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
import me.proton.core.drive.share.domain.entity.Share
import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.share.user.domain.entity.ShareUser
import me.proton.core.drive.share.user.domain.usecase.GetShareUsers
import me.proton.core.drive.sorting.domain.entity.Direction
import me.proton.core.util.kotlin.CoreLogger
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import me.proton.core.drive.base.domain.extension.combine as baseCombine
import me.proton.core.drive.base.presentation.R as BasePresentation
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
@@ -110,10 +132,14 @@ class AlbumViewModel @Inject constructor(
addToAlbumInfo: AddToAlbumInfo,
removeFromAlbumInfo: RemoveFromAlbumInfo,
getAddToAlbumPhotoListings: GetAddToAlbumPhotoListings,
getPhotoShare: GetPhotoShare,
getFeatureFlagFlow: GetFeatureFlagFlow,
getShareUsers: GetShareUsers,
@ApplicationContext private val appContext: Context,
private val onFilesDriveLinkError: OnFilesDriveLinkError,
private val photoDriveLinks: PhotoDriveLinks,
private val getPagedAlbumPhotoListingsList: GetPagedAlbumPhotoListingsList,
private val addPhotosToStream: AddPhotosToStream,
private val configurationProvider: ConfigurationProvider,
private val broadcastMessages: BroadcastMessages,
) : PhotosPickerAndSelectionViewModel(
@@ -130,6 +156,14 @@ class AlbumViewModel @Inject constructor(
override val filterByParentId: Boolean = false
private val shareId = ShareId(userId, requireNotNull(savedStateHandle.get<String>(SHARE_ID)))
private val albumId = AlbumId(shareId, requireNotNull(savedStateHandle[ALBUM_ID]))
private val shareTempDisabled = getFeatureFlagFlow(driveAlbumsTempDisabledOnRelease(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveAlbumsTempDisabledOnRelease(userId), NOT_FOUND))
private val photoShare: StateFlow<Share?> = getPhotoShare(userId)
.filterSuccessOrError()
.mapSuccessValueOrNull()
.stateIn(viewModelScope, Eagerly, null)
private val driveCopyFeature = getFeatureFlagFlow(FeatureFlagId.driveCopy(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(FeatureFlagId.driveCopy(userId), NOT_FOUND))
private var fetchingJob: Job? = null
private val listContentState = MutableStateFlow<ListContentState>(ListContentState.Loading)
private val listContentAppendingState = MutableStateFlow<ListContentAppendingState>(
@@ -145,7 +179,13 @@ class AlbumViewModel @Inject constructor(
notificationDotVisible = false,
onAction = { viewEvent?.onAlbumOptions?.invoke() },
)
private val emptyStateImageResId: Int get() = getThemeDrawableId(
light = BasePresentation.drawable.empty_albums_light,
dark = BasePresentation.drawable.empty_albums_dark,
dayNight = BasePresentation.drawable.empty_albums_daynight,
)
private val topBarActions: MutableStateFlow<Set<Action>> = MutableStateFlow(setOf(albumOptionsAction))
private val saveAllLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val retryTrigger = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
val driveLink: StateFlow<DriveLink.Album?> = retryTrigger.transformLatest {
emitAll(
@@ -182,13 +222,42 @@ class AlbumViewModel @Inject constructor(
return@mapWithPrevious null
}
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
}.stateIn(viewModelScope, Eagerly, null)
val viewState: Flow<AlbumViewState> = combine(
private val shareUsers = driveLink.transformLatest {
if((it?.sharePermissions ?: Permissions.owner).isAdmin) {
emitAll(getShareUsers(albumId).transformLatest { dataResult ->
dataResult.onSuccess { list ->
emit(list)
}.onFailure { error ->
emit(emptyList())
if (error.cause !is NoSuchElementException) {
error.cause?.log(VIEW_MODEL)
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.WARNING
)
}
}
})
} else {
emit(emptyList())
}
}.stateIn(viewModelScope, Eagerly, emptyList())
val viewState: Flow<AlbumViewState> = baseCombine(
driveLink.filterNotNull(),
listContentState,
selected,
) { album, contentState, selected ->
photoShare.filterNotNull(),
driveCopyFeature,
saveAllLoading,
shareUsers,
) { album, contentState, selected, photoShare, driveCopy, saveAllLoading, shareUsers ->
topBarActions.value = when {
inPickerMode -> emptySet()
selected.isNotEmpty() -> setOf(
@@ -200,7 +269,8 @@ class AlbumViewModel @Inject constructor(
}
AlbumViewState(
name = album.name,
details = album.details(appContext),
isNameEncrypted = album.isNameEncrypted,
details = album.details(appContext, showDisplayName = true),
coverLinkId = album.coverLinkId,
listContentState = contentState,
isRefreshEnabled = contentState !is ListContentState.Loading,
@@ -220,6 +290,15 @@ class AlbumViewModel @Inject constructor(
selected.size
)
},
showAddAction = (album.sharePermissions ?: Permissions.owner).canWrite && driveCopy.on,
addActionEnabled = selected.isEmpty() && !inPickerMode,
showSaveAllAction = (album.sharePermissions ?: Permissions.owner).isViewerOrEditorOnly && driveCopy.on
&& album.photoCount < configurationProvider.savePhotoToStreamLimit,
saveAllActionEnabled = selected.isEmpty() && !inPickerMode,
saveAllActionLoading = saveAllLoading,
showShareAction = (album.sharePermissions ?: Permissions.owner).isAdmin && shareTempDisabled.value.off,
shareActionEnabled = selected.isEmpty() && !inPickerMode,
shareUsers = shareUsers.map { shareUser -> shareUser.toViewState(appContext) },
)
}
@@ -250,10 +329,12 @@ class AlbumViewModel @Inject constructor(
fun viewEvent(
navigateToAlbumOptions: (AlbumId) -> Unit,
navigateToPhotosOptions: (FileId, AlbumId?) -> Unit,
navigateToPhotosOptions: (FileId, AlbumId?, SelectionId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId, AlbumId?) -> Unit,
navigateToPreview: (FileId, AlbumId) -> Unit,
navigateToPicker: (AlbumId) -> Unit,
navigateToShareViaInvitations: (AlbumId) -> Unit,
navigateToManageAccess: (AlbumId) -> Unit,
navigateBack: () -> Unit,
lifecycle: Lifecycle,
) : AlbumViewEvent = object : AlbumViewEvent {
@@ -263,7 +344,7 @@ class AlbumViewModel @Inject constructor(
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
flow.take(1).collect { driveLink ->
driveLink.onClick(
navigateToFolder = { _, _ -> error("Photos should not have folders") },
navigateToFolder = { _, _ -> error("Album should not have folders") },
navigateToPreview = { linkId ->
viewModelScope.launch {
navigateToPreview(
@@ -272,6 +353,7 @@ class AlbumViewModel @Inject constructor(
)
}
},
navigateToAlbum = { error("Album should not have albums") },
)
}
}
@@ -290,7 +372,7 @@ class AlbumViewModel @Inject constructor(
coroutineScope = viewModelScope,
emptyState = MutableStateFlow(
ListContentState.Empty(
imageResId = BasePresentation.drawable.ic_proton_images_50,
imageResId = emptyStateImageResId,
titleId = I18N.string.albums_empty_album_screen_title,
descriptionResId = I18N.string.albums_empty_album_screen_description,
)
@@ -324,6 +406,9 @@ class AlbumViewModel @Inject constructor(
override val onSelectedOptions =
{ onSelectedOptions(navigateToPhotosOptions, navigateToMultiplePhotosOptions, albumId) }
override val onBack = { onBack() }
override val onShare = { navigateToShareViaInvitations(albumId) }
override val onSaveAll = this@AlbumViewModel::onSaveAll
override val onShareUsers = { navigateToManageAccess(albumId) }
}.also { viewEvent ->
this.viewEvent = viewEvent
}
@@ -342,6 +427,33 @@ class AlbumViewModel @Inject constructor(
}
}
private fun onSaveAll() {
viewModelScope.launch {
saveAllLoading.value = true
addPhotosToStream(albumId).onFailure { error ->
error.log(LogTag.ALBUM, "Cannot copy photo to stream: ${albumId.id.logId()}")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR
)
}.onSuccess { result ->
result.processAddToStream(appContext) { message, type ->
broadcastMessages(
userId = userId,
message = message,
type = type,
)
}
}
saveAllLoading.value = false
}
}
private fun onScroll(driveLinkIds: Set<LinkId>) {
if (driveLinkIds.isNotEmpty()) {
fetchingJob?.cancel()
@@ -33,13 +33,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import me.proton.android.drive.photos.domain.usecase.GetPhotosDriveLink
import me.proton.android.drive.photos.presentation.R
import me.proton.android.drive.photos.presentation.state.AlbumsItem
import me.proton.android.drive.photos.presentation.viewevent.AlbumsViewEvent
import me.proton.android.drive.photos.presentation.viewstate.AlbumsFilter
@@ -48,6 +52,7 @@ import me.proton.android.drive.ui.effect.HomeEffect
import me.proton.android.drive.ui.effect.HomeTabViewModel
import me.proton.android.drive.usecase.OnFilesDriveLinkError
import me.proton.core.domain.arch.DataResult
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.domain.arch.onSuccess
import me.proton.core.drive.base.data.extension.getDefaultMessage
import me.proton.core.drive.base.data.extension.isRetryable
@@ -59,6 +64,8 @@ import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.presentation.common.Action
import me.proton.core.drive.base.presentation.common.getThemeDrawableId
import me.proton.core.drive.base.presentation.extension.quantityString
import me.proton.core.drive.base.presentation.state.ListContentState
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.base.presentation.viewstate.TagViewState
@@ -66,6 +73,7 @@ import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.photo.domain.paging.PhotoDriveLinks
import me.proton.core.drive.drivelink.photo.domain.usecase.FetchAndStoreAllAlbumListings
import me.proton.core.drive.drivelink.photo.domain.usecase.GetAllAlbumListings
import me.proton.core.drive.drivelink.shared.presentation.viewstate.UserInvitationBannerViewState
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.extension.shareId
@@ -73,6 +81,7 @@ import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.photo.domain.entity.AlbumListing
import me.proton.core.drive.photo.domain.extension.filterBy
import me.proton.core.drive.share.domain.entity.Share
import me.proton.core.drive.share.user.domain.usecase.GetUserInvitationCountFlow
import me.proton.core.util.kotlin.CoreLogger
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
@@ -90,6 +99,7 @@ class AlbumsViewModel @Inject constructor(
private val getPhotosDriveLink: GetPhotosDriveLink,
private val onFilesDriveLinkError: OnFilesDriveLinkError,
private val broadcastMessages: BroadcastMessages,
private val getUserInvitationCountFlow: GetUserInvitationCountFlow,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle), HomeTabViewModel {
private var fetchingJob: Job? = null
private val _homeEffect = MutableSharedFlow<HomeEffect>()
@@ -112,19 +122,25 @@ class AlbumsViewModel @Inject constructor(
)
private val topBarActions: MutableStateFlow<Set<Action>> =
MutableStateFlow(setOf(addAlbumAction))
private val emptyStateImageResId: Int get() = getThemeDrawableId(
light = BasePresentation.drawable.empty_albums_light,
dark = BasePresentation.drawable.empty_albums_dark,
dayNight = BasePresentation.drawable.empty_albums_daynight,
)
val initialViewState: AlbumsViewState =
AlbumsViewState(
topBarActions = topBarActions,
listContentState = listContentState.value,
isRefreshEnabled = listContentState.value != ListContentState.Loading,
placeholderImageResId = emptyStateImageResId,
navigationIconResId = CorePresentation.drawable.ic_proton_hamburger,
filters = listOf(
AlbumsFilter(
AlbumListing.Filter.ALL,
TagViewState(
label = appContext.getString(I18N.string.albums_filter_all),
icon = CorePresentation.drawable.ic_proton_checkmark,
icon = BasePresentation.drawable.ic_folder_album_outline,
selected = true,
)
),
@@ -151,6 +167,22 @@ class AlbumsViewModel @Inject constructor(
),
)
)
val userInvitationBannerViewState = getUserInvitationCountFlow(
userId = userId,
albumsOnly = true,
refresh = flowOf(true),
).filterSuccessOrError()
.mapSuccessValueOrNull()
.filterNotNull()
.filter { count -> count > 0 }
.map { count ->
UserInvitationBannerViewState(
appContext.quantityString(
I18N.plurals.shared_with_me_album_invitations_banner_description,
count
)
)
}
private val retryTrigger = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
val driveLink: StateFlow<DriveLink.Folder?> = retryTrigger.transformLatest {
@@ -199,11 +231,35 @@ class AlbumsViewModel @Inject constructor(
is DataResult.Success -> emit(result.value).also {
if (result.value.isEmpty()) {
listContentState.value = ListContentState.Empty(
imageResId = BasePresentation.drawable.empty_albums,
titleId = I18N.string.albums_empty_albums_list_screen_title,
descriptionResId = I18N.string.albums_empty_albums_list_screen_description,
)
listContentState.value = when (albumListingsFilter.value) {
AlbumListing.Filter.ALL -> ListContentState.Empty(
imageResId = emptyStateImageResId,
titleId = I18N.string.albums_empty_albums_list_screen_title,
descriptionResId = I18N.string.albums_empty_albums_list_screen_description,
actionResId = I18N.string.common_create_album_action
)
AlbumListing.Filter.MY_ALBUMS -> ListContentState.Empty(
imageResId = emptyStateImageResId,
titleId = I18N.string.albums_empty_albums_list_screen_title,
descriptionResId = I18N.string.albums_empty_albums_my_albums_screen_description,
actionResId = I18N.string.common_create_album_action
)
AlbumListing.Filter.SHARED_BY_ME -> ListContentState.Empty(
imageResId = emptyStateImageResId,
titleId = I18N.string.albums_empty_albums_shared_by_me_screen_title,
descriptionResId = I18N.string.albums_empty_albums_shared_by_me_screen_description,
actionResId = I18N.string.common_create_album_action
)
AlbumListing.Filter.SHARED_WITH_ME -> ListContentState.Empty(
imageResId = emptyStateImageResId,
titleId = I18N.string.albums_empty_albums_shared_with_me_screen_title,
descriptionResId = I18N.string.albums_empty_albums_shared_with_me_screen_description,
actionResId = null
)
}
} else {
listContentState.value = ListContentState.Content()
}
@@ -260,6 +316,7 @@ class AlbumsViewModel @Inject constructor(
fun viewEvent(
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
): AlbumsViewEvent = object : AlbumsViewEvent {
override val onRefresh = this@AlbumsViewModel::onRefresh
override val onScroll = this@AlbumsViewModel::onScroll
@@ -273,6 +330,7 @@ class AlbumsViewModel @Inject constructor(
}
override val onCreateNewAlbum = { navigateToCreateNewAlbum() }
override val onFilterSelected = this@AlbumsViewModel::onFilterSelected
override val onUserInvitation = { navigateToUserInvitation(true) }
}.also { viewEvent ->
this.viewEvent = viewEvent
}
@@ -54,6 +54,7 @@ import me.proton.core.drive.drivelink.shared.domain.usecase.SharedDriveLinks
import me.proton.core.drive.drivelink.shared.presentation.entity.SharedItem
import me.proton.core.drive.drivelink.shared.presentation.viewevent.SharedViewEvent
import me.proton.core.drive.drivelink.shared.presentation.viewstate.SharedViewState
import me.proton.core.drive.link.domain.entity.AlbumId
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
@@ -101,7 +102,9 @@ abstract class CommonSharedViewModel(
fun viewEvent(
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (fileId: FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToUserInvitation: (Boolean) -> Unit,
lifecycle: Lifecycle,
): SharedViewEvent = object : SharedViewEvent {
@@ -112,6 +115,7 @@ abstract class CommonSharedViewModel(
driveLink.onClick(
navigateToFolder = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToAlbum = navigateToAlbum,
)
}
}
@@ -143,6 +147,8 @@ abstract class CommonSharedViewModel(
override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) }
override val onErrorAction = this@CommonSharedViewModel::onErrorAction
override val onUserInvitation = { navigateToUserInvitation(false) }
}
private fun onScroll(driveLinkIds: Set<LinkId>) {
@@ -42,18 +42,26 @@ import me.proton.android.drive.photos.presentation.viewstate.ConfirmDeleteAlbumD
import me.proton.android.drive.photos.presentation.viewstate.ConfirmDeleteAlbumViewState
import me.proton.android.drive.photos.presentation.viewstate.ConfirmDeleteAlbumWithChildrenViewState
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.data.extension.detailsOrNull
import me.proton.core.drive.base.data.workmanager.onProtonHttpException
import me.proton.core.drive.base.domain.api.ProtonApiCode
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.usecase.GetDriveLink
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.extension.shareId
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.photo.data.api.response.DeleteAlbumErrorResponseDetails
import me.proton.core.drive.photo.domain.usecase.DeleteAlbum
import me.proton.core.drive.photo.domain.usecase.GetAllAlbumDirectChildren
import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.network.domain.ApiResult
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
@@ -66,11 +74,13 @@ class ConfirmDeleteAlbumDialogViewModel @Inject constructor(
private val saveChildrenAndDeleteAlbum: SaveChildrenAndDeleteAlbum,
private val configurationProvider: ConfigurationProvider,
private val broadcastMessages: BroadcastMessages,
private val getAllAlbumDirectChildren: GetAllAlbumDirectChildren,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val albumId = AlbumId(
ShareId(userId, requireNotNull(savedStateHandle[SHARE_ID])),
requireNotNull(savedStateHandle[ALBUM_ID])
)
private val albumDirectChildren: MutableSet<FileId> = mutableSetOf()
private val driveLink: StateFlow<DriveLink.Album?> = getDriveLink(albumId)
.mapSuccessValueOrNull()
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
@@ -147,9 +157,10 @@ class ConfirmDeleteAlbumDialogViewModel @Inject constructor(
)
.onFailure { error ->
isOperationInProgress.value = false
error.onProtonHttpException { protonCode: Int ->
if (protonCode == ProtonApiCode.RESULTS_IN_DATA_LOSS) {
error.onProtonHttpException { protonData ->
if (protonData.code == ProtonApiCode.RESULTS_IN_DATA_LOSS) {
// store linkIds once available
collectAlbumDirectChildren(album.shareId, protonData)
showDialog.value = ConfirmDeleteAlbumDialogViewState.Dialog.WITH_CHILDREN
}
else {
@@ -204,9 +215,25 @@ class ConfirmDeleteAlbumDialogViewModel @Inject constructor(
private fun onConfirmSaveAndDeleteAlbum() {
viewModelScope.launch {
isSavingOperationInProgress.value = true
val album = driveLink.filterNotNull().first()
val children = getAllAlbumDirectChildren(album.volumeId)
.onFailure { error ->
isSavingOperationInProgress.value = false
error.log(VIEW_MODEL, "Failed to get album direct children")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage,
),
type = BroadcastMessage.Type.ERROR,
)
return@launch
}
.getOrThrow()
saveChildrenAndDeleteAlbum(
albumId = albumId,
children = emptyList(),//TODO get list of Id's
children = children,
)
.onFailure { error ->
isSavingOperationInProgress.value = false
@@ -227,6 +254,35 @@ class ConfirmDeleteAlbumDialogViewModel @Inject constructor(
}
}
private fun collectAlbumDirectChildren(
albumShareId: ShareId,
protonData: ApiResult.Error.ProtonData,
) {
albumDirectChildren.clear()
val errorDetails = protonData.detailsOrNull<DeleteAlbumErrorResponseDetails>() ?: return
if (!errorDetails.more) {
albumDirectChildren.addAll(
errorDetails.childLinkIds.map { childPhotoId -> FileId(albumShareId, childPhotoId) }
)
}
}
private suspend fun getAllAlbumDirectChildren(volumeId: VolumeId): Result<Set<FileId>> = coRunCatching {
if (albumDirectChildren.isEmpty()) {
getAllAlbumDirectChildren(
volumeId = volumeId,
albumId = albumId,
includeTrashedChildren = true,
)
.onSuccess { children ->
albumDirectChildren.addAll(children)
}
.getOrThrow()
}
albumDirectChildren
}
companion object {
const val SHARE_ID = "shareId"
const val ALBUM_ID = "albumId"
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewmodel
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.proton.android.drive.extension.getDefaultMessage
import me.proton.android.drive.photos.domain.usecase.AddPhotosToStream
import me.proton.android.drive.photos.presentation.extension.processAddToStream
import me.proton.android.drive.photos.presentation.viewevent.ConfirmLeaveAlbumDialogViewEvent
import me.proton.android.drive.photos.presentation.viewstate.ConfirmLeaveAlbumDialogViewState
import me.proton.android.drive.usecase.LeaveShare
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
import me.proton.core.drive.base.domain.log.LogTag
import me.proton.core.drive.base.domain.log.logId
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.viewmodel.UserViewModel
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.share.domain.entity.ShareId
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
@HiltViewModel
class ConfirmLeaveAlbumDialogViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
savedStateHandle: SavedStateHandle,
getDecryptedDriveLink: GetDecryptedDriveLink,
private val leaveShare: LeaveShare,
private val addPhotosToStream: AddPhotosToStream,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val albumId = AlbumId(
ShareId(userId, requireNotNull(savedStateHandle[SHARE_ID])),
requireNotNull(savedStateHandle[ALBUM_ID])
)
private val album: StateFlow<DriveLink.Album?> = getDecryptedDriveLink(albumId)
.filterSuccessOrError()
.mapSuccessValueOrNull()
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val isSavingOperationInProgress = MutableStateFlow(false)
private val isWithoutSavingOperationInProgress = MutableStateFlow(false)
val initialViewState = ConfirmLeaveAlbumDialogViewState(
title = appContext.getString(I18N.string.albums_leave_album_dialog_title),
description = "",
dismissButtonResId = I18N.string.albums_leave_album_dialog_cancel_action,
confirmWithoutSavingButtonResId = I18N.string.albums_leave_album_dialog_leave_without_saving_action,
confirmSaveAndLeaveButtonResId = I18N.string.albums_leave_album_dialog_save_and_leave_action,
isSavingOperationInProgress = false,
isWithoutSavingOperationInProgress = false,
isSaveAndLeaveButtonVisible = true,
)
val viewState: Flow<ConfirmLeaveAlbumDialogViewState> = combine(
album.filterNotNull(),
isSavingOperationInProgress,
isWithoutSavingOperationInProgress,
) { album, savingOperationInProgress, withoutSavingOperationInProgress ->
initialViewState.copy(
description = appContext.getString(I18N.string.albums_leave_album_dialog_description, album.name),
isSavingOperationInProgress = savingOperationInProgress,
isWithoutSavingOperationInProgress = withoutSavingOperationInProgress,
isSaveAndLeaveButtonVisible = album.photoCount < configurationProvider.savePhotoToStreamLimit,
)
}
fun viewEvent(
onDismiss: () -> Unit
): ConfirmLeaveAlbumDialogViewEvent = object : ConfirmLeaveAlbumDialogViewEvent {
override val onDismiss = onDismiss
override val onLeaveAlbumWithoutSaving = { leaveAlbumWithoutSaving(onDismiss) }
override val onSaveAndLeaveAlbum = { saveAndLeaveAlbum(onDismiss) }
}
private fun leaveAlbumWithoutSaving(dismiss: () -> Unit) {
viewModelScope.launch {
album.value?.let {
isWithoutSavingOperationInProgress.value = true
leaveShare(it)
.onSuccess {
dismiss()
}
isWithoutSavingOperationInProgress.value = false
}
}
}
private fun saveAndLeaveAlbum(dismiss: () -> Unit) {
viewModelScope.launch {
album.value?.let {
isSavingOperationInProgress.value = true
addPhotosToStream(albumId).onFailure { error ->
error.log(LogTag.ALBUM, "Cannot copy photo to stream: ${albumId.id.logId()}")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR
)
}.onSuccess { result ->
result.processAddToStream(appContext) { message, type ->
broadcastMessages(
userId = userId,
message = message,
type = type,
)
}
leaveShare(it)
.onSuccess {
dismiss()
}
}
isSavingOperationInProgress.value = false
}
}
}
companion object {
const val SHARE_ID = "shareId"
const val ALBUM_ID = "albumId"
}
}
@@ -22,7 +22,6 @@ import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.CombinedLoadStates
import androidx.paging.cachedIn
import androidx.paging.map
@@ -60,6 +59,8 @@ import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.toVolumePhotoListing
import me.proton.core.drive.drivelink.photo.domain.paging.PhotoDriveLinks
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.InvalidLinkName.Empty
import me.proton.core.drive.link.domain.entity.InvalidLinkName.ExceedsMaxLength
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import javax.inject.Inject
@@ -69,17 +70,18 @@ import me.proton.core.drive.i18n.R as I18N
@HiltViewModel
class CreateNewAlbumViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getPagedAddToAlbumPhotoListings: GetPagedAddToAlbumPhotoListings,
@ApplicationContext private val appContext: Context,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
private val updateAlbumName: UpdateAlbumName,
private val clearNewAlbum: ClearNewAlbum,
private val getNewAlbumName: GetNewAlbumName,
private val getPagedAddToAlbumPhotoListings: GetPagedAddToAlbumPhotoListings,
private val photoDriveLinks: PhotoDriveLinks,
private val createNewAlbum: CreateNewAlbum,
private val removeFromAlbumInfo: RemoveFromAlbumInfo,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val isSharedAlbum = savedStateHandle.get<Boolean>(SHARED_ALBUM) ?: false
private val isCreationInProgress = MutableStateFlow(false)
private val currentAlbumName = MutableStateFlow<String?>(null)
private val initialAlbumName = flowOf {
@@ -100,6 +102,11 @@ class CreateNewAlbumViewModel @Inject constructor(
isAddEnabled = true,
isRemoveEnabled = true,
isCreationInProgress = isCreationInProgress.value,
doneButtonLabelResId = if (isSharedAlbum) {
I18N.string.common_share
} else {
I18N.string.common_done_action
},
name = initialAlbumName,
hint = appContext.getString(I18N.string.albums_new_album_name_hint),
)
@@ -147,18 +154,12 @@ class CreateNewAlbumViewModel @Inject constructor(
viewModelScope.launch {
currentAlbumName.value?.let { albumName ->
isCreationInProgress.value = true
showAddToAlbumStartMessage()
createNewAlbum(userId = userId, isLocked = false)
.onFailure { error ->
isCreationInProgress.value = false
error.log(VIEW_MODEL, "Creating new album failed")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
context = appContext,
useExceptionMessage = configurationProvider.useExceptionMessage,
),
type = BroadcastMessage.Type.ERROR,
)
error.handle()
}
.onSuccess { albumId ->
isCreationInProgress.value = false
@@ -215,4 +216,35 @@ class CreateNewAlbumViewModel @Inject constructor(
}
}
}
private fun Throwable.handle() {
val message = when (this) {
Empty -> appContext.getString(I18N.string.albums_new_album_create_error_name_is_blank)
is ExceedsMaxLength -> appContext.getString(
I18N.string.albums_new_album_create_error_name_too_long,
this.maxLength
)
else -> getDefaultMessage(
context = appContext,
useExceptionMessage = configurationProvider.useExceptionMessage,
)
}
broadcastMessages(
userId = userId,
message = message,
type = BroadcastMessage.Type.ERROR,
)
}
private fun showAddToAlbumStartMessage() {
broadcastMessages(
userId = userId,
message = appContext.getString(I18N.string.albums_add_to_album_start_message),
type = BroadcastMessage.Type.INFO,
)
}
companion object {
const val SHARED_ALBUM = "sharedAlbum"
}
}
@@ -62,6 +62,7 @@ class DefaultHomeTabViewModel @Inject constructor(
private fun TabItem.toHomeTab(): HomeTab = when (route) {
Screen.Files.route -> HomeTab.FILES
Screen.Photos.route -> HomeTab.PHOTOS
Screen.PhotosAndAlbums.route -> HomeTab.PHOTOS
Screen.Computers.route -> HomeTab.COMPUTERS
Screen.SharedTabs.route -> HomeTab.SHARED
else -> error("Unhandled tab item route: $route")
@@ -27,29 +27,31 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.proton.android.drive.extension.getDefaultMessage
import me.proton.android.drive.photos.domain.usecase.AddPhotosToStream
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
import me.proton.android.drive.photos.presentation.extension.processAddToStream
import me.proton.android.drive.photos.presentation.extension.processRemove
import me.proton.android.drive.ui.options.Option
import me.proton.android.drive.ui.options.filter
import me.proton.android.drive.ui.options.filterAlbums
import me.proton.android.drive.ui.options.filterPermissions
import me.proton.android.drive.ui.options.filterPhotoFavorite
import me.proton.android.drive.ui.options.filterProtonDocs
import me.proton.android.drive.ui.options.filterRoot
import me.proton.android.drive.ui.options.filterShare
import me.proton.android.drive.ui.options.filterShareMember
import me.proton.android.drive.usecase.LeaveShare
import me.proton.android.drive.usecase.NotifyActivityNotFound
import me.proton.android.drive.usecase.OpenProtonDocumentInBrowser
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.data.extension.getDefaultMessage
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.entity.Permissions
import me.proton.core.drive.base.domain.extension.combine
import me.proton.core.drive.base.domain.extension.mapWithPrevious
import me.proton.core.drive.base.domain.extension.onFailure
import me.proton.core.drive.base.domain.log.LogTag
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.log.logId
@@ -62,26 +64,29 @@ import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLin
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.isShareMember
import me.proton.core.drive.drivelink.offline.domain.usecase.ToggleOffline
import me.proton.core.drive.drivelink.photo.domain.usecase.ToggleFavorite
import me.proton.core.drive.drivelink.photo.domain.usecase.UpdateAlbumCover
import me.proton.core.drive.drivelink.shared.domain.extension.sharingDetails
import me.proton.core.drive.drivelink.trash.domain.usecase.ToggleTrashState
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.NOT_FOUND
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbumsDisabled
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbumsTempDisabledOnRelease
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveDocsDisabled
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveSharingDevelopment
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.files.presentation.entry.FileOptionEntry
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.extension.shareId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.share.user.domain.usecase.LeaveShare
import me.proton.core.util.kotlin.CoreLogger
import me.proton.core.drive.volume.domain.entity.Volume
import me.proton.core.drive.volume.domain.usecase.GetOldestActiveVolume
import me.proton.core.drive.volume.domain.usecase.HasPhotoVolume
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
@@ -91,7 +96,7 @@ class FileOrFolderOptionsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getDriveLink: GetDecryptedDriveLink,
getFeatureFlagFlow: GetFeatureFlagFlow,
albumsFeatureFlag: AlbumsFeatureFlag,
getOldestActiveVolume: GetOldestActiveVolume,
private val toggleOffline: ToggleOffline,
private val toggleTrashState: ToggleTrashState,
private val exportTo: ExportTo,
@@ -102,7 +107,12 @@ class FileOrFolderOptionsViewModel @Inject constructor(
private val openProtonDocumentInBrowser: OpenProtonDocumentInBrowser,
private val updateAlbumCover: UpdateAlbumCover,
private val removePhotosFromAlbum: RemovePhotosFromAlbum,
private val deselectLinks: DeselectLinks,
private val toggleFavorite: ToggleFavorite,
private val addPhotosToStream: AddPhotosToStream,
private val hasPhotoVolume: HasPhotoVolume,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val selectionId = savedStateHandle.get<String?>(KEY_SELECTION_ID)?.let { SelectionId(it) }
private var dismiss: (() -> Unit)? = null
private val linkId: LinkId = FileId(
ShareId(userId, savedStateHandle.require(KEY_SHARE_ID)),
@@ -133,11 +143,17 @@ class FileOrFolderOptionsViewModel @Inject constructor(
private val docsKillSwitch = getFeatureFlagFlow(driveDocsDisabled(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveDocsDisabled(userId), NOT_FOUND))
private val albumsFeature = albumsFeatureFlag(userId)
.stateIn(viewModelScope, Eagerly, configurationProvider.albumsFeatureFlag)
private val albumsKillSwitch = getFeatureFlagFlow(driveAlbumsDisabled(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveAlbumsDisabled(userId), NOT_FOUND))
private val shareTempDisabled = getFeatureFlagFlow(driveAlbumsTempDisabledOnRelease(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveAlbumsTempDisabledOnRelease(userId), NOT_FOUND))
private val photoVolume = getOldestActiveVolume(userId, Volume.Type.PHOTO)
.mapSuccessValueOrNull()
.stateIn(viewModelScope, Eagerly, null)
fun <T : DriveLink> entries(
runAction: (suspend () -> Unit) -> Unit,
navigateToInfo: (linkId: LinkId) -> Unit,
@@ -153,59 +169,99 @@ class FileOrFolderOptionsViewModel @Inject constructor(
this.driveLink.filterNotNull(),
sharingDevelopment,
docsKillSwitch,
albumsFeature,
hasPhotoVolume(userId),
albumsKillSwitch,
) { driveLink, sharingDevelopment, protonDocsKillSwitch, albumsFeatureFlagOn, albumsKillSwitch ->
shareTempDisabled,
) { driveLink, sharingDevelopment, protonDocsKillSwitch, hasPhotoVolume, albumsKillSwitch, shareTempDisabled ->
options
.filter(driveLink)
.filterAlbums(albumsFeatureFlagOn, albumsKillSwitch, albumId)
.filterAlbums(hasPhotoVolume, albumsKillSwitch, albumId)
.filterPhotoFavorite(hasPhotoVolume, albumsKillSwitch)
.filterRoot(driveLink, sharingDevelopment)
.filterShare(shareTempDisabled.on, albumId)
.filterShareMember(driveLink.isShareMember)
.filterPermissions(driveLink.sharePermissions ?: Permissions.owner)
.filterProtonDocs(protonDocsKillSwitch)
.map { option ->
when (option) {
is Option.DeletePermanently -> option.build(runAction, navigateToDelete)
is Option.Info -> option.build(runAction, navigateToInfo)
is Option.Move -> option.build(runAction, navigateToMove)
is Option.FavoriteToggle -> option.build(runAction) { driveLink ->
viewModelScope.launch {
toggleFavoriteAction(driveLink)
deselectLinks()
}
}
is Option.Info -> option.build(runAction) { linkId ->
navigateToInfo(linkId)
deselectLinks()
}
is Option.Move -> option.build(runAction) { linkId, parentId ->
navigateToMove(linkId, parentId)
deselectLinks()
}
is Option.OfflineToggle -> option.build(runAction) { driveLink ->
viewModelScope.launch {
toggleOffline(driveLink)
deselectLinks()
}
}
is Option.Rename -> option.build(runAction, navigateToRename)
is Option.Rename -> option.build(runAction) { linkId ->
navigateToRename(linkId)
deselectLinks()
}
is Option.Trash -> option.build(
runAction = runAction,
toggleTrash = {
viewModelScope.launch {
toggleTrashState(driveLink)
deselectLinks()
}
}
)
is Option.SendFile -> option.build(runAction, navigateToSendFile)
is Option.SendFile -> option.build(runAction) { linkId ->
navigateToSendFile(linkId)
deselectLinks()
}
is Option.Download -> option.build { filename ->
showCreateDocumentPicker(filename) { handleActivityNotFound() }
deselectLinks()
}
is Option.ManageAccess -> option.build(runAction) { linkId ->
navigateToManageAccess(linkId)
deselectLinks()
}
is Option.ShareViaInvitations -> option.build(runAction) { linkId ->
navigateToShareViaInvitations(linkId)
deselectLinks()
}
is Option.ManageAccess -> option.build(runAction, navigateToManageAccess)
is Option.ShareViaInvitations -> option.build(runAction, navigateToShareViaInvitations)
is Option.RemoveMe -> option.build(runAction) { driveLink ->
viewModelScope.launch {
leaveShare(driveLink)
deselectLinks()
}
}
is Option.OpenInBrowser -> option.build(runAction) { driveLink ->
viewModelScope.launch {
openProtonDocumentInBrowser(driveLink)
deselectLinks()
}
}
is Option.SetAsAlbumCover -> option.build(runAction) { driveLink ->
viewModelScope.launch {
setAsAlbumCover(driveLink)
deselectLinks()
}
}
is Option.SaveSharePhoto -> option.build(runAction) { driveLink ->
viewModelScope.launch {
saveSharedPhoto(driveLink)
deselectLinks()
}
}
is Option.RemoveFromAlbum -> option.build(runAction) { driveLink ->
viewModelScope.launch {
removePhotosFromAlbum(driveLink)
deselectLinks()
}
}
else -> throw IllegalStateException(
@@ -217,6 +273,14 @@ class FileOrFolderOptionsViewModel @Inject constructor(
}
}
private fun deselectLinks() {
viewModelScope.launch {
if (selectionId != null) {
deselectLinks(selectionId)
}
}
}
private suspend fun removePhotosFromAlbum(
driveLink: DriveLink.File,
) {
@@ -272,28 +336,62 @@ class FileOrFolderOptionsViewModel @Inject constructor(
}
}
private suspend fun leaveShare(driveLink: DriveLink) {
val shareId = driveLink.sharingDetails?.shareId
val memberId = driveLink.shareUser?.id
if (shareId != null && memberId != null && shareId == driveLink.shareId) {
leaveShare(driveLink.volumeId, driveLink.id, memberId).last().onFailure { error ->
error.log(LogTag.SHARING, "Cannot leave share")
private suspend fun saveSharedPhoto(
driveLink: DriveLink.File
) {
val fileId = driveLink.id
addPhotosToStream(
photoIds = listOf(fileId),
albumId = requireNotNull(albumId),
).onFailure { error ->
error.log(LogTag.ALBUM, "Cannot copy photo to stream: ${fileId.id.logId()}")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR
)
}.onSuccess { result ->
result.processAddToStream(appContext) { message, type ->
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR
message = message,
type = type,
)
}
} else {
CoreLogger.i(
tag = VIEW_MODEL,
message = """
Skipping leave share (DriveLink.shareId=${driveLink.shareId.id.logId()},
SharingDetails.shareId=${shareId?.id?.logId()}, memberId=${memberId?.logId()})
""".trimIndent(),
}
}
private suspend fun toggleFavoriteAction(
driveLink: DriveLink.File
) {
toggleFavorite(driveLink).onFailure { error ->
error.log(VIEW_MODEL, "Cannot toggle favorite")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR
)
}.onSuccess {
broadcastMessages(
userId = userId,
message = if(driveLink.isFavorite) {
appContext.getString(I18N.string.files_remove_from_favorite_action_success)
} else {
appContext.getString(
if (driveLink.volumeId != photoVolume.value?.id) {
I18N.string.files_add_to_favorite_from_foreign_volume_action_success
} else {
I18N.string.files_add_to_favorite_action_success
}
)
},
type = BroadcastMessage.Type.INFO
)
}
}
@@ -313,11 +411,14 @@ class FileOrFolderOptionsViewModel @Inject constructor(
const val KEY_LINK_ID = "linkId"
const val KEY_ALBUM_ID = "albumId"
const val KEY_ALBUM_SHARE_ID = "albumShareId"
const val KEY_SELECTION_ID = "selectionId"
private val options = setOf(
Option.SetAsAlbumCover,
Option.RemoveFromAlbum,
Option.OfflineToggle,
Option.FavoriteToggle,
Option.SaveSharePhoto,
Option.ShareViaInvitations,
Option.ManageAccess,
Option.SendFile,
@@ -326,6 +326,7 @@ class FilesViewModel @Inject constructor(
driveLink.onClick(
navigateToFolder = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToAlbum = { error("Album is not expected here") },
)
}
}
@@ -364,7 +365,7 @@ class FilesViewModel @Inject constructor(
override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) }
override val onSelectedOptions = {
onSelectedOptions(
{ linkId: LinkId, _ -> navigateToFileOrFolderOptions(linkId) },
{ linkId: LinkId, _, _ -> navigateToFileOrFolderOptions(linkId) },
{ selectionId: SelectionId, _ -> navigateToMultipleFileOrFolderOptions(selectionId) },
)
}
@@ -30,14 +30,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import me.proton.android.drive.BuildConfig
import me.proton.android.drive.ui.navigation.HomeTab
@@ -47,22 +43,18 @@ import me.proton.android.drive.ui.viewstate.HomeViewState
import me.proton.android.drive.usecase.CanGetMoreFreeStorage
import me.proton.android.drive.usecase.GetDynamicHomeTabsFlow
import me.proton.android.drive.usecase.ShouldShowOverlay
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.domain.entity.SessionUserId
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
import me.proton.core.drive.base.domain.extension.getOrNull
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.base.presentation.component.NavigationTab
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveMobileSharingInvitationsAcceptReject
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerViewEvent
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerViewState
import me.proton.core.drive.share.user.domain.usecase.HasUserInvitationFlow
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.payment.domain.PaymentManager
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.entity.User
import me.proton.core.util.kotlin.CoreLogger
@@ -81,24 +73,23 @@ class HomeViewModel @Inject constructor(
private val canGetMoreFreeStorage: CanGetMoreFreeStorage,
getDynamicHomeTabsFlow: GetDynamicHomeTabsFlow,
hasUserInvitationFlow: HasUserInvitationFlow,
getFeatureFlagFlow: GetFeatureFlagFlow,
private val broadcastMessages: BroadcastMessages,
private val shouldShowOverlay: ShouldShowOverlay,
private val paymentManager: PaymentManager,
) : ViewModel(), NotificationDotViewModel, UserViewModel by UserViewModel(savedStateHandle) {
private var navigateToTab: ((route: String) -> Unit)? = null
override val notificationDotRequested = getFeatureFlagFlow(
driveMobileSharingInvitationsAcceptReject(userId),
).transform { featureFlag ->
if (featureFlag.on) {
emitAll(hasUserInvitationFlow(userId))
}
}.stateIn(viewModelScope, SharingStarted.Lazily, false)
override val notificationDotRequested = hasUserInvitationFlow(userId)
.stateIn(viewModelScope, SharingStarted.Lazily, false)
private val notificationDotForPhotosTab: StateFlow<Boolean> = hasUserInvitationFlow(userId, true)
.stateIn(viewModelScope, SharingStarted.Lazily, false)
val tabs: StateFlow<Map<out HomeTab, NavigationTab>> = combine(
getDynamicHomeTabsFlow(userId),
notificationDotRequested,
) { dynamicHomeTabs, notificationDotRequested ->
notificationDotForPhotosTab,
) { dynamicHomeTabs, notificationDotRequested, notificationDotForPhotosTab ->
dynamicHomeTabs
.filter { dynamicHomeTab -> dynamicHomeTab.isEnabled }
.sortedBy { dynamicHomeTab -> dynamicHomeTab.order }
@@ -106,8 +97,11 @@ class HomeViewModel @Inject constructor(
dynamicHomeTab.screen to NavigationTab(
iconResId = dynamicHomeTab.iconResId,
titleResId = dynamicHomeTab.titleResId,
notificationDotVisible = dynamicHomeTab.screen is Screen.SharedTabs
&& notificationDotRequested,
notificationDotVisible = when (dynamicHomeTab.screen) {
is Screen.SharedTabs -> notificationDotRequested
is Screen.PhotosAndAlbums -> notificationDotForPhotosTab
else -> false
}
)
}
.associateBy({ tab -> tab.first }, { tab -> tab.second })
@@ -145,6 +139,7 @@ class HomeViewModel @Inject constructor(
navigateToOnboarding: () -> Unit,
navigateToWhatsNew: (WhatsNewKey) -> Unit,
navigateToRatingBooster: () -> Unit,
navigateToSubscriptionPromo: (String) -> Unit,
): HomeViewEvent = object : HomeViewEvent {
override val onTab = { tab: NavigationTab -> navigateToTab(tab.screen(userId)) }
override val onFirstLaunch: () -> Unit = {
@@ -153,6 +148,7 @@ class HomeViewModel @Inject constructor(
UserOverlay.Onboarding -> navigateToOnboarding()
is UserOverlay.WhatsNew -> navigateToWhatsNew(overlay.key)
UserOverlay.RatingBooster -> navigateToRatingBooster()
is UserOverlay.Subcription -> navigateToSubscriptionPromo(overlay.key)
null -> {}
}
}
@@ -189,7 +185,7 @@ class HomeViewModel @Inject constructor(
else -> error("Unhandled tab item route: $route")
}
private fun getViewState(
private suspend fun getViewState(
user: User?,
startDestination: String,
tabs: Map<out HomeTab, NavigationTab>,
@@ -204,6 +200,11 @@ class HomeViewModel @Inject constructor(
BuildConfig.VERSION_NAME,
currentUser = user,
showGetFreeStorage = user?.let { canGetMoreFreeStorage(user) } ?: false,
showSubscription = user?.userId?.let { userId ->
coRunCatching {
paymentManager.isSubscriptionAvailable(userId)
}.getOrNull(VIEW_MODEL, "Failed to read subscriptions")
} ?: false
)
)
@@ -37,6 +37,8 @@ import me.proton.android.drive.photos.presentation.extension.processRemove
import me.proton.android.drive.ui.options.Option
import me.proton.android.drive.ui.options.filterAlbums
import me.proton.android.drive.ui.options.filterAll
import me.proton.android.drive.ui.options.filterPermissions
import me.proton.android.drive.ui.options.filterShare
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.extension.getOrNull
import me.proton.core.drive.base.domain.log.LogTag
@@ -48,12 +50,14 @@ import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.documentsprovider.domain.usecase.ExportToDownload
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.isPhoto
import me.proton.core.drive.drivelink.domain.extension.lowestCommonPermissions
import me.proton.core.drive.drivelink.domain.extension.toVolumePhotoListing
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.NOT_FOUND
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbumsDisabled
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbumsTempDisabledOnRelease
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.files.presentation.entry.OptionEntry
import me.proton.core.drive.link.domain.entity.AlbumId
@@ -64,6 +68,7 @@ import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.trash.domain.usecase.SendToTrash
import me.proton.core.drive.volume.domain.usecase.HasPhotoVolume
import javax.inject.Inject
@HiltViewModel
@@ -71,7 +76,6 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getSelectedDriveLinks: GetSelectedDriveLinks,
getFeatureFlagFlow: GetFeatureFlagFlow,
albumsFeatureFlag: AlbumsFeatureFlag,
@ApplicationContext private val appContext: Context,
private val sendToTrash: SendToTrash,
private val exportToDownload: ExportToDownload,
@@ -80,6 +84,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
private val removePhotosFromAlbum: RemovePhotosFromAlbum,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
private val hasPhotoVolume: HasPhotoVolume,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val selectionId = SelectionId(requireNotNull(savedStateHandle.get(KEY_SELECTION_ID)))
val selectedDriveLinks: Flow<List<DriveLink>> = getSelectedDriveLinks(selectionId)
@@ -91,24 +96,28 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
albumId
)
}
private val albumsFeature = albumsFeatureFlag(userId)
.stateIn(viewModelScope, Eagerly, configurationProvider.albumsFeatureFlag)
private val albumsKillSwitch = getFeatureFlagFlow(driveAlbumsDisabled(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveAlbumsDisabled(userId), NOT_FOUND))
private val shareTempDisabled = getFeatureFlagFlow(driveAlbumsTempDisabledOnRelease(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveAlbumsTempDisabledOnRelease(userId), NOT_FOUND))
fun entries(
driveLinks: List<DriveLink>,
runAction: (suspend () -> Unit) -> Unit,
navigateToMove: (SelectionId, parentId: FolderId?) -> Unit,
navigateToCreateNewAlbum: () -> Unit,
navigateToShareMultiplePhotosOptions: (SelectionId) -> Unit,
dismiss: () -> Unit,
): Flow<List<OptionEntry<Unit>>> = combine(
albumsFeature,
hasPhotoVolume(userId),
albumsKillSwitch,
) { albumsFeatureFlagOn, albumsKillSwitch ->
shareTempDisabled,
) { hasPhotoVolume, albumsKillSwitch, shareDisabled ->
options
.filterAll(driveLinks)
.filterAlbums(albumsFeatureFlagOn, albumsKillSwitch, albumId)
.filterAlbums(hasPhotoVolume, albumsKillSwitch, albumId)
.filterShare(shareDisabled.on, albumId)
.filterPermissions(driveLinks.lowestCommonPermissions)
.map { option ->
when (option) {
is Option.Trash -> option.build(
@@ -165,6 +174,12 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
}
},
)
is Option.ShareMultiplePhotos -> option.build(
runAction = runAction,
navigateToShareMultiplePhotosOptions = {
navigateToShareMultiplePhotosOptions(selectionId)
},
)
else -> throw IllegalStateException(
"Option ${option.javaClass.simpleName} is not found. Did you forget to add it?"
)
@@ -212,6 +227,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
private val options = setOfNotNull(
Option.RemoveFromAlbum,
Option.CreateAlbum,
Option.ShareMultiplePhotos,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Option.Download else null,
Option.Move,
Option.Trash,
@@ -73,6 +73,7 @@ import me.proton.core.drive.drivelink.download.domain.usecase.GetDownloadProgres
import me.proton.core.drive.drivelink.list.domain.usecase.GetPagedDriveLinksList
import me.proton.core.drive.drivelink.offline.domain.usecase.GetPagedOfflineDriveLinksList
import me.proton.core.drive.files.presentation.state.FilesViewState
import me.proton.core.drive.link.domain.entity.AlbumId
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
@@ -206,6 +207,7 @@ class OfflineViewModel @Inject constructor(
fun viewEvent(
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (PagerType, FileId) -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToSortingDialog: (Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateBack: () -> Unit,
@@ -224,6 +226,7 @@ class OfflineViewModel @Inject constructor(
fileId
)
},
navigateToAlbum = navigateToAlbum,
)
}
}
@@ -23,26 +23,33 @@ import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import me.proton.android.drive.ui.viewevent.PhotosAndAlbumsViewEvent
import me.proton.android.drive.ui.viewstate.PhotosAndAlbumsViewState
import me.proton.android.drive.ui.viewstate.PhotosAndAlbumsViewState.Tab
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.volume.domain.usecase.HasPhotoVolume
import javax.inject.Inject
@HiltViewModel
class PhotosAndAlbumsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
hasPhotoVolume: HasPhotoVolume,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val selectedTab = MutableStateFlow(Tab.PHOTOS)
val initialViewState: PhotosAndAlbumsViewState = PhotosAndAlbumsViewState(
selectedTab = Tab.PHOTOS,
isAlbumsTabVisible = false,
)
val viewState: Flow<PhotosAndAlbumsViewState> = selectedTab.map { selectedTab ->
val viewState: Flow<PhotosAndAlbumsViewState> = combine(
selectedTab,
hasPhotoVolume(userId),
) { selectedTab, hasPhotoVolume ->
initialViewState.copy(
selectedTab = selectedTab,
isAlbumsTabVisible = hasPhotoVolume,
)
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewmodel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import me.proton.android.drive.photos.presentation.viewevent.PhotosImportantUpdatesViewEvent
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.entity.TimestampMs
import me.proton.core.drive.base.domain.log.LogTag.PHOTO
import me.proton.core.drive.base.presentation.component.RunAction
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.drivelink.photo.domain.manager.PhotoShareMigrationManager
import me.proton.core.drive.photo.domain.repository.PhotoShareMigrationRepository
import javax.inject.Inject
@HiltViewModel
class PhotosImportantUpdatesViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val photoShareMigrationManager: PhotoShareMigrationManager,
private val repository: PhotoShareMigrationRepository,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
fun viewEvent(
runAction: RunAction,
): PhotosImportantUpdatesViewEvent = object : PhotosImportantUpdatesViewEvent {
override val onStart = { Unit.also { onStart(runAction) } }
override val onRemindMeLater = { Unit.also { onRemindMeLater(runAction) } }
}
private fun onStart(runAction: RunAction) = viewModelScope.launch {
photoShareMigrationManager.start(userId)
.onFailure { error ->
error.log(PHOTO, "Failed starting photo share migration")
}
.getOrNull()
runAction {}
}
private fun onRemindMeLater(runAction: RunAction) = viewModelScope.launch {
repository.setPhotosImportantUpdatesLastShown(userId, TimestampMs())
runAction {}
}
}
@@ -29,6 +29,7 @@ import me.proton.android.drive.photos.domain.usecase.GetPhotoListingCount
import me.proton.android.drive.photos.domain.usecase.RemoveFromAlbumInfo
import me.proton.core.drive.base.domain.extension.getOrNull
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.log.logId
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.toVolumePhotoListing
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
@@ -66,11 +67,9 @@ open class PhotosPickerAndSelectionViewModel(
override fun onDriveLink(driveLink: DriveLink, nonSelectedBlock: () -> Unit) {
if (inPickerMode && driveLink is DriveLink.File) {
if (selected.value.contains(driveLink.id)) {
removeFromAlbum(driveLink)
removeSelected(listOf(driveLink.id))
removeFromAlbumAndFromSelected(driveLink)
} else {
addToAlbum(driveLink)
addSelected(listOf(driveLink.id))
addToAlbumAndToSelected(driveLink)
}
} else {
super.onDriveLink(driveLink, nonSelectedBlock)
@@ -100,22 +99,28 @@ open class PhotosPickerAndSelectionViewModel(
}
}
private fun addToAlbum(driveLink: DriveLink.File) = viewModelScope.launch {
private fun addToAlbumAndToSelected(driveLink: DriveLink.File) = viewModelScope.launch {
val photoListings = setOf(driveLink.toVolumePhotoListing())
if (destinationAlbumId == null) {
addToAlbumInfo(photoListings)
.getOrNull(VIEW_MODEL, "Failed to add to album info for new album ShareId=${driveLink.id.shareId.id.logId()}, LinkId=${driveLink.id.id.logId()}")
} else {
addToAlbumInfo(destinationAlbumId, photoListings)
.getOrNull(VIEW_MODEL, "Failed to add to album info ShareId=${driveLink.id.shareId.id.logId()}, LinkId=${driveLink.id.id.logId()}")
}
addSelected(listOf(driveLink.id))
}
private fun removeFromAlbum(driveLink: DriveLink.File) = viewModelScope.launch {
private fun removeFromAlbumAndFromSelected(driveLink: DriveLink.File) = viewModelScope.launch {
val photoListings = setOf(driveLink.toVolumePhotoListing())
if (destinationAlbumId == null) {
removeFromAlbumInfo(photoListings)
.getOrNull(VIEW_MODEL, "Failed to remove from album info for new album ShareId=${driveLink.id.shareId.id.logId()}, LinkId=${driveLink.id.id.logId()}")
} else {
removeFromAlbumInfo(destinationAlbumId, photoListings)
.getOrNull(VIEW_MODEL, "Failed to remove from album info ShareId=${driveLink.id.shareId.id.logId()}, LinkId=${driveLink.id.id.logId()}")
}
removeSelected(listOf(driveLink.id))
}
companion object {
@@ -36,9 +36,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
@@ -48,6 +49,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import me.proton.android.drive.photos.domain.entity.PhotoBackupState
@@ -57,19 +59,25 @@ import me.proton.android.drive.photos.domain.usecase.GetAddToAlbumPhotoListings
import me.proton.android.drive.photos.domain.usecase.GetPhotoListingCount
import me.proton.android.drive.photos.domain.usecase.GetPhotosDriveLink
import me.proton.android.drive.photos.domain.usecase.RemoveFromAlbumInfo
import me.proton.android.drive.photos.domain.usecase.ShowImportantUpdates
import me.proton.android.drive.photos.domain.usecase.ShowUpsell
import me.proton.android.drive.photos.presentation.R
import me.proton.android.drive.photos.presentation.extension.isSelected
import me.proton.android.drive.photos.presentation.extension.toEmptyPhotoTagState
import me.proton.android.drive.photos.presentation.extension.toPhotosFilter
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.viewmodel.BackupPermissionsViewModel
import me.proton.android.drive.photos.presentation.viewmodel.BackupStatusFormatter
import me.proton.android.drive.photos.presentation.viewmodel.SeparatorFormatter
import me.proton.android.drive.photos.presentation.viewstate.PhotosFilter
import me.proton.android.drive.photos.presentation.viewstate.PhotosViewState
import me.proton.android.drive.ui.common.onClick
import me.proton.android.drive.ui.effect.HomeEffect
import me.proton.android.drive.ui.effect.HomeTabViewModel
import me.proton.android.drive.ui.effect.PhotosEffect
import me.proton.android.drive.usecase.OnFilesDriveLinkError
import me.proton.core.domain.arch.DataResult
import me.proton.core.domain.arch.onSuccess
import me.proton.core.drive.backup.domain.entity.BackupPermissions
import me.proton.core.drive.backup.domain.entity.BackupStatus
@@ -100,15 +108,26 @@ import me.proton.core.drive.base.presentation.extension.quantityString
import me.proton.core.drive.base.presentation.state.ListContentAppendingState
import me.proton.core.drive.base.presentation.state.ListContentState
import me.proton.core.drive.base.presentation.viewmodel.onLoadState
import me.proton.core.drive.base.presentation.viewstate.TagViewState
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.photo.domain.extension.isInProgress
import me.proton.core.drive.drivelink.photo.domain.extension.isPending
import me.proton.core.drive.drivelink.photo.domain.manager.PhotoShareMigrationManager
import me.proton.core.drive.drivelink.photo.domain.paging.PhotoDriveLinks
import me.proton.core.drive.drivelink.photo.domain.usecase.GetPagedPhotoListingsList
import me.proton.core.drive.drivelink.photo.domain.usecase.GetPhotoCount
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.NOT_FOUND
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbumsDisabled
import me.proton.core.drive.feature.flag.domain.extension.off
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.entity.PhotoTag
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
@@ -117,6 +136,7 @@ import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.share.domain.entity.Share
import me.proton.core.drive.user.domain.entity.UserMessage
import me.proton.core.drive.user.domain.usecase.CancelUserMessage
import me.proton.core.drive.volume.domain.usecase.HasPhotoVolume
import me.proton.core.plan.presentation.compose.usecase.ShouldUpgradeStorage
import me.proton.core.util.kotlin.CoreLogger
import java.util.Calendar
@@ -152,13 +172,18 @@ class PhotosViewModel @Inject constructor(
removeFromAlbumInfo: RemoveFromAlbumInfo,
getAddToAlbumPhotoListings: GetAddToAlbumPhotoListings,
getPhotoListingCount: GetPhotoListingCount,
albumsFeatureFlag: AlbumsFeatureFlag,
getFeatureFlagFlow: GetFeatureFlagFlow,
val backupPermissionsViewModel: BackupPermissionsViewModel,
private val photoDriveLinks: PhotoDriveLinks,
private val onFilesDriveLinkError: OnFilesDriveLinkError,
private val syncFolders: SyncFolders,
private val checkMissingFolders: CheckMissingFolders,
private val cancelUserMessage: CancelUserMessage,
private val photoShareMigrationManager: PhotoShareMigrationManager,
private val hasPhotoVolume: HasPhotoVolume,
shouldUpgradeStorage: ShouldUpgradeStorage,
showImportantUpdates: ShowImportantUpdates,
) : PhotosPickerAndSelectionViewModel(
savedStateHandle = savedStateHandle,
selectLinks = selectLinks,
@@ -173,6 +198,15 @@ class PhotosViewModel @Inject constructor(
HomeTabViewModel,
NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) {
private val albumsFeatureFlagOn = combine(
albumsFeatureFlag(userId)
.stateIn(viewModelScope, Eagerly, configurationProvider.albumsFeatureFlag),
getFeatureFlagFlow(driveAlbumsDisabled(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveAlbumsDisabled(userId), NOT_FOUND))
) { featureFlagOn, killSwitch ->
featureFlagOn && killSwitch.off
}
override val driveLinkFilter = { driveLink: DriveLink -> driveLink !is DriveLink.Album }
private var viewEvent: PhotosViewEvent? = null
@@ -194,11 +228,33 @@ class PhotosViewModel @Inject constructor(
emit(PhotosEffect.ShowUpsell)
}
}
private val photosEffectShowImportantUpdates = showImportantUpdates(userId).transform { show ->
if (show) {
CoreLogger.i(PHOTO, "Showing important updates")
emit(PhotosEffect.ShowImportantUpdates)
}
}
val photosEffect: Flow<PhotosEffect> = merge(
_photosEffect.asSharedFlow(),
photosEffectShowUpsell,
photosEffectShowImportantUpdates,
)
private val photoListingsFilter: MutableStateFlow<PhotoTag?> =
MutableStateFlow(null)
private val photosFilters = listOf(
PhotosFilter(
filter = null,
tagViewState = TagViewState(
label = appContext.getString(I18N.string.photos_filter_all),
icon = CorePresentation.drawable.ic_proton_image,
selected = true,
)
),
PhotoTag.Favorites.toPhotosFilter(appContext),
PhotoTag.Videos.toPhotosFilter(appContext),
PhotoTag.Raw.toPhotosFilter(appContext),
)
val initialViewState = PhotosViewState(
title = appContext.getString(I18N.string.photos_title),
navigationIconResId = CorePresentation.drawable.ic_proton_hamburger,
@@ -210,7 +266,11 @@ class PhotosViewModel @Inject constructor(
backupStatusViewState = null,
selected = selected,
isRefreshEnabled = selected.value.isEmpty(),
inMultiselect = false
inMultiselect = false,
filters = emptyList(),
showPhotoShareMigrationInProgress = false,
showPhotoShareMigrationNeededBanner = false,
showStorageBanner = false,
)
private val retryTrigger = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
val driveLink: StateFlow<DriveLink.Folder?> = retryTrigger.transformLatest {
@@ -233,61 +293,65 @@ class PhotosViewModel @Inject constructor(
shareType = Share.Type.PHOTO,
)
error.log(VIEW_MODEL, "Cannot get drive link")
if (previous is DataResult.Success) {
retryLoadingPhotosDriveLinkFolder()
}
}
return@mapWithPrevious null
}
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
}.stateIn(viewModelScope, Eagerly, null)
val driveLinksMap: Flow<Map<LinkId, DriveLink>> = photoDriveLinks.getDriveLinksMapFlow(userId)
val driveLinks: Flow<PagingData<PhotosItem>> =
parentId
.filterNotNull()
.distinctUntilChanged()
.transformLatest { _ ->
emitAll(
getPagedPhotoListingsList(userId)
.map { pagingData ->
pagingData.map { photoListing ->
PhotosItem.PhotoListing(
photoListing.linkId,
photoListing.captureTime,
null
)
}
val driveLinks: Flow<PagingData<PhotosItem>> = combine(
parentId.filterNotNull().distinctUntilChanged(),
photoListingsFilter,
) { _, filter ->
filter
}.transformLatest { filter ->
emit(PagingData.empty())
emitAll(getPagedPhotoListingsList(userId, filter)
.map { pagingData ->
pagingData.map { photoListing ->
PhotosItem.PhotoListing(
photoListing.linkId,
photoListing.captureTime,
null
)
}
}
.map {
it.insertSeparators { before: PhotosItem.PhotoListing?, after: PhotosItem.PhotoListing? ->
if (after == null) {
null
} else if (before == null) {
PhotosItem.Separator(
value = separatorFormatter.toSeparator(after.captureTime),
)
} else {
val beforeCalendar = Calendar.getInstance().apply {
timeInMillis = before.captureTime.value * 1000L
}
.map {
it.insertSeparators { before: PhotosItem.PhotoListing?, after: PhotosItem.PhotoListing? ->
if (after == null) {
null
} else if (before == null) {
PhotosItem.Separator(
value = separatorFormatter.toSeparator(after.captureTime),
)
} else {
val beforeCalendar = Calendar.getInstance().apply {
timeInMillis = before.captureTime.value * 1000L
}
val afterCalendar = Calendar.getInstance().apply {
timeInMillis = after.captureTime.value * 1000L
}
if (beforeCalendar.get(Calendar.YEAR)
!= afterCalendar.get(Calendar.YEAR) ||
beforeCalendar.get(Calendar.MONTH)
!= afterCalendar.get(Calendar.MONTH)
) {
PhotosItem.Separator(
value = separatorFormatter.toSeparator(after.captureTime),
)
} else {
null
}
}
}
val afterCalendar = Calendar.getInstance().apply {
timeInMillis = after.captureTime.value * 1000L
}
)
}.cachedIn(viewModelScope)
if (beforeCalendar.get(Calendar.YEAR)
!= afterCalendar.get(Calendar.YEAR) ||
beforeCalendar.get(Calendar.MONTH)
!= afterCalendar.get(Calendar.MONTH)
) {
PhotosItem.Separator(
value = separatorFormatter.toSeparator(after.captureTime),
)
} else {
null
}
}
}
}
)
}.cachedIn(viewModelScope)
private val listContentAppendingState = MutableStateFlow<ListContentAppendingState>(
ListContentAppendingState.Idle
@@ -328,8 +392,12 @@ class PhotosViewModel @Inject constructor(
getPhotoCount(userId = userId),
firstVisibleItemIndex,
forceStatusExpand,
notificationDotRequested
) { selected, contentState, backupState, count, firstVisibleItemIndex, forceStatusExpand, notificationDotRequested ->
notificationDotRequested,
photoListingsFilter,
albumsFeatureFlagOn,
hasPhotoVolume(userId),
photoShareMigrationManager.status,
) { selected, contentState, backupState, count, firstVisibleItemIndex, forceStatusExpand, notificationDotRequested, photoListingsFilter, albumsFeatureFlagOn, hasPhotoVolume, photoShareMigrationStatus ->
val listContentState = when (contentState) {
is ListContentState.Empty -> contentState.copy(
imageResId = emptyStateImageResId,
@@ -348,6 +416,17 @@ class PhotosViewModel @Inject constructor(
((firstVisibleItemIndex?.let { index -> index > 0 } ?: false)
|| !isDisableOrRunning)
val showHamburgerMenuIcon = selected.isEmpty()
val backupStatusViewState = backupStatusFormatter.toViewState(
backupState = backupState,
count = count.takeIf { configurationProvider.photosSavedCounter },
)
val filters = photosFilters.map { filter ->
filter.copy(
tagViewState = filter.tagViewState.copy(
selected = filter.filter == photoListingsFilter,
),
)
}
initialViewState.copy(
title = if (selected.isNotEmpty()) {
appContext.quantityString(
@@ -368,22 +447,28 @@ class PhotosViewModel @Inject constructor(
showEmptyList = backupState.isBackupEnabled || backupState.hasDefaultFolder == false ,
showPhotosStateIndicator = showPhotosStateIndicator && !inPickerMode,
showPhotosStateBanner = showPhotosStateBanner && !inPickerMode,
backupStatusViewState = backupStatusFormatter.toViewState(
backupState = backupState,
count = count.takeIf { configurationProvider.photosSavedCounter },
),
backupStatusViewState = backupStatusViewState,
isRefreshEnabled = selected.isEmpty(),
filters = filters,
shouldShowFilters = filters.isNotEmpty()
&& hasPhotoVolume
&& (listContentState !is ListContentState.Empty || !filters.isSelected(null)),
emptyPhotoTagState = photoListingsFilter?.toEmptyPhotoTagState(),
showPhotoShareMigrationInProgress = albumsFeatureFlagOn && photoShareMigrationStatus.isInProgress,
showPhotoShareMigrationNeededBanner = albumsFeatureFlagOn && photoShareMigrationStatus.isPending,
showStorageBanner = !inPickerMode,
)
}
fun viewEvent(
navigateToPreview: (fileId: FileId) -> Unit,
navigateToPhotosOptions: (fileId: FileId) -> Unit,
navigateToPreview: (fileId: FileId, photoTag: PhotoTag?) -> Unit,
navigateToPhotosOptions: (fileId: FileId, SelectionId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
navigateToPhotosUpsell: () -> Unit,
navigateToBackupSettings: () -> Unit,
navigateToPhotosImportantUpdates: () -> Unit,
lifecycle: Lifecycle,
): PhotosViewEvent = object : PhotosViewEvent {
@@ -394,7 +479,10 @@ class PhotosViewModel @Inject constructor(
flow.take(1).collect { driveLink ->
driveLink.onClick(
navigateToFolder = { _, _ -> error("Photos should not have folders") },
navigateToPreview = navigateToPreview,
navigateToPreview = { fileId, ->
navigateToPreview(fileId, photoListingsFilter.value)
},
navigateToAlbum = { error("Photos should not have albums") },
)
}
}
@@ -427,7 +515,7 @@ class PhotosViewModel @Inject constructor(
override val onErrorAction = this@PhotosViewModel::onErrorAction
override val onSelectedOptions = {
onSelectedOptions(
{ linkId: FileId, _ -> navigateToPhotosOptions(linkId) },
{ linkId: FileId, _, selectionId -> navigateToPhotosOptions(linkId, selectionId) },
{ selectionId: SelectionId, _ -> navigateToMultiplePhotosOptions(selectionId) },
)
}
@@ -456,6 +544,9 @@ class PhotosViewModel @Inject constructor(
}
}
override val onShowUpsell = navigateToPhotosUpsell
override val onFilterSelected = this@PhotosViewModel::onFilterSelected
override val onStartPhotoShareMigration = this@PhotosViewModel::onStartPhotoShareMigration
override val onShowImportantUpdates = navigateToPhotosImportantUpdates
}.also { viewEvent ->
this.viewEvent = viewEvent
}
@@ -605,4 +696,29 @@ class PhotosViewModel @Inject constructor(
_listEffect.emit(ListEffect.REFRESH)
}
}
private fun onFilterSelected(filter: PhotoTag?) {
viewModelScope.launch {
photoListingsFilter.emit(filter)
listContentState.value = ListContentState.Loading
removeAllSelected()
}
}
private fun onStartPhotoShareMigration() {
viewModelScope.launch {
photoShareMigrationManager.start(userId)
.onFailure { error ->
error.log(VIEW_MODEL, "Failed to start photo share migration")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR,
)
}
}
}
}
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewmodel
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.proton.android.drive.extension.getDefaultMessage
import me.proton.android.drive.extension.log
import me.proton.android.drive.photos.domain.usecase.AddPhotosToAlbum
import me.proton.android.drive.photos.domain.usecase.GetPhotoListingCount
import me.proton.android.drive.photos.domain.usecase.RemoveFromAlbumInfo
import me.proton.android.drive.photos.presentation.extension.processAdd
import me.proton.android.drive.ui.viewevent.PickerPhotosAndAlbumsViewEvent
import me.proton.android.drive.ui.viewstate.PickerPhotosAndAlbumsViewState
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.presentation.extension.quantityString
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.share.domain.entity.ShareId
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
@HiltViewModel
class PickerPhotosViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getPhotoListingCount: GetPhotoListingCount,
@ApplicationContext private val appContext: Context,
private val removeFromAlbumInfo: RemoveFromAlbumInfo,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
private val addPhotosToAlbum: AddPhotosToAlbum,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
val destinationAlbumId = savedStateHandle.get<String?>(DESTINATION_SHARE_ID)?.let { destinationShareId ->
savedStateHandle.get<String?>(DESTINATION_ALBUM_ID)?.let { destinationAlbumId ->
AlbumId(ShareId(userId, destinationShareId), destinationAlbumId)
}
}
private val addingInProgress = MutableStateFlow(false)
private val photoListingsCount: StateFlow<Int?> = getPhotoListingCount(userId, destinationAlbumId)
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val viewState = combine(
addingInProgress,
photoListingsCount.filterNotNull(),
) { inProgress, count ->
PickerPhotosAndAlbumsViewState(
addToAlbumButtonTitle = if (count == 0) {
appContext.getString(I18N.string.albums_add_zero_to_album_button)
} else {
appContext.quantityString(
pluralRes = I18N.plurals.albums_add_non_zero_to_album_button,
quantity = count,
)
},
isAddToAlbumButtonEnabled = (count > 0) && !inProgress,
isResetButtonEnabled = (count > 0) && !inProgress,
isAddingInProgress = inProgress,
)
}
fun viewEvent(
navigateBack: () -> Unit,
onAddToAlbumDone: () -> Unit,
): PickerPhotosAndAlbumsViewEvent = object : PickerPhotosAndAlbumsViewEvent {
override val onBackPressed = { navigateBack() }
override val onReset = this@PickerPhotosViewModel::onReset
override val onAddToAlbum = { onAddToAlbum(onAddToAlbumDone) }
}
private fun onAddToAlbum(onDone: () -> Unit) {
viewModelScope.launch {
if (destinationAlbumId == null) {
onDone()
} else {
// add photos from add to album info into destination album
addingInProgress.value = true
showAddToAlbumStartMessage()
addPhotosToAlbum(destinationAlbumId)
.onFailure { error ->
addingInProgress.value = false
error.log(VIEW_MODEL, "Failed adding photos to album")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
context = appContext,
useExceptionMessage = configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR,
)
}
.onSuccess { result ->
addingInProgress.value = false
result
.processAdd(appContext) { message, type ->
broadcastMessages(
userId = userId,
message = message,
type = type,
)
}
onDone()
}
}
}
}
private fun onReset() {
viewModelScope.launch {
removeFromAlbumInfo(userId, destinationAlbumId)
.onFailure { error ->
error.log(VIEW_MODEL, "Failed removing all photo listings from add to album")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
context = appContext,
useExceptionMessage = configurationProvider.useExceptionMessage
)
)
}
}
}
private fun showAddToAlbumStartMessage() {
broadcastMessages(
userId = userId,
message = appContext.getString(I18N.string.albums_add_to_album_start_message),
type = BroadcastMessage.Type.INFO,
)
}
companion object {
const val DESTINATION_SHARE_ID = "destinationShareId"
const val DESTINATION_ALBUM_ID = "destinationAlbumId"
}
}
@@ -55,4 +55,11 @@ class PlansViewModel @Inject constructor(
plansOrchestrator.showCurrentPlanWorkflow(account.userId)
}
}
fun startUpgrade() {
viewModelScope.launch {
val account = primaryAccount.filterNotNull().first()
plansOrchestrator.startUpgradeWorkflow(account.userId)
}
}
}
@@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import me.proton.android.drive.BuildConfig
import me.proton.android.drive.extension.getDefaultMessage
import me.proton.android.drive.extension.log
import me.proton.android.drive.ui.effect.PreviewEffect
@@ -104,6 +105,7 @@ import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.Link
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.entity.PhotoTag
import me.proton.core.drive.link.domain.extension.isProtonDocument
import me.proton.core.drive.link.domain.extension.requireFolderId
import me.proton.core.drive.link.domain.extension.rootFolderId
@@ -159,6 +161,9 @@ class PreviewViewModel @Inject constructor(
id = albumIdString,
)
}
private val photoTag = savedStateHandle.get<String>(Screen.PagerPreview.PHOTO_TAG)?.let { tag ->
PhotoTag.fromLong(tag.toLong())
}
private val trigger = MutableSharedFlow<Trigger>(1).apply {
val shareId = savedStateHandle.require<String>(Screen.PagerPreview.SHARE_ID)
val fileId = savedStateHandle.require<String>(Screen.PagerPreview.FILE_ID)
@@ -206,6 +211,7 @@ class PreviewViewModel @Inject constructor(
)
PagerType.PHOTO -> PhotoContentProvider(
userId = userId,
photoTag = photoTag,
getDecryptedDriveLink = getDecryptedDriveLink,
getPhotoShare = getPhotoShare,
photoRepository = photoRepository,
@@ -275,7 +281,9 @@ class PreviewViewModel @Inject constructor(
},
currentIndex = currentIndex.value,
)
CoreLogger.d(VIEW_MODEL, "$previewViewState")
if (BuildConfig.DEBUG) {
CoreLogger.d(VIEW_MODEL, "$previewViewState")
}
emit(previewViewState)
}.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
@@ -715,6 +723,7 @@ class PhotoContentProvider(
getPhotoShare: GetPhotoShare,
photoRepository: PhotoRepository,
userId: UserId,
photoTag: PhotoTag?,
configurationProvider: ConfigurationProvider,
coroutineScope: CoroutineScope,
) : PreviewContentProvider {
@@ -729,14 +738,20 @@ class PhotoContentProvider(
.distinctUntilChanged()
.transform { photoShare ->
emitAll(
photoRepository.getPhotoListingCount(userId, photoShare.volumeId)
photoRepository.getPhotoListingCount(userId, photoShare.volumeId, photoTag)
.distinctUntilChanged()
.transformLatest {
emit(
pagedList(
pageSize = configurationProvider.dbPageSize,
) { fromIndex, count ->
photoRepository.getPhotoListings(userId, photoShare.volumeId, fromIndex, count)
photoRepository.getPhotoListings(
userId = userId,
volumeId = photoShare.volumeId,
fromIndex = fromIndex,
count = count,
tag = photoTag,
)
}
)
}
@@ -133,12 +133,12 @@ open class SelectionViewModel(
}
protected inline fun <reified T : LinkId> onSelectedOptions(
navigateToFileOrFolderOptions: (linkId: T, albumId: AlbumId?) -> Unit,
navigateToFileOrFolderOptions: (linkId: T, albumId: AlbumId?, selectionId: SelectionId?) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, albumId: AlbumId?) -> Unit,
albumId: AlbumId? = null,
) {
if (selected.value.size == 1) {
navigateToFileOrFolderOptions(selected.value.first() as T, albumId)
navigateToFileOrFolderOptions(selected.value.first() as T, albumId, selectionId.value)
} else {
selectionId.value?.let { selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, albumId) }
}
@@ -0,0 +1,261 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewmodel
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import me.proton.android.drive.extension.getDefaultMessage
import me.proton.android.drive.extension.log
import me.proton.android.drive.photos.domain.usecase.AddPhotosToAlbum
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
import me.proton.android.drive.photos.presentation.extension.processAdd
import me.proton.android.drive.photos.presentation.state.AlbumsItem
import me.proton.android.drive.ui.viewevent.ShareMultiplePhotosOptionsViewEvent
import me.proton.android.drive.ui.viewstate.ShareMultiplePhotosOptionsViewState
import me.proton.core.domain.arch.DataResult
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.presentation.component.RunAction
import me.proton.core.drive.base.presentation.extension.require
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.extension.toVolumePhotoListing
import me.proton.core.drive.drivelink.photo.domain.paging.PhotoDriveLinks
import me.proton.core.drive.drivelink.photo.domain.usecase.GetAllAlbumListings
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
import me.proton.core.drive.files.presentation.entry.OptionEntry
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.photo.domain.entity.AlbumListing
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
import me.proton.core.drive.share.domain.entity.Share
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
@HiltViewModel
class ShareMultiplePhotosOptionsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getSelectedDriveLinks: GetSelectedDriveLinks,
getPhotoShare: GetPhotoShare,
@ApplicationContext private val appContext: Context,
private val getAllAlbumListings: GetAllAlbumListings,
private val photoDriveLinks: PhotoDriveLinks,
private val deselectLinks: DeselectLinks,
private val addToAlbumInfo: AddToAlbumInfo,
private val addPhotosToAlbum: AddPhotosToAlbum,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
val selectionId = SelectionId(savedStateHandle.require(SELECTION_ID))
private val selectedDriveLinks: Flow<List<DriveLink>> = getSelectedDriveLinks(selectionId)
private var fetchingJob: Job? = null
private var runAction: RunAction? = null
private var navigateToCreateNewAlbum: (() -> Unit)? = null
private var navigateToAlbum: ((AlbumId) -> Unit)? = null
private val driveLinksMap: StateFlow<Map<LinkId, DriveLink>> =
photoDriveLinks.getDriveLinksMapFlow(userId)
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
private val photoShare: StateFlow<Share?> = getPhotoShare(userId)
.filterSuccessOrError()
.mapSuccessValueOrNull()
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val allAlbumListings: StateFlow<List<AlbumListing>> = photoShare
.filterNotNull()
.distinctUntilChanged()
.transform { photoShare ->
emitAll(
getAllAlbumListings(
userId = userId,
volumeId = photoShare.volumeId,
filterBy = flowOf(AlbumListing.Filter.SHARED_BY_ME),
)
)
}
.transform { result ->
if (result is DataResult.Success) {
emit(result.value)
}
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val sharedAlbums: Flow<List<AlbumsItem.Listing>> = combine(
allAlbumListings,
driveLinksMap,
) { listings, links ->
listings.map { albumListing ->
AlbumsItem.Listing(
id = albumListing.albumId,
isLocked = albumListing.isLocked,
photoCount = albumListing.photoCount,
lastActivityTime = albumListing.lastActivityTime,
isShared = albumListing.isShared,
album = links[albumListing.albumId] as? DriveLink.Album,
coverLink = albumListing.coverLinkId?.let { coverLinkId ->
links[coverLinkId] as? DriveLink.File
},
albumDetails = (links[albumListing.albumId] as? DriveLink.Album).details(),
)
}
}
val initialViewState: ShareMultiplePhotosOptionsViewState =
ShareMultiplePhotosOptionsViewState(
shareOptionsSectionTitleResId = I18N.string.albums_share_multiple_photos_options_section_title,
shareOptions = listOf(
object : OptionEntry<Unit> {
override val icon = CorePresentation.drawable.ic_proton_users
override val label = I18N.string.albums_share_multiple_photos_options_new_shared_album
override val onClick = { _: Unit ->
runAction?.invoke { onCreateNewAlbum() }
Unit
}
},
),
sharedAlbumsSectionTitleResId = I18N.string.albums_share_multiple_photos_options_shared_albums_section_title,
sharedAlbums = sharedAlbums,
)
fun viewEvent(
runAction: RunAction,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
): ShareMultiplePhotosOptionsViewEvent = object : ShareMultiplePhotosOptionsViewEvent {
override val onScroll = this@ShareMultiplePhotosOptionsViewModel::onScroll
override val onSharedAlbum = { albumId: AlbumId -> runAction { onSharedAlbum(albumId) } }
}.also {
this.runAction = runAction
this.navigateToCreateNewAlbum = navigateToCreateNewAlbum
this.navigateToAlbum = navigateToAlbum
}
private fun onScroll(driveLinkIds: Set<LinkId>) {
if (driveLinkIds.isNotEmpty()) {
fetchingJob?.cancel()
fetchingJob = viewModelScope.launch {
photoDriveLinks.load(driveLinkIds)
}
}
}
private fun onSharedAlbum(albumId: AlbumId) {
viewModelScope.launch {
selectedDriveLinks.firstOrNull()?.let { driveLinks ->
driveLinks
.filterIsInstance<DriveLink.File>()
.map { photo -> photo.toVolumePhotoListing() }
.let { photoListings ->
addToAlbumInfo(albumId, photoListings.toSet())
.onFailure { error ->
error.log(VIEW_MODEL, "Failed to add photos to album info")
error.broadcast()
}
.onSuccess {
deselectLinks(selectionId)
showAddToAlbumStartMessage()
addPhotosToAlbum(albumId)
.onFailure { error ->
error.log(VIEW_MODEL, "Failed to add photos to album")
error.broadcast()
}
.onSuccess { result ->
result.processAdd(appContext) { message, type ->
broadcastMessages(
userId = userId,
message = message,
type = type,
)
}
navigateToAlbum?.invoke(albumId)
}
}
}
}
}
}
private fun onCreateNewAlbum() {
viewModelScope.launch {
selectedDriveLinks.firstOrNull()?.let { driveLinks ->
driveLinks
.filterIsInstance<DriveLink.File>()
.map { photo -> photo.toVolumePhotoListing() }
.let { photoListings ->
addToAlbumInfo(photoListings.toSet())
.onFailure { error ->
error.log(VIEW_MODEL, "Failed to add photos to album info")
error.broadcast()
}
.onSuccess {
deselectLinks(selectionId)
navigateToCreateNewAlbum?.invoke()
}
}
}
}
}
private fun DriveLink.Album?.details(): String? = this?.let {
appContext.getString(I18N.string.albums_share_multiple_photos_options_album_details)
}
private fun Throwable.broadcast() =
broadcastMessages(
userId = userId,
message = getDefaultMessage(
context = appContext,
useExceptionMessage = configurationProvider.useExceptionMessage,
),
type = BroadcastMessage.Type.ERROR,
)
private fun showAddToAlbumStartMessage() {
broadcastMessages(
userId = userId,
message = appContext.getString(I18N.string.albums_add_to_album_start_message),
type = BroadcastMessage.Type.INFO,
)
}
companion object {
const val SELECTION_ID = "selectionId"
}
}
@@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
@@ -50,9 +49,7 @@ import me.proton.core.drive.drivelink.shared.presentation.viewstate.UserInvitati
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.NOT_FOUND
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveMobileSharingInvitationsAcceptReject
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveSharingDisabled
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.share.user.domain.entity.SharedLinkId
import me.proton.core.drive.share.user.domain.usecase.GetAllSharedWithMeIds
@@ -116,24 +113,20 @@ class SharedWithMeViewModel @Inject constructor(
override suspend fun getAllIds(): Result<List<SharedLinkId>> = getAllSharedWithMeIds(userId)
val userInvitationBannerViewState =
getFeatureFlagFlow(driveMobileSharingInvitationsAcceptReject(userId)).transform { featureFlag ->
if (featureFlag.on) {
emitAll(getUserInvitationCountFlow(userId, refresh = flowOf(true))
.filterSuccessOrError()
.mapSuccessValueOrNull()
.filterNotNull()
.filter { count -> count > 0 }
.map { count ->
UserInvitationBannerViewState(
appContext.quantityString(
I18N.plurals.shared_by_me_invitation_banner_description,
count
)
)
}
val userInvitationBannerViewState = getUserInvitationCountFlow(
userId = userId,
refresh = flowOf(true),
).filterSuccessOrError()
.mapSuccessValueOrNull()
.filterNotNull()
.filter { count -> count > 0 }
.map { count ->
UserInvitationBannerViewState(
appContext.quantityString(
I18N.plurals.shared_by_me_invitation_banner_description,
count
)
}
)
}
}
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewmodel
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import me.proton.android.drive.R
import me.proton.android.drive.log.DriveLogTag.DEFAULT
import me.proton.android.drive.ui.viewevent.DriveLitePopupViewEvent
import me.proton.android.drive.ui.viewstate.SubscriptionPromoViewState
import me.proton.android.drive.usecase.MarkSubscriptionPromoAsShown
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.extension.GiB
import me.proton.core.drive.base.presentation.component.RunAction
import me.proton.core.drive.base.presentation.extension.asHumanReadableString
import me.proton.core.drive.base.presentation.extension.require
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.plan.domain.entity.DynamicPlan
import me.proton.core.plan.domain.usecase.GetDynamicPlansAdjustedPrices
import me.proton.core.plan.domain.usecase.ObserveUserCurrency
import me.proton.core.plan.presentation.entity.PlanCycle
import me.proton.core.presentation.utils.formatCentsPriceDefaultLocale
import java.text.NumberFormat
import java.util.Locale
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
@HiltViewModel
class SubscriptionPromoViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
savedStateHandle: SavedStateHandle,
private val markSubscriptionPromoAsShown: MarkSubscriptionPromoAsShown,
private val getDynamicPlansAdjustedPrices: GetDynamicPlansAdjustedPrices,
observeUserCurrency: ObserveUserCurrency,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
val key: String = savedStateHandle.require(PROMO_KEY)
val viewState = observeUserCurrency(userId).map { userCurrency ->
val plans = getDynamicPlansAdjustedPrices(userId)
val plan = plans.plans.firstOrNull { plan -> plan.name == key }
if (plan != null) {
val currency = plan.getCurrency(userCurrency)
when (key) {
PLAN_NAME_PLUS -> drivePlusViewState(plan, currency, PlanCycle.MONTHLY)
PLAN_NAME_LITE -> driveLiteViewState(plan, currency, PlanCycle.MONTHLY)
else -> null
}
} else {
null
}
}
private fun DynamicPlan.getCurrency(
userCurrency: String
): String {
val instanceCurrencies = instances.flatMap { it.value.price.keys }.toSet().toList()
val currencies = when {
instanceCurrencies.contains(userCurrency) -> listOf(userCurrency) + (instanceCurrencies - userCurrency)
else -> instanceCurrencies
}
val currency = currencies.firstOrNull { it == userCurrency }
?: currencies.firstOrNull() ?: userCurrency
return currency
}
private fun drivePlusViewState(
plan: DynamicPlan,
currency: String,
planCycle: PlanCycle,
): SubscriptionPromoViewState {
val price = plan.instances[planCycle.value]?.price?.get(currency)
val firstMonthPriceString =
(price?.current)?.toDouble()?.formatCentsPriceDefaultLocale(currency).orEmpty()
val priceString =
(price?.default)?.toDouble()?.formatCentsPriceDefaultLocale(currency).orEmpty()
val photoCountString = NumberFormat.getNumberInstance(Locale.getDefault()).format(50_000)
val storageString = 200.GiB.asHumanReadableString(appContext, numberOfDecimals = 0)
return SubscriptionPromoViewState(
image = R.drawable.img_drive_plus,
title = appContext.getString(I18N.string.drive_plus_promo_title)
.format(storageString, firstMonthPriceString),
description = appContext.getString(I18N.string.drive_plus_promo_description)
.format(
photoCountString,
firstMonthPriceString,
priceString
),
actionText = appContext.getString(I18N.string.drive_plus_promo_action)
.format(firstMonthPriceString)
)
}
private fun driveLiteViewState(
plan: DynamicPlan,
currency: String,
planCycle: PlanCycle,
): SubscriptionPromoViewState {
val price = plan.instances[planCycle.value]?.price?.get(currency)
val priceString =
(price?.default)?.toDouble()?.formatCentsPriceDefaultLocale(currency).orEmpty()
val name = plan.title
val storageString = 20.GiB.asHumanReadableString(appContext, numberOfDecimals = 0)
return SubscriptionPromoViewState(
image = R.drawable.img_drive_lite,
title = appContext.getString(I18N.string.drive_lite_promo_title)
.format(priceString),
description = appContext.getString(I18N.string.drive_lite_promo_description)
.format(name, storageString, priceString),
actionText = appContext.getString(I18N.string.drive_lite_promo_action)
.format(name)
)
}
fun viewEvent(
runAction: RunAction,
navigateToSubscription: () -> Unit,
): DriveLitePopupViewEvent = object : DriveLitePopupViewEvent {
override val onGetSubscription = {
runAction {
navigateToSubscription()
}
}
override val onCancel = {
runAction {
}
}
override val onDismiss = {
viewModelScope.launch {
markSubscriptionPromoAsShown(userId, key).onFailure { error ->
error.log(DEFAULT, "Cannot mark subscription promo as shown")
}
}
Unit
}
}
companion object {
const val PROMO_KEY = "key"
private const val PLAN_NAME_LITE = "drivelite2024"
private const val PLAN_NAME_PLUS = "drive2022"
}
}
@@ -182,6 +182,7 @@ class SyncedFoldersViewModel @Inject constructor(
driveLink.onClick(
navigateToFolder = navigateToFiles,
navigateToPreview = { error("Preview is not supported here") },
navigateToAlbum = { error("Album is not supported here") },
)
}
}
@@ -63,7 +63,7 @@ import me.proton.core.drive.trash.domain.TrashManager
import me.proton.core.drive.trash.domain.usecase.GetEmptyTrashState
import me.proton.core.drive.volume.domain.entity.Volume
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.drive.volume.domain.usecase.GetVolumes
import me.proton.core.drive.volume.domain.usecase.GetActiveVolumes
import me.proton.drive.android.settings.domain.entity.LayoutType
import me.proton.drive.android.settings.domain.usecase.GetLayoutType
import me.proton.drive.android.settings.domain.usecase.ToggleLayoutType
@@ -84,7 +84,7 @@ class TrashViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val toggleLayoutType: ToggleLayoutType,
private val configurationProvider: ConfigurationProvider,
getVolumes: GetVolumes,
getActiveVolumes: GetActiveVolumes,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val volumeIdFlow = MutableStateFlow<VolumeId?>(null)
@@ -108,7 +108,7 @@ class TrashViewModel @Inject constructor(
listContentState,
listContentAppendingState,
layoutType,
getVolumes(userId).mapSuccessValueOrNull(),
getActiveVolumes(userId).mapSuccessValueOrNull(),
) { sorting, contentState, appendingState, layoutType, volumes ->
val listContentState = when (contentState) {
is ListContentState.Empty -> contentState.copy(
@@ -70,6 +70,7 @@ class UserInvitationViewModel @Inject constructor(
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val albumsOnly: Boolean = savedStateHandle[ALBUMS_ONLY] ?: false
private val emptyState = ListContentState.Empty(
imageResId = getThemeDrawableId(
@@ -77,7 +78,11 @@ class UserInvitationViewModel @Inject constructor(
dark = R.drawable.empty_shared_with_me_dark,
dayNight = R.drawable.empty_shared_with_me_daynight,
),
titleId = I18N.string.shared_user_invitations_title_empty,
titleId = if (albumsOnly) {
I18N.string.shared_user_album_invitations_title_empty
} else {
I18N.string.shared_user_invitations_title_empty
},
descriptionResId = I18N.string.shared_user_invitations_description_empty
)
@@ -85,7 +90,7 @@ class UserInvitationViewModel @Inject constructor(
MutableStateFlow(ListContentState.Loading)
val userInvitations: StateFlow<List<UserInvitation>?> =
getDecryptedUserInvitationsFlow(userId).transform { result ->
getDecryptedUserInvitationsFlow(userId, albumsOnly).transform { result ->
when (result) {
is DataResult.Processing -> listContentState.value = ListContentState.Loading
is DataResult.Error -> {
@@ -111,7 +116,13 @@ class UserInvitationViewModel @Inject constructor(
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val initialViewState = UserInvitationViewState(
title = appContext.getString(I18N.string.shared_user_invitations_title).format(0),
title = appContext.getString(
if (albumsOnly) {
I18N.string.shared_user_album_invitations_title
} else {
I18N.string.shared_user_invitations_title
}
).format(0),
navigationIconResId = CorePresentation.drawable.ic_proton_arrow_back,
listContentState = listContentState.value,
)
@@ -121,8 +132,13 @@ class UserInvitationViewModel @Inject constructor(
userInvitations,
) { state, userInvitations ->
initialViewState.copy(
title = appContext.getString(I18N.string.shared_user_invitations_title)
.format(userInvitations.orEmpty().size),
title = appContext.getString(
if (albumsOnly) {
I18N.string.shared_user_album_invitations_title
} else {
I18N.string.shared_user_invitations_title
}
).format(userInvitations.orEmpty().size),
listContentState = state,
)
}
@@ -151,7 +167,13 @@ class UserInvitationViewModel @Inject constructor(
}.onSuccess {
broadcastMessages(
userId,
appContext.getString(I18N.string.shared_user_invitations_accept_success),
appContext.getString(
if (albumsOnly) {
I18N.string.shared_user_album_invitations_accept_success
} else {
I18N.string.shared_user_invitations_accept_success
}
),
)
}
}
@@ -175,9 +197,19 @@ class UserInvitationViewModel @Inject constructor(
}.onSuccess {
broadcastMessages(
userId,
appContext.getString(I18N.string.shared_user_invitations_decline_success),
appContext.getString(
if (albumsOnly) {
I18N.string.shared_user_album_invitations_decline_success
} else {
I18N.string.shared_user_invitations_decline_success
}
),
)
}
}
}
companion object {
const val ALBUMS_ONLY = "albumsOnly"
}
}
@@ -49,12 +49,13 @@ class WhatsNewViewModel @Inject constructor(
val viewState: Flow<WhatsNewViewState?> = flowOf {
when (key) {
WhatsNewKey.PUBLIC_SHARING -> WhatsNewViewState(
title = appContext.getString(I18N.string.whats_new_public_sharing_title),
description = appContext.getString(I18N.string.whats_new_public_sharing_description),
action = appContext.getString(I18N.string.whats_new_public_sharing_action),
image = BasePresentation.drawable.img_whats_new_public_sharing,
WhatsNewKey.ALBUMS -> WhatsNewViewState(
title = appContext.getString(I18N.string.whats_new_albums_title),
description = appContext.getString(I18N.string.whats_new_albums_description),
action = appContext.getString(I18N.string.whats_new_albums_action),
image = BasePresentation.drawable.img_whats_new_albums,
)
else -> null
}
}
@@ -20,6 +20,7 @@ package me.proton.android.drive.ui.viewstate
data class PhotosAndAlbumsViewState(
val selectedTab: Tab,
val isAlbumsTabVisible: Boolean,
) {
enum class Tab {
PHOTOS, ALBUMS
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewstate
import kotlinx.coroutines.flow.Flow
import me.proton.android.drive.photos.presentation.state.AlbumsItem
import me.proton.core.drive.files.presentation.entry.OptionEntry
data class ShareMultiplePhotosOptionsViewState(
val shareOptionsSectionTitleResId: Int,
val shareOptions: List<OptionEntry<Unit>>,
val sharedAlbumsSectionTitleResId: Int,
val sharedAlbums: Flow<List<AlbumsItem.Listing>>,
)
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewstate
import androidx.annotation.DrawableRes
data class SubscriptionPromoViewState(
@DrawableRes val image: Int,
val title: String,
val description: String,
val actionText: String,
)
@@ -19,14 +19,12 @@
package me.proton.android.drive.usecase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import me.proton.android.drive.log.DriveLogTag
import me.proton.android.drive.ui.navigation.Screen
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.extension.getOrNull
import me.proton.core.drive.base.domain.usecase.HasBusinessPlan
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
import me.proton.drive.android.settings.domain.entity.DynamicHomeTab
import me.proton.drive.android.settings.domain.entity.HomeTab
import me.proton.drive.android.settings.domain.usecase.GetHomeTab
@@ -37,17 +35,15 @@ import me.proton.core.presentation.R as CorePresentation
class GetDynamicHomeTabsFlow @Inject constructor(
private val getHomeTab: GetHomeTab,
private val hasBusinessPlan: HasBusinessPlan,
private val albumsFeatureFlag: AlbumsFeatureFlag,
) {
operator fun invoke(userId: UserId): Flow<List<DynamicHomeTab>> = getHomeTab(userId)
.map { userDefaultHomeTab ->
val areAlbumsEnabled = albumsFeatureFlag(userId).firstOrNull() ?: false
HomeTab
.entries
.map { homeTab: HomeTab ->
DynamicHomeTab(
id = homeTab,
route = homeTab.route(areAlbumsEnabled),
route = homeTab.route(),
order = homeTab.ordinal,
iconResId = homeTab.iconResId(),
titleResId = homeTab.titleResId,
@@ -61,9 +57,9 @@ class GetDynamicHomeTabsFlow @Inject constructor(
.ifNoDefaultMakeFirstDefault()
}
private fun HomeTab.route(areAlbumsEnabled: Boolean): String = when (this) {
private fun HomeTab.route(): String = when (this) {
HomeTab.FILES -> Screen.Files.route
HomeTab.PHOTOS -> if (areAlbumsEnabled) Screen.PhotosAndAlbums.route else Screen.Photos.route
HomeTab.PHOTOS -> Screen.PhotosAndAlbums.route
HomeTab.COMPUTERS -> Screen.Computers.route
HomeTab.SHARED -> Screen.SharedTabs.route
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.usecase
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.last
import me.proton.core.drive.base.data.extension.getDefaultMessage
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.extension.toResult
import me.proton.core.drive.base.domain.log.LogTag
import me.proton.core.drive.base.domain.log.logId
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.shared.domain.extension.sharingDetails
import me.proton.core.drive.link.domain.extension.shareId
import me.proton.core.drive.link.domain.extension.userId
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.share.user.domain.usecase.LeaveShare
import me.proton.core.util.kotlin.CoreLogger
import javax.inject.Inject
class LeaveShare @Inject constructor(
@ApplicationContext private val appContext: Context,
private val leaveShare: LeaveShare,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
) {
suspend operator fun invoke(driveLink: DriveLink): Result<Boolean> = coRunCatching {
val shareId = driveLink.sharingDetails?.shareId
val memberId = driveLink.shareUser?.id
if (shareId != null && memberId != null && shareId == driveLink.shareId) {
leaveShare(driveLink.volumeId, driveLink.id, memberId).last().toResult()
.onFailure { error ->
error.log(LogTag.SHARING, "Cannot leave share")
broadcastMessages(
userId = driveLink.userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR
)
}.getOrThrow()
true
} else {
CoreLogger.i(
tag = LogTag.SHARING,
message = """
Skipping leave share (DriveLink.shareId=${driveLink.shareId.id.logId()},
SharingDetails.shareId=${shareId?.id?.logId()}, memberId=${memberId?.logId()})
""".trimIndent(),
)
false
}
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.usecase
import androidx.datastore.preferences.core.edit
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.data.datastore.GetUserDataStore
import me.proton.core.drive.base.data.datastore.GetUserDataStore.Keys.subscriptionLastUpdate
import me.proton.core.drive.base.domain.util.coRunCatching
import java.util.Date
import javax.inject.Inject
class MarkSubscriptionPromoAsShown @Inject constructor(
private val getUserDataStore: GetUserDataStore,
) {
suspend operator fun invoke(userId: UserId, key: String) = coRunCatching {
getUserDataStore(userId).edit { preferences ->
preferences[subscriptionLastUpdate(key)] = Date().time
}
}
}
@@ -30,22 +30,27 @@ class ShouldShowOverlay @Inject constructor(
private val shouldShowOnboarding: ShouldShowOnboarding,
private val shouldShowWhatsNew: ShouldShowWhatsNew,
private val shouldShowRatingBooster: ShouldShowRatingBooster,
private val shouldShowSubscriptionPromo: ShouldShowSubscriptionPromo,
private val repository: UiSettingsRepository,
) {
suspend operator fun invoke(userId: UserId): Result<UserOverlay?> = coRunCatching {
if (repository.hasShownOverlay()) {
null
} else if (shouldShowOnboarding(userId).getOrThrow()) {
UserOverlay.Onboarding
return@coRunCatching null
}
if (shouldShowOnboarding(userId).getOrThrow()) {
return@coRunCatching UserOverlay.Onboarding
}
val subscriptionPromo = shouldShowSubscriptionPromo(userId).getOrThrow()
if (subscriptionPromo != null) {
return@coRunCatching subscriptionPromo
}
val whatsNewKey = shouldShowWhatsNew(userId).getOrThrow()
if (whatsNewKey != null) {
UserOverlay.WhatsNew(whatsNewKey)
} else if (shouldShowRatingBooster(userId).getOrThrow()) {
UserOverlay.RatingBooster
} else {
val whatsNewKey = shouldShowWhatsNew(userId).getOrThrow()
if (whatsNewKey != null) {
UserOverlay.WhatsNew(whatsNewKey)
} else if (shouldShowRatingBooster(userId).getOrThrow()) {
UserOverlay.RatingBooster
} else {
null
}
null
}
}
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.usecase
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.data.datastore.GetUserDataStore
import me.proton.core.drive.base.data.datastore.GetUserDataStore.Keys.subscriptionLastUpdate
import me.proton.core.drive.base.data.extension.get
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
import me.proton.core.drive.feature.flag.domain.extension.off
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlag
import me.proton.core.payment.domain.PaymentManager
import me.proton.core.plan.domain.usecase.GetDynamicPlansAdjustedPrices
import me.proton.core.plan.presentation.entity.PlanCycle
import me.proton.core.user.domain.extension.hasSubscription
import me.proton.core.user.domain.usecase.GetUser
import me.proton.drive.android.settings.domain.entity.UserOverlay
import javax.inject.Inject
class ShouldShowSubscriptionPromo @Inject constructor(
private val getFeatureFlag: GetFeatureFlag,
private val getUser: GetUser,
private val paymentManager: PaymentManager,
private val getDynamicPlansAdjustedPrices: GetDynamicPlansAdjustedPrices,
private val getUserDataStore: GetUserDataStore,
) {
suspend operator fun invoke(userId: UserId): Result<UserOverlay.Subcription?> = coRunCatching {
if (!paymentManager.isSubscriptionAvailable(userId)) {
return@coRunCatching null
}
if (getFeatureFlag(FeatureFlagId.drivePlusPlanIntro(userId)).on) {
val subscription = subscription(userId, PLAN_NAME_PLUS, PlanCycle.MONTHLY)
if (subscription != null) {
return@coRunCatching subscription
}
}
if (getFeatureFlag(FeatureFlagId.driveOneDollarPlanUpsell(userId)).on) {
subscription(userId, PLAN_NAME_LITE, PlanCycle.MONTHLY)
} else {
null
}
}
private suspend fun subscription(
userId: UserId,
name: String,
cycle: PlanCycle,
): UserOverlay.Subcription? {
val isFreeUser = getUser(userId, false).hasSubscription().not()
val plans = getDynamicPlansAdjustedPrices(userId).plans
val lastUpdate = getUserDataStore(userId).get(subscriptionLastUpdate(name))
val hasPlan = plans.any { plan -> plan.name == name && cycle.value in plan.instances.keys }
return if (isFreeUser && hasPlan && lastUpdate == null) {
UserOverlay.Subcription(name)
} else {
null
}
}
private companion object {
const val PLAN_NAME_LITE = "drivelite2024"
const val PLAN_NAME_PLUS = "drive2022"
}
}
@@ -24,6 +24,7 @@ import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAndroidWhatsNew
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.drive.android.settings.domain.entity.WhatsNewKey
import javax.inject.Inject
@@ -33,6 +34,7 @@ import javax.inject.Singleton
class ShouldShowWhatsNew @Inject constructor(
private val wasWhatsNewShown: WasWhatsNewShown,
private val getFeatureFlagFlow: GetFeatureFlagFlow,
private val albumsFeatureFlag: AlbumsFeatureFlag,
) {
suspend operator fun invoke(userId: UserId): Result<WhatsNewKey?> = coRunCatching {
@@ -42,7 +44,7 @@ class ShouldShowWhatsNew @Inject constructor(
).first().on
) {
when {
canShow(WhatsNewKey.PUBLIC_SHARING) -> WhatsNewKey.PUBLIC_SHARING
canShow(WhatsNewKey.ALBUMS) && albumsOn(userId) -> WhatsNewKey.ALBUMS
else -> null
}
} else {
@@ -52,4 +54,6 @@ class ShouldShowWhatsNew @Inject constructor(
private suspend fun ShouldShowWhatsNew.canShow(key: WhatsNewKey) =
TimestampS() < key.limit && wasWhatsNewShown(key).getOrThrow().not()
private suspend fun albumsOn(userId: UserId): Boolean = albumsFeatureFlag(userId).first()
}
@@ -65,7 +65,7 @@ class BackupNotificationBuilder @Inject constructor(
): NotificationCompat.Builder = setContentIntent(
contentIntent(
notificationId = notificationId,
uri = "${appContext.deepLinkBaseUrl}/${Screen.Photos(notificationId.channel.userId)}".toUri(),
uri = "${appContext.deepLinkBaseUrl}/${Screen.PhotosAndAlbums(notificationId.channel.userId)}".toUri(),
)
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

+1
View File
@@ -29,4 +29,5 @@
<!-- Show 2 times the Notification Permission Request rational -->
<integer name="core_feature_notifications_permission_show_rationale_count">0</integer>
<bool name="core_feature_observability_enabled">true</bool>
<bool name="core_feature_payments_v5_enabled">true</bool>
</resources>
@@ -0,0 +1,260 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewmodel
import androidx.lifecycle.SavedStateHandle
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import me.proton.core.crypto.common.pgp.VerificationStatus
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.entity.Attributes
import me.proton.core.drive.base.domain.entity.Bytes
import me.proton.core.drive.base.domain.entity.CryptoProperty
import me.proton.core.drive.base.domain.entity.Permissions
import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.core.drive.base.domain.extension.asSuccess
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.db.test.volumeId
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.files.presentation.entry.DeleteAlbumEntry
import me.proton.core.drive.files.presentation.entry.LeaveAlbumEntry
import me.proton.core.drive.files.presentation.entry.ManageAccessEntry
import me.proton.core.drive.files.presentation.entry.RenameFileEntry
import me.proton.core.drive.files.presentation.entry.ShareViaInvitationsEntry
import me.proton.core.drive.files.presentation.entry.ToggleOfflineEntry
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.Link
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.entity.SharingDetails
import me.proton.core.drive.linkdownload.domain.entity.DownloadState
import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.share.user.domain.entity.ShareUser
import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AlbumOptionsViewModelTest {
private val savedStateHandle = mockk<SavedStateHandle>()
private val getDriveLink = mockk<GetDecryptedDriveLink>()
private val getFeatureFlagFlow = mockk<GetFeatureFlagFlow>()
private val broadcastMessages = mockk<BroadcastMessages>()
private val albumOptionsViewModel get() =
AlbumOptionsViewModel(
savedStateHandle = savedStateHandle,
getDriveLink = getDriveLink,
getFeatureFlagFlow = getFeatureFlagFlow,
broadcastMessages = broadcastMessages,
)
@Before
fun before() {
coEvery { savedStateHandle.get<String>(any()) } returns "value"
coEvery { getFeatureFlagFlow.invoke(any(), any(), any())} answers {
val id: FeatureFlagId = arg(0)
flowOf(FeatureFlag(id, State.NOT_FOUND))
}
coEvery { getFeatureFlagFlow.refreshAfterDuration} answers {
{ false }
}
}
@Test
fun `private album`() = runTest {
// Given
coEvery { getDriveLink.invoke(any<AlbumId>(), any()) } returns flowOf(album.copy(
link = albumLink.copy(
isShared = false,
sharingDetails = null,
)
).asSuccess)
// When
val entries = albumOptionEntries()
// Then
assertEquals(
listOf(
//ToggleOfflineEntry::class,
ShareViaInvitationsEntry::class,
ManageAccessEntry::class,
RenameFileEntry::class,
DeleteAlbumEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
@Test
fun `album shared by me`() = runTest {
// Given
coEvery { getDriveLink.invoke(any<AlbumId>(), any()) } returns flowOf(album.asSuccess)
// When
val entries = albumOptionEntries()
// Then
assertEquals(
listOf(
//ToggleOfflineEntry::class,
ShareViaInvitationsEntry::class,
ManageAccessEntry::class,
RenameFileEntry::class,
DeleteAlbumEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
@Test
fun `album shared with me as viewer`() = runTest {
// Given
coEvery { getDriveLink.invoke(any<AlbumId>(), any()) } returns flowOf(
album.copy(
sharePermissions = Permissions.viewer,
shareUser = shareUser.copy(permissions = Permissions.viewer)
).asSuccess
)
// When
val entries = albumOptionEntries()
// Then
assertEquals(
listOf(
//ToggleOfflineEntry::class,
LeaveAlbumEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
@Test
fun `album shared with me as editor`() = runTest {
// Given
coEvery { getDriveLink.invoke(any<AlbumId>(), any()) } returns flowOf(
album.copy(
sharePermissions = Permissions.viewer,
shareUser = shareUser.copy(permissions = Permissions.editor)
).asSuccess
)
// When
val entries = albumOptionEntries()
// Then
assertEquals(
listOf(
//ToggleOfflineEntry::class,
LeaveAlbumEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
private suspend fun albumOptionEntries() =
albumOptionsViewModel.entries(
runAction = {},
navigateToShareViaInvitations = { _: LinkId -> },
navigateToManageAccess = { _: LinkId -> },
navigateToRename = { _: LinkId -> },
navigateToDelete = { _: AlbumId -> },
navigateToLeave = { _: AlbumId -> },
dismiss = {},
).filterNotNull().first()
private val albumLink = Link.Album(
id = AlbumId(ShareId(UserId("USER_ID"), "SHARE_ID"), "ID"),
parentId = FolderId(ShareId(UserId("USER_ID"), "SHARE_ID"), "PARENT_ID"),
size = Bytes(123),
lastModified = TimestampS(System.currentTimeMillis() / 1000),
mimeType = "video/mp4",
numberOfAccesses = 2,
isShared = true,
uploadedBy = "m4@proton.black",
name = "Link name",
key = "key",
passphrase = "passphrase",
passphraseSignature = "signature",
attributes = Attributes(0),
permissions = Permissions(0),
state = Link.State.ACTIVE,
nameSignatureEmail = "",
hash = "",
expirationTime = null,
nodeKey = "",
nodePassphrase = "",
nodePassphraseSignature = "",
signatureEmail = "",
creationTime = TimestampS(0),
trashedTime = null,
shareUrlExpirationTime = null,
xAttr = null,
sharingDetails = SharingDetails(
shareId = ShareId(UserId("USER_ID"), "SHARING_ID"),
shareUrlId = ShareUrlId(ShareId(UserId("USER_ID"), "SHARING_ID"), "")
),
nodeHashKey = "nodehashkey",
isLocked = false,
lastActivityTime = TimestampS(0),
photoCount = 2,
)
val album = DriveLink.Album(
link = albumLink,
volumeId = volumeId,
isMarkedAsOffline = true,
isAnyAncestorMarkedAsOffline = false,
downloadState = DownloadState.Downloaded(),
trashState = null,
cryptoName = CryptoProperty.Decrypted("Album", VerificationStatus.Success),
cryptoXAttr = CryptoProperty.Decrypted(
"""{"Common":{"ModificationTime":"2023-07-27T13:52:23.636Z"},"Media":{"Duration":46}}""",
VerificationStatus.Success,
),
shareInvitationCount = null,
shareMemberCount = null,
shareUser = null,
sharePermissions = Permissions.admin
)
private val shareUser = ShareUser.Member(
id ="id",
inviter = "",
email = "",
createTime = TimestampS(),
permissions = Permissions.owner,
keyPacket = "",
keyPacketSignature = null,
sessionKeySignature = null,
)
}
@@ -26,10 +26,15 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import me.proton.android.drive.photos.domain.usecase.AddPhotosToStream
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
import me.proton.android.drive.ui.viewmodel.FileOrFolderOptionsViewModel.Companion.KEY_ALBUM_ID
import me.proton.android.drive.usecase.LeaveShare
import me.proton.android.drive.usecase.NotifyActivityNotFound
import me.proton.android.drive.usecase.OpenProtonDocumentInBrowser
import me.proton.core.crypto.common.pgp.VerificationStatus
import me.proton.core.domain.arch.DataResult
import me.proton.core.domain.arch.ResponseSource
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.entity.Attributes
import me.proton.core.drive.base.domain.entity.Bytes
@@ -37,18 +42,20 @@ import me.proton.core.drive.base.domain.entity.CryptoProperty
import me.proton.core.drive.base.domain.entity.Permissions
import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.core.drive.base.domain.extension.asSuccess
import me.proton.core.drive.base.domain.extension.bytes
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.db.test.volumeId
import me.proton.core.drive.documentsprovider.domain.usecase.ExportTo
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.offline.domain.usecase.ToggleOffline
import me.proton.core.drive.drivelink.photo.domain.usecase.ToggleFavorite
import me.proton.core.drive.drivelink.photo.domain.usecase.UpdateAlbumCover
import me.proton.core.drive.drivelink.trash.domain.usecase.ToggleTrashState
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.files.presentation.entry.DownloadFileEntry
import me.proton.core.drive.files.presentation.entry.FileInfoEntry
@@ -61,6 +68,7 @@ import me.proton.core.drive.files.presentation.entry.RenameFileEntry
import me.proton.core.drive.files.presentation.entry.SendFileEntry
import me.proton.core.drive.files.presentation.entry.SetAsAlbumCoverEntry
import me.proton.core.drive.files.presentation.entry.ShareViaInvitationsEntry
import me.proton.core.drive.files.presentation.entry.ToggleFavoriteFileEntry
import me.proton.core.drive.files.presentation.entry.ToggleOfflineEntry
import me.proton.core.drive.files.presentation.entry.ToggleTrashEntry
import me.proton.core.drive.link.domain.entity.FileId
@@ -68,12 +76,14 @@ import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.Link
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.entity.SharingDetails
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
import me.proton.core.drive.linkdownload.domain.entity.DownloadState
import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.share.user.domain.entity.ShareUser
import me.proton.core.drive.share.user.domain.usecase.LeaveShare
import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.drive.volume.domain.entity.Volume
import me.proton.core.drive.volume.domain.usecase.GetOldestActiveVolume
import me.proton.core.drive.volume.domain.usecase.HasPhotoVolume
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@@ -95,10 +105,19 @@ class FileOrFolderOptionsViewModelTest {
private val openProtonDocumentInBrowser = mockk<OpenProtonDocumentInBrowser>()
private val updateAlbumCover = mockk<UpdateAlbumCover>()
private val removePhotosFromAlbum = mockk<RemovePhotosFromAlbum>()
private val deselectLinks = mockk<DeselectLinks>()
private val toggleFavorite = mockk<ToggleFavorite>()
private val getOldestActiveVolume = mockk<GetOldestActiveVolume>()
private val addPhotosToStream = mockk<AddPhotosToStream>()
private val hasPhotoVolume = mockk<HasPhotoVolume>()
@Before
fun before() {
coEvery { savedStateHandle.get<String>(any()) } returns "value"
coEvery { savedStateHandle.get<String>(any()) } answers {
val key: String = arg(0)
if (key == KEY_ALBUM_ID) null else "value"
}
coEvery { getFeatureFlagFlow.invoke(any(), any(), any())} answers {
val id: FeatureFlagId = arg(0)
flowOf(FeatureFlag(id, State.NOT_FOUND))
@@ -107,6 +126,20 @@ class FileOrFolderOptionsViewModelTest {
{ false }
}
coEvery { configurationProvider.albumsFeatureFlag} returns true
coEvery { deselectLinks(any()) } returns Unit
coEvery { getOldestActiveVolume(any(), any()) } returns flowOf(DataResult.Success(
source = ResponseSource.Local,
value = Volume(
id = volumeId,
shareId = "",
linkId = "",
usedSpace = 0.bytes,
state = 0,
createTime = TimestampS(),
type = Volume.Type.PHOTO
)
))
coEvery { hasPhotoVolume.invoke(any()) } returns flowOf(true)
}
@Test
@@ -198,7 +231,13 @@ class FileOrFolderOptionsViewModelTest {
coEvery { getDriveLink.invoke(any<LinkId>(), any()) } returns flowOf(fileDriveLink.asSuccess)
coEvery { getFeatureFlagFlow.invoke(any())} answers {
val id: FeatureFlagId = arg(0)
flowOf(FeatureFlag(id, State.ENABLED))
flowOf(
if (id == FeatureFlagId.driveAlbumsTempDisabledOnRelease(UserId("value"))) {
FeatureFlag(id, State.NOT_FOUND)
} else {
FeatureFlag(id, State.ENABLED)
}
)
}
// When
val entries = fileOptionEntries()
@@ -283,9 +322,10 @@ class FileOrFolderOptionsViewModelTest {
}
@Test
fun `file options on photo share without album feature flag`() = runTest {
fun `file options on photo share without photo volume`() = runTest {
// Given
coEvery { getDriveLink.invoke(any<LinkId>(), any()) } returns flowOf(photoDriveLink.asSuccess)
coEvery { hasPhotoVolume.invoke(any()) } returns flowOf(false)
// When
val entries = fileOptionEntries()
@@ -306,12 +346,13 @@ class FileOrFolderOptionsViewModelTest {
}
@Test
fun `file options on photo share with album feature flag`() = runTest {
fun `file options on photo share with photo volume`() = runTest {
// Given
coEvery { getDriveLink.invoke(any<LinkId>(), any()) } returns flowOf(photoDriveLink.asSuccess)
val featureFlagId = FeatureFlagId.driveAlbums(UserId("value"))
coEvery { getFeatureFlagFlow(featureFlagId, any(), any()) } returns
flowOf( FeatureFlag(featureFlagId, State.ENABLED))
coEvery { savedStateHandle.get<String>(KEY_ALBUM_ID) } returns "album-id"
// When
val entries = fileOptionEntries()
@@ -322,12 +363,10 @@ class FileOrFolderOptionsViewModelTest {
SetAsAlbumCoverEntry::class,
RemoveFromAlbumFileEntry::class,
ToggleOfflineEntry::class,
ShareViaInvitationsEntry::class,
ManageAccessEntry::class,
ToggleFavoriteFileEntry::class,
SendFileEntry::class,
DownloadFileEntry::class,
FileInfoEntry::class,
ToggleTrashEntry::class,
),
entries.map { it.javaClass.kotlin }
)
@@ -384,13 +423,17 @@ class FileOrFolderOptionsViewModelTest {
exportTo = exportTo,
notifyActivityNotFound = notifyActivityNotFound,
getFeatureFlagFlow = getFeatureFlagFlow,
albumsFeatureFlag = AlbumsFeatureFlag(getFeatureFlagFlow, configurationProvider),
leaveShare = leaveShare,
configurationProvider = configurationProvider,
broadcastMessages = broadcastMessages,
openProtonDocumentInBrowser = openProtonDocumentInBrowser,
updateAlbumCover = updateAlbumCover,
removePhotosFromAlbum = removePhotosFromAlbum,
deselectLinks = deselectLinks,
toggleFavorite = toggleFavorite,
getOldestActiveVolume = getOldestActiveVolume,
addPhotosToStream = addPhotosToStream,
hasPhotoVolume = hasPhotoVolume,
)
private val fileLink = Link.File(
@@ -435,7 +478,7 @@ class FileOrFolderOptionsViewModelTest {
private val fileDriveLink = DriveLink.File(
link = fileLink,
volumeId = VolumeId("VOLUME_ID"),
volumeId = volumeId,
isMarkedAsOffline = true,
isAnyAncestorMarkedAsOffline = false,
downloadState = DownloadState.Downloaded(),
@@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
import me.proton.android.drive.ui.viewmodel.FileOrFolderOptionsViewModel.Companion
import me.proton.android.drive.ui.viewmodel.MultipleFileOrFolderOptionsViewModel.Companion.KEY_ALBUM_ID
import me.proton.core.crypto.common.pgp.VerificationStatus
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.entity.Attributes
@@ -44,12 +46,12 @@ import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbums
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.files.presentation.entry.CreateAlbumEntry
import me.proton.core.drive.files.presentation.entry.DownloadEntry
import me.proton.core.drive.files.presentation.entry.MoveEntry
import me.proton.core.drive.files.presentation.entry.RemoveFromAlbumEntry
import me.proton.core.drive.files.presentation.entry.ShareMultiplePhotosEntry
import me.proton.core.drive.files.presentation.entry.TrashEntry
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
@@ -62,6 +64,7 @@ import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId
import me.proton.core.drive.trash.domain.usecase.SendToTrash
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.drive.volume.domain.usecase.HasPhotoVolume
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@@ -80,10 +83,14 @@ class MultipleFileOrFolderOptionsViewModelTest {
private val configurationProvider = mockk<ConfigurationProvider>()
private val removePhotosFromAlbum = mockk<RemovePhotosFromAlbum>()
private val broadcastMessages = mockk<BroadcastMessages>()
private val hasPhotoVolume = mockk<HasPhotoVolume>()
@Before
fun before() {
coEvery { savedStateHandle.get<String>(any()) } returns "value"
coEvery { savedStateHandle.get<String>(any()) } answers {
val key: String = arg(0)
if (key == FileOrFolderOptionsViewModel.KEY_ALBUM_ID) null else "value"
}
coEvery { getSelectedDriveLinks.invoke(any()) } returns flowOf()
coEvery { getFeatureFlagFlow.invoke(any(), any(), any())} answers {
val id: FeatureFlagId = arg(0)
@@ -93,12 +100,13 @@ class MultipleFileOrFolderOptionsViewModelTest {
{ false }
}
coEvery { configurationProvider.albumsFeatureFlag} returns true
coEvery { hasPhotoVolume.invoke(any()) } returns flowOf(true)
}
@Test
fun `files options`() = runTest {
// Given
val files = listOf(fileDriveLink)
val files = listOf(fileDriveLink, fileDriveLink2)
// When
val entries = files.fileOptionEntries()
@@ -115,9 +123,10 @@ class MultipleFileOrFolderOptionsViewModelTest {
}
@Test
fun `photos options without album feature flag`() = runTest {
fun `photos options without photo volume`() = runTest {
// Given
val files = listOf(photoDriveLink)
coEvery { hasPhotoVolume.invoke(any()) } returns flowOf(false)
val files = listOf(photoDriveLink, photoDriveLink2)
// When
val entries = files.fileOptionEntries()
@@ -133,12 +142,37 @@ class MultipleFileOrFolderOptionsViewModelTest {
}
@Test
fun `photos options with album feature flag `() = runTest {
fun `photos options with photo volume`() = runTest {
// Given
val featureFlagId = driveAlbums(UserId("value"))
coEvery { getFeatureFlagFlow(featureFlagId, any(), any()) } returns
flowOf( FeatureFlag(featureFlagId, State.ENABLED))
val files = listOf(photoDriveLink)
coEvery { savedStateHandle.get<String>(KEY_ALBUM_ID) } returns null
val files = listOf(photoDriveLink, photoDriveLink2)
// When
val entries = files.fileOptionEntries()
// Then
assertEquals(
listOf(
CreateAlbumEntry::class,
ShareMultiplePhotosEntry::class,
DownloadEntry::class,
TrashEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
@Test
fun `photos options within album`() = runTest {
// Given
val featureFlagId = driveAlbums(UserId("value"))
coEvery { getFeatureFlagFlow(featureFlagId, any(), any()) } returns
flowOf( FeatureFlag(featureFlagId, State.ENABLED))
coEvery { savedStateHandle.get<String>(Companion.KEY_ALBUM_ID) } returns "album-id"
val files = listOf(photoDriveLink, photoDriveLink2)
// When
val entries = files.fileOptionEntries()
@@ -147,21 +181,19 @@ class MultipleFileOrFolderOptionsViewModelTest {
assertEquals(
listOf(
RemoveFromAlbumEntry::class,
CreateAlbumEntry::class,
DownloadEntry::class,
TrashEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
private suspend fun List<DriveLink>.fileOptionEntries() =
fileOrFolderOptionsViewModel().entries(
driveLinks = this,
runAction = {},
navigateToMove = { _: SelectionId, _: FolderId? -> },
navigateToCreateNewAlbum = { },
navigateToCreateNewAlbum = {},
navigateToShareMultiplePhotosOptions = {},
dismiss = {},
).filterNotNull().first()
@@ -175,9 +207,9 @@ class MultipleFileOrFolderOptionsViewModelTest {
addToAlbumInfo = addToAlbumInfo,
removePhotosFromAlbum = removePhotosFromAlbum,
getFeatureFlagFlow = getFeatureFlagFlow,
albumsFeatureFlag = AlbumsFeatureFlag(getFeatureFlagFlow, configurationProvider),
broadcastMessages = broadcastMessages,
configurationProvider = configurationProvider,
hasPhotoVolume = hasPhotoVolume,
)
private val fileLink = Link.File(
@@ -238,6 +270,13 @@ class MultipleFileOrFolderOptionsViewModelTest {
sharePermissions = Permissions.admin
)
private val fileDriveLink2 = fileDriveLink.copy(
link = fileDriveLink.link.copy(
id = FileId(ShareId(UserId("USER_ID"), "SHARE_ID"), "ID2"),
),
cryptoName = CryptoProperty.Decrypted("Link name 2", VerificationStatus.Success),
)
private val photoDriveLink = fileDriveLink.copy(
link = fileLink.copy(
photoCaptureTime = TimestampS(0),
@@ -245,4 +284,10 @@ class MultipleFileOrFolderOptionsViewModelTest {
mainPhotoLinkId = "MAIN_ID"
)
)
private val photoDriveLink2 = photoDriveLink.copy(
link = photoDriveLink.link.copy(
id = FileId(ShareId(UserId("USER_ID"), "SHARE_ID"), "ID2"),
)
)
}
@@ -51,6 +51,10 @@ fun NodeAssertions.assertIsSharedWithUsers(expectedValue: Boolean) = apply {
interaction.assert(SemanticsMatcher.expectValue(DriveLinkSemanticsProperties.IsSharedWithUsers, expectedValue))
}
fun NodeAssertions.assertIsFavorite(expectedValue: Boolean) = apply {
interaction.assert(SemanticsMatcher.expectValue(DriveLinkSemanticsProperties.IsFavorite, expectedValue))
}
// Remove after TPE-334 is resolved
fun NodeAssertions.doesNotExist() =
try {
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.extension
import me.proton.core.domain.entity.UserId
import me.proton.core.test.rule.ProtonRule
val ProtonRule.mainUserId: UserId get() = UserId(requireNotNull(testDataRule.mainTestUser?.id))
@@ -26,6 +26,9 @@ object AlbumOptionsRobot : Robot {
private val albumOptionsScreen get() = node.withTag(AlbumOptionsTestTag.albumOptions)
private val renameButton get() = node.withText(I18N.string.common_rename_action)
private val deleteAlbumButton get() = node.withText(I18N.string.albums_delete_album_action)
private val shareButton get() = node.withText(I18N.string.common_share)
private val manageAccessButton get() = node.withText(I18N.string.common_manage_access_action)
private val leaveAlbumButton get() = node.withText(I18N.string.common_leave_album_action)
fun clickRename() = RenameRobot.apply {
renameButton.scrollTo().click()
@@ -33,6 +36,18 @@ object AlbumOptionsRobot : Robot {
fun clickDeleteAlbum() = deleteAlbumButton.clickTo(ConfirmDeleteAlbumRobot)
fun clickShare() = shareButton
.hasAncestor(node.withTag(AlbumOptionsTestTag.albumOptions))
.clickTo(ShareUserRobot)
fun clickManageAccess() = manageAccessButton.clickTo(ManageAccessRobot)
fun clickLeaveAlbum() = leaveAlbumButton.clickTo(ConfirmLeaveAlbumRobot)
fun assertDeleteAlbumOptionIsDisplayed() {
deleteAlbumButton.await { assertIsDisplayed() }
}
override fun robotDisplayed() {
albumOptionsScreen.await { assertIsDisplayed() }
}
@@ -18,32 +18,65 @@
package me.proton.android.drive.ui.robot
import androidx.compose.ui.test.assertCountEquals
import me.proton.android.drive.ui.extension.withItemType
import me.proton.android.drive.ui.extension.withLayoutType
import me.proton.android.drive.ui.extension.withLinkName
import me.proton.android.drive.ui.screen.AlbumScreenTestTag
import me.proton.core.drive.files.presentation.extension.ItemType
import me.proton.core.drive.files.presentation.extension.LayoutType
import me.proton.test.fusion.Fusion.allNodes
import me.proton.test.fusion.Fusion.node
import me.proton.test.fusion.FusionConfig.targetContext
import me.proton.core.drive.i18n.R as I18N
import me.proton.android.drive.photos.presentation.component.ProtonMediaItemTestTags
import me.proton.test.fusion.ui.compose.ComposeWaiter.waitFor
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
object AlbumRobot : LinksRobot, NavigationBarRobot {
private val albumScreen get() = node.withTag(AlbumScreenTestTag.screen)
private val moreButton get() = node.withContentDescription(I18N.string.common_more)
private val addButton get() = node.withText(I18N.string.common_add_action)
private val saveAllButton get() = node.withText(I18N.string.common_save_all_action)
private val shareButton get() = node.withText(I18N.string.common_share)
private val emptyAlbumText get() = node.withText(I18N.string.albums_empty_album_screen_title)
private val mediaItems get() = allNodes.withTag(ProtonMediaItemTestTags.mediaItemPreviewBox)
private val favoriteFromForeignVolume
get() = node.withText(I18N.string.files_add_to_favorite_from_foreign_volume_action_success)
private val addToAlbumStartMessage
get() = node.withText(I18N.string.albums_add_to_album_start_message)
fun clickOnMoreButton() = moreButton.clickTo(AlbumOptionsRobot)
fun clickOnAdd() = addButton.clickTo(PickerPhotosAndAlbumsRobot)
fun clickOnAdd() = addButton.clickTo(PickerPhotosTabRobot)
fun clickOnSaveAll() = saveAllButton.clickTo(AlbumRobot)
fun clickOnShare() = shareButton.clickTo(ShareUserRobot)
fun clickOnPhoto(name: String) =
photoWithName(name).clickTo(PreviewRobot)
fun <T : Robot> clickOnPhoto(name: String, goesTo: T) =
photoWithName(name).clickTo(goesTo)
fun longClickOnPhoto(fileName: String) =
photoWithName(fileName).longClickTo(this)
private fun photoWithName(name: String) = linkWithName(name)
.withItemType(ItemType.File)
.withLayoutType(LayoutType.Grid)
fun dismissPhotoSavedToDrive(count: Int) = node.withText(
targetContext.resources.getQuantityString(
I18N.plurals.in_app_notification_add_to_stream_success,
count
).format(count)
).clickTo(AlbumsTabRobot)
fun assertMoreButtonIsDisplayed() = moreButton.await { assertIsDisplayed() }
fun assertAlbumNameIsDisplayed(name: String) = node.withText(name)
.await { assertIsDisplayed() }
@@ -54,11 +87,34 @@ object AlbumRobot : LinksRobot, NavigationBarRobot {
).format(count)
).await { assertIsDisplayed() }
fun assertVisibleMediaItemsInAlbum(count: Int) {
waitUntilMediaItemsCountEquals(count)
}
private fun waitUntilMediaItemsCountEquals(
expectedCount: Int,
timeout: Duration = 10.seconds,
interval: Duration = 1.seconds,
) {
val nodes = mediaItems
.withItemType(ItemType.File)
.withLayoutType(LayoutType.Grid)
nodes.waitFor(timeout, interval) {
nodes.interaction.assertCountEquals(expectedCount)
}
}
fun assertCoverAlbum(name: String) = node.withLinkName(name)
.withItemType(ItemType.File)
.withLayoutType(LayoutType.Cover)
.await { assertIsDisplayed() }
fun assertEmptyAlbum() = emptyAlbumText.await { assertIsDisplayed() }
fun assertAddToFavoriteFromForeignVolume() = favoriteFromForeignVolume.await { assertIsDisplayed() }
fun dismissAddToAlbumStartMessage() = addToAlbumStartMessage.clickTo(AlbumRobot)
override fun robotDisplayed() {
albumScreen.await { assertIsDisplayed() }
}
@@ -18,9 +18,13 @@
package me.proton.android.drive.ui.robot
import me.proton.android.drive.photos.presentation.component.ProtonPreviewAlbumItemTestTags
import me.proton.android.drive.photos.presentation.extension.albumDetails
import me.proton.android.drive.ui.screen.PhotosAndAlbumsScreenTestTag
import me.proton.android.drive.ui.test.AbstractBaseTest.Companion.targetContext
import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.core.test.android.instrumented.utils.StringUtils
import me.proton.test.fusion.Fusion.allNodes
import me.proton.test.fusion.Fusion.node
import me.proton.core.drive.i18n.R as I18N
@@ -31,7 +35,8 @@ object AlbumsTabRobot :
HomeRobot,
LinksRobot,
NavigationBarRobot {
private val albumTitle get() = node.withText(I18N.string.albums_title)
private val albumTitleTab get() = node.withTag(PhotosAndAlbumsScreenTestTag.albumsTitleTab)
private val photosTitleTab get() = node.withTag(PhotosAndAlbumsScreenTestTag.photosTitleTab)
private val filterAll get() = node.withText(I18N.string.albums_filter_all)
private val filterMyAlbums get() = node.withText(I18N.string.albums_filter_my_albums)
@@ -41,8 +46,14 @@ object AlbumsTabRobot :
private val emptyTitle get() = node.withText(I18N.string.albums_empty_albums_list_screen_title)
private val emptyDescription get() = node.withText(I18N.string.albums_empty_albums_list_screen_description)
private val emptySharedWithMeTitle get() = node.withText(I18N.string.albums_empty_albums_shared_with_me_screen_title)
private val emptySharedWithMeDescription get() = node.withText(I18N.string.albums_empty_albums_shared_with_me_screen_description)
private val plusButton get() = node.withContentDescription(I18N.string.content_description_albums_new)
private val previewAlbumItems get() = allNodes.withTag(ProtonPreviewAlbumItemTestTags.albumName)
fun clickOnPhotosTitleTab() = photosTitleTab.clickTo(PhotosTabRobot)
fun clickOnFilterAll() = apply {
filterAll.click()
@@ -60,12 +71,28 @@ object AlbumsTabRobot :
filterSharedWithMe.click()
}
fun swipeFiltersToEnd() = apply {
filterSharedWithMe.onParent().swipeLeft()
}
fun clickPlusButton() = plusButton.clickTo(CreateAlbumTabRobot)
fun clickUserInvitation(count: Int) = node.withText(
StringUtils.pluralStringFromResource(
I18N.plurals.shared_with_me_album_invitations_banner_description,
count,
count
)
).clickTo(UserInvitationRobot)
fun assertAlbumIsDisplayed(name: String) {
node.withText(name).await { assertIsDisplayed() }
}
fun assertAtLeastOneAlbumIsDisplayed() {
previewAlbumItems.onFirst().await { assertIsDisplayed() }
}
fun assertAlbumIsDisplayed(
name: String,
photoCount: Long,
@@ -95,9 +122,14 @@ object AlbumsTabRobot :
emptyDescription.await { assertIsDisplayed() }
}
fun assertIsEmptySharedWithMe() {
emptySharedWithMeTitle.await { assertIsDisplayed() }
emptySharedWithMeDescription.await { assertIsDisplayed() }
}
override fun robotDisplayed() {
homeScreenDisplayed()
photosTab.assertIsSelected()
albumTitle.assertIsDisplayed()
albumTitleTab.assertIsDisplayed()
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.robot
import me.proton.android.drive.ui.dialog.ConfirmLeaveAlbumDialogTestTag
import me.proton.core.test.android.instrumented.FusionConfig
import me.proton.test.fusion.Fusion.node
import me.proton.core.drive.i18n.R as I18N
object ConfirmLeaveAlbumRobot : Robot {
private val dialog get() = node.withTag(ConfirmLeaveAlbumDialogTestTag.confirmLeaveAlbum)
private val cancelButton = node.withText(I18N.string.common_cancel_action)
private val leaveWithoutSavingButton = node.withText(I18N.string.albums_leave_album_dialog_leave_without_saving_action)
private val saveAndLeaveButton = node.withText(I18N.string.albums_leave_album_dialog_save_and_leave_action)
fun <T : Robot> clickOnCancel(goesTo: T) = cancelButton.clickTo(goesTo)
fun <T : Robot> clickOnLeaveWithoutSaving(goesTo: T) = leaveWithoutSavingButton.clickTo(goesTo)
fun <T : Robot> clickOnSaveAndLeave(goesTo: T) = saveAndLeaveButton.clickTo(goesTo)
fun assertDescriptionIsDisplayed(album: String) =
node.withText(
FusionConfig.targetContext().getString(I18N.string.albums_leave_album_dialog_description, album)
).await { assertIsDisplayed() }
override fun robotDisplayed() {
dialog.await { assertIsDisplayed() }
}
}
@@ -30,6 +30,7 @@ object CreateAlbumTabRobot : Robot {
.hasDescendant(node.withText(I18N.string.albums_new_album_name_hint))
private val doneButton get() = node.withText(I18N.string.common_done_action)
private val shareButton get() = node.withText(I18N.string.common_share)
private val addButton get() = node.withText(I18N.string.common_add_action)
fun typeName(text: String) = apply { newAlbumHint.typeText(text) }
@@ -42,7 +43,10 @@ object CreateAlbumTabRobot : Robot {
fun <T : Robot> clickOnDone(goesTo: T) = doneButton.clickTo(goesTo)
fun clickOnAdd() = addButton.clickTo(PickerPhotosAndAlbumsRobot)
fun clickOnAdd() = addButton.clickTo(PickerPhotosTabRobot)
fun <T : Robot> clickOnShare(goesTo: T) = shareButton.clickTo(goesTo)
override fun robotDisplayed() {
newAlbumHint.assertIsDisplayed()

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