This commit is contained in:
Damir Mihaljinec
2025-04-09 08:56:56 +02:00
parent 349e5691bf
commit d526c96ce6
332 changed files with 26025 additions and 1040 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
{
"project": "android-drive",
"locale": "c45081f9f1a84cc7c5d9344759bfe91b69a985c2"
}
"locale": "4c89919839fd831eddec9a556ab6e713aa607375"
}
@@ -19,6 +19,7 @@
package me.proton.android.drive.ui.dialog
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
@@ -35,7 +36,7 @@ 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.drive.base.presentation.R
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
@@ -45,6 +46,7 @@ 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
import me.proton.core.drive.thumbnail.presentation.extension.thumbnailPainter
import me.proton.core.drive.base.presentation.R as BasePresentation
@Composable
fun AlbumOptions(
@@ -132,18 +134,33 @@ fun AlbumOptionsHeader(
modifier: Modifier = Modifier,
) {
val coverPainter = coverLink?.thumbnailPainter()?.painter
?: painterResource(id = R.drawable.img_whats_new_public_sharing) //TODO: once available put proper no-cover illustration
val localContext = LocalContext.current
val details = remember (localContext) {
album.details(localContext, useCreationTime = false)
}
OptionsHeader(
painter = coverPainter,
title = album.name,
isTitleEncrypted = album.isNameEncrypted,
subtitle = details,
modifier = modifier,
)
if (coverPainter != null) {
OptionsHeader(
painter = coverPainter,
title = album.name,
isTitleEncrypted = album.isNameEncrypted,
subtitle = details,
modifier = modifier,
)
} else {
OptionsHeader(
title = album.name,
isTitleEncrypted = album.isNameEncrypted,
subtitle = details,
modifier = modifier,
) { contentModifier ->
Icon(
painter = painterResource(id = BasePresentation.drawable.ic_proton_images),
contentDescription = null,
tint = ProtonTheme.colors.iconNorm,
modifier = contentModifier,
)
}
}
}
object AlbumOptionsTestTag {
@@ -18,7 +18,9 @@
package me.proton.android.drive.ui.effect
import me.proton.core.drive.volume.domain.entity.VolumeId
sealed class TrashEffect {
data class ShowSnackbar(val message: String) : TrashEffect()
object MoreOptions : TrashEffect()
data class MoreOptions(val volumeId: VolumeId) : TrashEffect()
}
@@ -97,7 +97,6 @@ import me.proton.android.drive.ui.navigation.internal.MutableNavControllerSaver
import me.proton.android.drive.ui.navigation.internal.createNavController
import me.proton.android.drive.ui.navigation.internal.modalBottomSheet
import me.proton.android.drive.ui.navigation.internal.rememberAnimatedNavController
import me.proton.android.drive.ui.options.OptionsFilter
import me.proton.android.drive.ui.screen.AccountSettingsScreen
import me.proton.android.drive.ui.screen.AlbumScreen
import me.proton.android.drive.ui.screen.AppAccessScreen
@@ -113,6 +112,8 @@ import me.proton.android.drive.ui.screen.MoveToFolder
import me.proton.android.drive.ui.screen.OfflineScreen
import me.proton.android.drive.ui.screen.PhotosBackupScreen
import me.proton.android.drive.ui.screen.PhotosUpsellScreen
import me.proton.android.drive.ui.screen.PickerAlbumScreen
import me.proton.android.drive.ui.screen.PickerPhotosAndAlbumsScreen
import me.proton.android.drive.ui.screen.PreviewScreen
import me.proton.android.drive.ui.screen.SettingsScreen
import me.proton.android.drive.ui.screen.SigningOutScreen
@@ -387,6 +388,8 @@ fun AppNavGraph(
addAlbum(navController)
addAlbumOptions(navController)
addConfirmDeleteAlbumDialog(navController)
addPickerPhotosAndAlbums(navController)
addPickerAlbum(navController)
}
}
@@ -486,9 +489,13 @@ fun NavGraphBuilder.addFileOrFolderOptions(
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
navArgument(Screen.FileOrFolderOptions.SHARE_ID) { type = NavType.StringType },
navArgument(Screen.FileOrFolderOptions.LINK_ID) { type = NavType.StringType },
navArgument(Screen.FileOrFolderOptions.OPTIONS_FILTER) {
type = NavType.EnumType(OptionsFilter::class.java)
defaultValue = OptionsFilter.FILES
navArgument(Screen.FileOrFolderOptions.ALBUM_ID) {
type = NavType.StringType
nullable = true
},
navArgument(Screen.FileOrFolderOptions.KEY_ALBUM_SHARE_ID) {
type = NavType.StringType
nullable = true
},
),
) { navBackStackEntry, runAction ->
@@ -557,9 +564,13 @@ fun NavGraphBuilder.addMultipleFileOrFolderOptions(
arguments = listOf(
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
navArgument(Screen.MultipleFileOrFolderOptions.SELECTION_ID) { type = NavType.StringType },
navArgument(Screen.MultipleFileOrFolderOptions.OPTIONS_FILTER) {
type = NavType.EnumType(OptionsFilter::class.java)
defaultValue = OptionsFilter.FILES
navArgument(Screen.MultipleFileOrFolderOptions.ALBUM_SHARE_ID) {
type = NavType.StringType
nullable = true
},
navArgument(Screen.MultipleFileOrFolderOptions.ALBUM_ID) {
type = NavType.StringType
nullable = true
},
),
) { navBackStackEntry, runAction ->
@@ -807,6 +818,7 @@ fun NavGraphBuilder.addConfirmEmptyTrashDialog(navController: NavHostController)
route = Screen.Files.Dialogs.ConfirmEmptyTrash.route,
arguments = listOf(
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
navArgument(Screen.Files.VOLUME_ID) { type = NavType.StringType },
),
) {
ConfirmEmptyTrashDialog(
@@ -872,8 +884,8 @@ internal fun NavGraphBuilder.addHome(
navigateToOffline = {
navController.navigate(Screen.OfflineFiles(userId))
},
navigateToPreview = { fileId, pagerType, optionsFilter ->
navController.navigate(Screen.PagerPreview(pagerType, userId, fileId, optionsFilter))
navigateToPreview = { fileId, pagerType ->
navController.navigate(Screen.PagerPreview(pagerType, userId, fileId))
},
navigateToSorting = { sorting ->
navController.navigate(
@@ -883,14 +895,14 @@ internal fun NavGraphBuilder.addHome(
navigateToSettings = {
navController.navigate(Screen.Settings(userId))
},
navigateToFileOrFolderOptions = { linkId, optionsFilter ->
navigateToFileOrFolderOptions = { linkId ->
navController.navigate(
Screen.FileOrFolderOptions(userId, linkId, optionsFilter)
Screen.FileOrFolderOptions(userId, linkId)
)
},
navigateToMultipleFileOrFolderOptions = { selectionId, optionsFilter ->
navigateToMultipleFileOrFolderOptions = { selectionId ->
navController.navigate(
Screen.MultipleFileOrFolderOptions(userId, selectionId, optionsFilter)
Screen.MultipleFileOrFolderOptions(userId, selectionId)
)
},
navigateToParentFolderOptions = { folderId ->
@@ -1269,8 +1281,8 @@ fun NavGraphBuilder.addTrash(navController: NavHostController) = composable(
inclusive = true,
)
},
navigateToEmptyTrash = {
navController.navigate(Screen.Files.Dialogs.ConfirmEmptyTrash(userId))
navigateToEmptyTrash = { volumeId ->
navController.navigate(Screen.Files.Dialogs.ConfirmEmptyTrash(userId, volumeId))
},
navigateToFileOrFolderOptions = { linkId ->
navController.navigate(Screen.FileOrFolderOptions(userId, linkId))
@@ -1341,18 +1353,19 @@ fun NavGraphBuilder.addPagerPreview(navController: NavHostController) = composab
arguments = listOf(
navArgument(Screen.PagerPreview.PAGER_TYPE) { type = NavType.EnumType(PagerType::class.java) },
navArgument(Screen.PagerPreview.USER_ID) { type = NavType.StringType },
navArgument(Screen.PagerPreview.SHARE_ID) { type = NavType.StringType },
navArgument(Screen.PagerPreview.FILE_ID) { type = NavType.StringType },
navArgument(Screen.PagerPreview.OPTIONS_FILTER) {
type = NavType.EnumType(OptionsFilter::class.java)
defaultValue = OptionsFilter.FILES
navArgument(Screen.PagerPreview.ALBUM_SHARE_ID) {
type = NavType.StringType
nullable = true
},
navArgument(Screen.PagerPreview.ALBUM_ID) {
type = NavType.StringType
nullable = true
},
),
) { navBackStackEntry ->
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
val optionsFilter = navBackStackEntry.requireSerializable(
Screen.PagerPreview.OPTIONS_FILTER,
OptionsFilter::class.java
)
PreviewScreen(
navigateBack = {
navController.popBackStack(
@@ -1360,9 +1373,9 @@ fun NavGraphBuilder.addPagerPreview(navController: NavHostController) = composab
inclusive = true,
)
},
navigateToFileOrFolderOptions = { linkId ->
navigateToFileOrFolderOptions = { linkId, albumId ->
navController.navigate(
Screen.FileOrFolderOptions(userId, linkId, optionsFilter)
Screen.FileOrFolderOptions(userId, linkId, albumId)
)
},
navigateToProtonDocsInsertImageOptions = {
@@ -2155,7 +2168,8 @@ fun NavGraphBuilder.addCreateNewAlbum(navController: NavHostController) = compos
arguments = listOf(
navArgument(Screen.Log.USER_ID) { type = NavType.StringType },
),
) {
) { navBackStackEntry ->
val userId = UserId(navBackStackEntry.require(Screen.PhotosAndAlbums.USER_ID))
CreateNewAlbumScreen(
navigateBack = {
navController.popBackStack(
@@ -2167,7 +2181,10 @@ fun NavGraphBuilder.addCreateNewAlbum(navController: NavHostController) = compos
navController.navigate(Screen.Album(albumId)) {
popUpTo(route = Screen.PhotosAndAlbums.CreateNewAlbum.route) { inclusive = true }
}
}
},
navigateToPicker = {
navController.navigate(Screen.Picker.PhotosAndAlbums(userId = userId))
},
)
}
@@ -2189,6 +2206,27 @@ fun NavGraphBuilder.addAlbum(navController: NavHostController) = composable(
navigateToAlbumOptions = { albumId ->
navController.navigate(Screen.AlbumOptions(userId, albumId))
},
navigateToPhotosOptions = { linkId, albumId ->
navController.navigate(
Screen.FileOrFolderOptions(userId, linkId, albumId)
)
},
navigateToMultiplePhotosOptions = { selectionId, albumId ->
navController.navigate(
Screen.MultipleFileOrFolderOptions(userId, selectionId, albumId)
)
},
navigateToPreview = { fileId, albumId ->
navController.navigate(Screen.PagerPreview(
pagerType = PagerType.ALBUM,
userId = userId,
fileId = fileId,
albumId = albumId,
))
},
navigateToPicker = { albumId ->
navController.navigate(Screen.Picker.PhotosAndAlbums(destinationAlbumId = albumId))
},
navigateBack = {
navController.popBackStack(
route = Screen.Album.route,
@@ -2260,6 +2298,82 @@ fun NavGraphBuilder.addConfirmDeleteAlbumDialog(navController: NavHostController
)
}
@ExperimentalAnimationApi
fun NavGraphBuilder.addPickerPhotosAndAlbums(navController: NavHostController) = composable(
route = Screen.Picker.PhotosAndAlbums.route,
enterTransition = defaultEnterSlideTransition { true },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = defaultPopExitSlideTransition { true },
arguments = listOf(
navArgument(Screen.Picker.USER_ID) { type = NavType.StringType },
navArgument(Screen.Picker.IN_PICKER_MODE) { type = NavType.BoolType },
navArgument(Screen.Picker.SHARE_ID) {
type = NavType.StringType
nullable = true
},
navArgument(Screen.Picker.ALBUM_ID) {
type = NavType.StringType
nullable = true
},
),
) {
PickerPhotosAndAlbumsScreen(
navigateToAlbum = { albumId, destinationAlbumId ->
val route = if (destinationAlbumId != null) {
Screen.Picker.Album(albumId, destinationAlbumId)
} else {
Screen.Picker.Album(albumId)
}
navController.navigate(route)
},
navigateBack = {
navController.popBackStack(
route = Screen.Picker.PhotosAndAlbums.route,
inclusive = true,
)
}
)
}
@ExperimentalAnimationApi
fun NavGraphBuilder.addPickerAlbum(navController: NavHostController) = composable(
route = Screen.Picker.Album.route,
enterTransition = defaultEnterSlideTransition { true },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = defaultPopExitSlideTransition { true },
arguments = listOf(
navArgument(Screen.Picker.USER_ID) { type = NavType.StringType },
navArgument(Screen.Picker.IN_PICKER_MODE) { type = NavType.BoolType },
navArgument(Screen.Picker.SHARE_ID) { type = NavType.StringType },
navArgument(Screen.Picker.ALBUM_ID) { type = NavType.StringType },
navArgument(Screen.Picker.DESTINATION_SHARE_ID) {
type = NavType.StringType
nullable = true
},
navArgument(Screen.Picker.DESTINATION_ALBUM_ID) {
type = NavType.StringType
nullable = true
},
),
) {
PickerAlbumScreen(
navigateBack = {
navController.popBackStack(
route = Screen.Picker.Album.route,
inclusive = true,
)
},
onAddToAlbumDone = {
navController.popBackStack(
route = Screen.Picker.PhotosAndAlbums.route,
inclusive = true,
)
},
)
}
private suspend fun CoroutineScope.announceScreen(
announceEvent: AnnounceEvent,
primaryAccount: Flow<Account?>,
@@ -35,7 +35,6 @@ import me.proton.android.drive.ui.navigation.animation.defaultExitSlideTransitio
import me.proton.android.drive.ui.navigation.animation.defaultPopEnterSlideTransition
import me.proton.android.drive.ui.navigation.animation.defaultPopExitSlideTransition
import me.proton.android.drive.ui.navigation.internal.DriveNavHost
import me.proton.android.drive.ui.options.OptionsFilter
import me.proton.android.drive.ui.screen.ComputersScreen
import me.proton.android.drive.ui.screen.FilesScreen
import me.proton.android.drive.ui.screen.PhotosAndAlbumsScreen
@@ -62,10 +61,10 @@ fun HomeNavGraph(
arguments: Bundle,
startDestination: String,
homeScaffoldState: HomeScaffoldState,
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
navigateToPhotosPermissionRationale: () -> Unit,
navigateToSubscription: () -> Unit,
@@ -86,10 +85,10 @@ fun HomeNavGraph(
deepLinkBaseUrl,
arguments,
homeScaffoldState,
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) },
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
navigateToSorting,
{ linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES) },
{ linkId -> navigateToFileOrFolderOptions(linkId) },
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) },
navigateToParentFolderOptions,
)
addPhotos(
@@ -98,10 +97,10 @@ fun HomeNavGraph(
arguments,
homeScaffoldState,
navigateToPhotosPermissionRationale,
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO, OptionsFilter.PHOTOS) },
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId, OptionsFilter.PHOTOS) },
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO) },
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId) },
navigateToMultiplePhotosOptions = { selectionId ->
navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.PHOTOS)
navigateToMultipleFileOrFolderOptions(selectionId)
},
navigateToSubscription = navigateToSubscription,
navigateToPhotosIssues = navigateToPhotosIssues,
@@ -115,10 +114,10 @@ fun HomeNavGraph(
arguments,
homeScaffoldState,
navigateToPhotosPermissionRationale,
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO, OptionsFilter.PHOTOS) },
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId, OptionsFilter.PHOTOS) },
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO) },
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId) },
navigateToMultiplePhotosOptions = { selectionId ->
navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.PHOTOS)
navigateToMultipleFileOrFolderOptions(selectionId)
},
navigateToSubscription = navigateToSubscription,
navigateToPhotosIssues = navigateToPhotosIssues,
@@ -133,10 +132,10 @@ fun HomeNavGraph(
deepLinkBaseUrl,
arguments,
homeScaffoldState,
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) },
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
navigateToSorting,
{ linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES) },
{ linkId -> navigateToFileOrFolderOptions(linkId) },
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) },
navigateToParentFolderOptions,
navigateToComputerOptions,
)
@@ -145,12 +144,12 @@ fun HomeNavGraph(
deepLinkBaseUrl = deepLinkBaseUrl,
arguments = arguments,
homeScaffoldState = homeScaffoldState,
navigateToFolderPreview = { fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) },
navigateToSinglePreview = { fileId -> navigateToPreview(fileId, PagerType.SINGLE, OptionsFilter.FILES) },
navigateToFolderPreview = { fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
navigateToSinglePreview = { fileId -> navigateToPreview(fileId, PagerType.SINGLE) },
navigateToSorting = navigateToSorting,
navigateToFileOrFolderOptions = { linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
navigateToFileOrFolderOptions = { linkId -> navigateToFileOrFolderOptions(linkId) },
navigateToMultipleFileOrFolderOptions = { selectionId ->
navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES)
navigateToMultipleFileOrFolderOptions(selectionId)
},
navigateToParentFolderOptions = navigateToParentFolderOptions,
navigateToUserInvitation = navigateToUserInvitation,
@@ -26,7 +26,6 @@ import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.proton.android.drive.ui.options.OptionsFilter
import me.proton.android.drive.ui.viewmodel.AlbumOptionsViewModel
import me.proton.android.drive.ui.viewmodel.AlbumViewModel
import me.proton.android.drive.ui.viewmodel.ComputerOptionsViewModel
@@ -36,6 +35,8 @@ 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.ShareInvitationOptionsViewModel
import me.proton.android.drive.ui.viewmodel.ShareMemberOptionsViewModel
import me.proton.android.drive.ui.viewmodel.UploadToViewModel
@@ -113,30 +114,42 @@ sealed class Screen(val route: String) {
}
data object FileOrFolderOptions : Screen(
"options/link/{userId}/shares/{shareId}/linkId={linkId}?optionsFilter={optionsFilter}"
"options/link/{userId}/shares/{shareId}/linkId={linkId}?albumShareId={albumShareId}&albumId={albumId}"
) {
operator fun invoke(
userId: UserId,
linkId: LinkId,
optionsFilter: OptionsFilter = OptionsFilter.FILES,
) = "options/link/${userId.id}/shares/${linkId.shareId.id}/linkId=${linkId.id}?optionsFilter=${optionsFilter.type}"
albumId: AlbumId? = 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}")
}
}
const val SHARE_ID = FileOrFolderOptionsViewModel.KEY_SHARE_ID
const val LINK_ID = FileOrFolderOptionsViewModel.KEY_LINK_ID
const val OPTIONS_FILTER = FileOrFolderOptionsViewModel.OPTIONS_FILTER
const val ALBUM_ID = FileOrFolderOptionsViewModel.KEY_ALBUM_ID
const val KEY_ALBUM_SHARE_ID = FileOrFolderOptionsViewModel.KEY_ALBUM_SHARE_ID
}
data object MultipleFileOrFolderOptions : Screen(
"options/multiple/{userId}/selectionId={selectionId}?optionsFilter={optionsFilter}"
"options/multiple/{userId}/selectionId={selectionId}?albumShareId={albumShareId}&albumId={albumId}"
) {
operator fun invoke(
userId: UserId,
selectionId: SelectionId,
optionsFilter: OptionsFilter = OptionsFilter.FILES,
) = "options/multiple/${userId.id}/selectionId=${selectionId.id}?optionsFilter=${optionsFilter.type}"
albumId: AlbumId? = null,
) = buildString {
append("options/multiple/${userId.id}/selectionId=${selectionId.id}")
if (albumId != null) {
append("?albumShareId=${albumId.shareId.id}&albumId=${albumId.id}")
}
}
const val SELECTION_ID = MultipleFileOrFolderOptionsViewModel.KEY_SELECTION_ID
const val OPTIONS_FILTER = MultipleFileOrFolderOptionsViewModel.OPTIONS_FILTER
const val ALBUM_SHARE_ID = MultipleFileOrFolderOptionsViewModel.KEY_ALBUM_SHARE_ID
const val ALBUM_ID = MultipleFileOrFolderOptionsViewModel.KEY_ALBUM_ID
}
data object ParentFolderOptions : Screen(
@@ -211,8 +224,11 @@ sealed class Screen(val route: String) {
const val SHARE_ID = "shareId"
}
data object ConfirmEmptyTrash : Screen("delete/{userId}/trash") {
operator fun invoke(userId: UserId) = "delete/${userId.id}/trash"
data object ConfirmEmptyTrash : Screen("delete/{userId}/trash?volumeId={volumeId}") {
operator fun invoke(
userId: UserId,
volumeId: VolumeId,
) = "delete/${userId.id}/trash?volumeId=${volumeId.id}"
}
data object Rename : Screen("rename/{userId}/shares/{shareId}/files?fileId={fileId}&folderId={folderId}&albumId={albumId}") {
@@ -279,6 +295,7 @@ sealed class Screen(val route: String) {
const val USER_ID = Screen.USER_ID
const val FOLDER_ID = "folderId"
const val VOLUME_ID = "volumeId"
const val SHARE_ID = "shareId"
const val FOLDER_NAME = "folderName"
}
@@ -367,12 +384,35 @@ sealed class Screen(val route: String) {
const val ALBUM_ID = AlbumViewModel.ALBUM_ID
}
object Picker {
data object PhotosAndAlbums : 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) =
"picker/${destinationAlbumId.userId.id}/photos/destination?inPickerMode=true&destinationShareId=${destinationAlbumId.shareId.id}&destinationAlbumId=${destinationAlbumId.id}"
}
data object Album : Screen("picker/{userId}/shares/{shareId}/albums/{albumId}/destination?inPickerMode={inPickerMode}&destinationShareId={destinationShareId}&destinationAlbumId={destinationAlbumId}") {
operator fun invoke(albumId: AlbumId) = "picker/${albumId.userId.id}/shares/${albumId.shareId.id}/albums/${albumId.id}/destination?inPickerMode=true"
operator fun invoke(albumId: AlbumId, destinationAlbumId: AlbumId) =
"picker/${albumId.userId.id}/shares/${albumId.shareId.id}/albums/${albumId.id}/destination?inPickerMode=true&destinationShareId=${destinationAlbumId.shareId.id}&destinationAlbumId=${destinationAlbumId.id}"
}
const val USER_ID = Screen.USER_ID
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
}
data object BackupIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}") {
fun invoke(folderId: FolderId) = "backup/issues/${folderId.shareId.userId.id}/shares/${folderId.shareId.id}/folder/${folderId.id}"
object Dialogs {
object ConfirmSkipIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}/confirm_skip?confirmPopUpRoute={confirmPopUpRoute}&confirmPopUpRouteInclusive={confirmPopUpRouteInclusive}"){
data object ConfirmSkipIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}/confirm_skip?confirmPopUpRoute={confirmPopUpRoute}&confirmPopUpRouteInclusive={confirmPopUpRouteInclusive}"){
operator fun invoke(
folderId: FolderId,
confirmPopUpRoute: String,
@@ -422,20 +462,25 @@ sealed class Screen(val route: String) {
filesBrowsableBuildRoute("offline", userId, folderId, folderName)
}
data object PagerPreview : Screen("pager/{pagerType}/preview/{userId}/shares/{shareId}/files/{fileId}?optionsFilter={optionsFilter}") {
data object PagerPreview : Screen("pager/{pagerType}/preview/{userId}/shares/{shareId}/files/{fileId}?albumShareId={albumShareId}&albumId={albumId}") {
operator fun invoke(
pagerType: PagerType,
userId: UserId,
fileId: FileId,
optionsFilter: OptionsFilter = OptionsFilter.FILES
) =
"pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}?optionsFilter=${optionsFilter.type}"
) = "pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}"
operator fun invoke(
pagerType: PagerType,
userId: UserId,
fileId: FileId,
albumId: AlbumId,
) = "pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}?albumShareId=${albumId.shareId.id}&albumId=${albumId.id}"
const val USER_ID = Screen.USER_ID
const val SHARE_ID = "shareId"
const val FILE_ID = "fileId"
const val ALBUM_SHARE_ID = "albumShareId"
const val ALBUM_ID = "albumId"
const val PAGER_TYPE = "pagerType"
const val OPTIONS_FILTER = FileOrFolderOptions.OPTIONS_FILTER
}
data object Settings : Screen("settings/{userId}") {
@@ -752,7 +797,11 @@ fun NavHostController.navigate(screen: Screen, builder: NavOptionsBuilder.() ->
}
enum class PagerType(val type: String) {
FOLDER("folder"), OFFLINE("offline"), SINGLE("single"), PHOTO("photo")
FOLDER("folder"),
OFFLINE("offline"),
SINGLE("single"),
PHOTO("photo"),
ALBUM("album"),
}
interface HomeTab {
@@ -38,9 +38,12 @@ 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
import me.proton.core.drive.files.presentation.entry.OpenInBrowserProtonDocsEntry
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.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.ToggleOfflineEntry
import me.proton.core.drive.files.presentation.entry.ToggleTrashEntry
@@ -162,7 +165,7 @@ sealed class Option(
runAction: RunAction,
navigateToMove: (linkId: LinkId, parentId: FolderId?) -> Unit,
) = MoveFileEntry { driveLink ->
runAction { navigateToMove(driveLink.id, driveLink.parentId) }
runAction { navigateToMove(driveLink.id, driveLink.parentId as? FolderId) }
}
fun build(
@@ -345,6 +348,43 @@ sealed class Option(
runAction { createAlbum() }
}
}
data object SetAsAlbumCover : Option(
ApplicableQuantity.Single,
setOf(ApplicableTo.FILE_PHOTO),
setOf(State.NOT_TRASHED) + State.ANY_SHARED
) {
fun build(
runAction: RunAction,
setAsAlbumCover: (DriveLink.File) -> Unit,
) = SetAsAlbumCoverEntry { driveLink ->
runAction { setAsAlbumCover(driveLink) }
} as FileOptionEntry<DriveLink>
}
data object RemoveFromAlbum : Option(
ApplicableQuantity.All,
setOf(ApplicableTo.FILE_PHOTO),
setOf(State.NOT_TRASHED) + State.ANY_SHARED
) {
fun build(
runAction: RunAction,
removeFromAlbum: (DriveLink.File) -> Unit,
) = RemoveFromAlbumFileEntry { driveLink ->
runAction {
removeFromAlbum(driveLink)
}
} as FileOptionEntry<DriveLink>
fun build(
runAction: RunAction,
removeSelectedFromAlbum: () -> Unit,
) = RemoveFromAlbumEntry {
runAction {
removeSelectedFromAlbum()
}
}
}
}
sealed class ApplicableQuantity(open val quantity: Long) {
@@ -403,23 +443,6 @@ fun Set<Option>.filter(driveLink: DriveLink) =
option.applicableStates.containsAll(driveLink.toOptionState()) && driveLink.isApplicableTo(option.applicableTo)
}
private val photosOptions = listOf(
Option.OfflineToggle,
Option.ShareViaInvitations,
Option.ManageAccess,
Option.SendFile,
Option.Download,
Option.Info,
Option.Trash,
Option.CreateAlbum,
)
fun Iterable<Option>.filter(optionsFilter: OptionsFilter) =
when (optionsFilter) {
OptionsFilter.FILES -> this
OptionsFilter.PHOTOS -> filter { option -> option in photosOptions }
}
fun Iterable<Option>.filterRoot(driveLink: DriveLink, featureFlag: FeatureFlag) = filter { option ->
if (driveLink.parentId == null) {
when (option) {
@@ -452,6 +475,7 @@ fun Iterable<Option>.filterPermissions(
Option.CreateDocument -> permissions.canWrite
Option.CreateAlbum -> permissions.canWrite
Option.CreateFolder -> permissions.canWrite
Option.DeleteAlbum -> permissions.isAdmin
Option.DeletePermanently -> permissions.canWrite
Option.Download -> permissions.canRead
Option.Info -> permissions.canRead
@@ -460,13 +484,14 @@ fun Iterable<Option>.filterPermissions(
Option.OfflineToggle -> permissions.canRead
Option.OpenInBrowser -> permissions.canRead
Option.Rename -> permissions.canWrite
Option.RemoveFromAlbum -> permissions.isAdmin
Option.RemoveMe -> permissions.canRead
Option.SendFile -> permissions.canRead
Option.SetAsAlbumCover -> permissions.isAdmin
Option.ShareViaInvitations -> permissions.isAdmin
Option.TakeAPhoto -> permissions.canWrite
Option.Trash -> permissions.isAdmin
Option.UploadFile -> permissions.canWrite
Option.RemoveMe -> permissions.canRead
Option.DeleteAlbum -> permissions.isAdmin
}
}
@@ -481,9 +506,13 @@ fun Iterable<Option>.filterProtonDocs(killSwitch: FeatureFlag) = filter { option
fun Iterable<Option>.filterAlbums(
featureFlagOn: Boolean,
killSwitch: FeatureFlag,
albumId: AlbumId? = null,
) = filter { option ->
val featureEnabled = featureFlagOn && killSwitch.off
when (option) {
Option.CreateAlbum -> featureFlagOn && killSwitch.off
Option.CreateAlbum -> featureEnabled
Option.RemoveFromAlbum -> featureEnabled && albumId != null
Option.SetAsAlbumCover -> featureEnabled && albumId != null
else -> true
}
}
@@ -18,7 +18,9 @@
package me.proton.android.drive.ui.screen
import androidx.activity.compose.BackHandler
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
@@ -30,12 +32,19 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import me.proton.android.drive.photos.presentation.component.Album
import me.proton.android.drive.ui.viewmodel.AlbumViewModel
import me.proton.core.compose.flow.rememberFlowWithLifecycle
import me.proton.core.drive.base.presentation.component.RepeatOnLifecycleLaunchedEffect
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun AlbumScreen(
navigateToAlbumOptions: (AlbumId) -> Unit,
navigateToPhotosOptions: (FileId, AlbumId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId, AlbumId?) -> Unit,
navigateToPreview: (FileId, AlbumId) -> Unit,
navigateToPicker: (AlbumId) -> Unit,
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -45,19 +54,32 @@ fun AlbumScreen(
val viewEvent = remember(lifecycle) {
viewModel.viewEvent(
navigateToAlbumOptions = navigateToAlbumOptions,
navigateToPhotosOptions = navigateToPhotosOptions,
navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions,
navigateToPreview = navigateToPreview,
navigateToPicker = navigateToPicker,
navigateBack = navigateBack,
lifecycle = lifecycle,
)
}
val photoItems = viewModel.driveLinks.collectAsLazyPagingItems()
val listEffect = rememberFlowWithLifecycle(flow = viewModel.listEffect)
viewState.value?.let { viewState ->
val selectedPhotos by viewState.selected.collectAsStateWithLifecycle(initialValue = emptySet())
val inMultiselect = remember(selectedPhotos) { selectedPhotos.isNotEmpty() }
BackHandler(enabled = inMultiselect) { viewEvent.onBack() }
RepeatOnLifecycleLaunchedEffect {
viewModel.initializeSelectionInPickerMode()
}
Album(
viewState = viewState,
viewEvent = viewEvent,
items = photoItems,
listEffect = listEffect,
driveLinksFlow = viewModel.driveLinksMap,
selectedPhotos = selectedPhotos,
isRefreshEnabled = viewState.isRefreshEnabled,
isRefreshing = viewState.listContentState.isRefreshing,
onRefresh = viewEvent.onRefresh,
@@ -62,6 +62,7 @@ import me.proton.core.presentation.R as CorePresentation
fun CreateNewAlbumScreen(
navigateBack: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToPicker: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<CreateNewAlbumViewModel>()
@@ -73,6 +74,7 @@ fun CreateNewAlbumScreen(
viewModel.viewEvent(
navigateBack = navigateBack,
navigateToAlbum = navigateToAlbum,
navigateToPicker = navigateToPicker,
)
}
val items = viewModel.photos.collectAsLazyPagingItems()
@@ -153,6 +155,7 @@ private fun CreateNewAlbumScreenPreview() {
isCreationInProgress = true,
isAlbumNameEnabled = true,
isAddEnabled = true,
isRemoveEnabled = true,
name = emptyFlow(),
hint = stringResource(I18N.string.albums_new_album_name_hint),
),
@@ -48,7 +48,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import me.proton.android.drive.ui.navigation.HomeNavGraph
import me.proton.android.drive.ui.navigation.PagerType
import me.proton.android.drive.ui.navigation.Screen
import me.proton.android.drive.ui.options.OptionsFilter
import me.proton.android.drive.ui.provider.setLocalSnackbarPadding
import me.proton.android.drive.ui.viewevent.HomeViewEvent
import me.proton.android.drive.ui.viewmodel.HomeViewModel
@@ -71,7 +70,7 @@ import me.proton.core.drive.link.domain.entity.LinkId
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.drive.android.settings.domain.entity.UserOverlay
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.drive.android.settings.domain.entity.WhatsNewKey
@Composable
@@ -88,11 +87,11 @@ fun HomeScreen(
navigateToSigningOut: () -> Unit,
navigateToTrash: () -> Unit,
navigateToOffline: () -> Unit,
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToSettings: () -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
navigateToSubscription: () -> Unit,
navigateToPhotosIssues: (FolderId) -> Unit,
@@ -186,10 +185,10 @@ internal fun Home(
deepLinkBaseUrl: String,
startDestination: String,
onDrawerStateChanged: (Boolean) -> Unit,
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
navigateToSorting: (sorting: Sorting) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
arguments: Bundle,
viewState: HomeViewState,
@@ -77,6 +77,7 @@ import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.headlineSmallNorm
import me.proton.core.compose.theme.headlineSmallUnspecified
import me.proton.core.drive.base.presentation.component.RepeatOnLifecycleLaunchedEffect
import me.proton.core.drive.base.presentation.component.TopAppBar
import me.proton.core.drive.base.presentation.component.TopBarActions
import me.proton.core.drive.base.presentation.effect.ListEffect
@@ -137,7 +138,7 @@ fun PhotosAndAlbumsScreen(
defaultTitle = defaultTitle,
)
Tab.ALBUMS -> AlbumTab(
Tab.ALBUMS -> AlbumsTab(
homeScaffoldState = homeScaffoldState,
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
navigateToAlbum = navigateToAlbum,
@@ -148,7 +149,7 @@ fun PhotosAndAlbumsScreen(
}
@Composable
private fun AlbumTab(
fun AlbumsTab(
homeScaffoldState: HomeScaffoldState,
navigateToCreateNewAlbum: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
@@ -193,7 +194,7 @@ private fun AlbumTab(
}
@Composable
private fun PhotosTab(
fun PhotosTab(
homeScaffoldState: HomeScaffoldState,
modifier: Modifier = Modifier,
navigateToPhotosPermissionRationale: () -> Unit,
@@ -239,6 +240,9 @@ private fun PhotosTab(
}.launchIn(this)
}
RepeatOnLifecycleLaunchedEffect {
viewModel.initializeSelectionInPickerMode()
}
PhotosTab(
homeScaffoldState = homeScaffoldState,
viewState = viewState,
@@ -320,7 +324,7 @@ private fun PhotosTab(
@Composable
private fun Tabs(
fun Tabs(
viewState: PhotosAndAlbumsViewState,
viewEvent: PhotosAndAlbumsViewEvent,
modifier: Modifier = Modifier,
@@ -0,0 +1,98 @@
/*
* 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.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
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.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.core.drive.base.presentation.extension.shadow
@Composable
fun PickerAlbumScreen(
navigateBack: () -> Unit,
onAddToAlbumDone: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<PickerPhotosAndAlbumsViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(initialValue = null)
val lifecycle = LocalLifecycleOwner.current
val viewEvent = remember(lifecycle) {
viewModel.viewEvent(
navigateBack = navigateBack,
onAddToAlbumDone = onAddToAlbumDone,
)
}
viewState?.let { viewState ->
PickerAlbumScreen(
addToAlbumTitle = viewState.addToAlbumButtonTitle,
isAddToAlbumButtonEnabled = viewState.isAddToAlbumButtonEnabled,
isAddToAlbumInProgress = viewState.isAddingInProgress,
isResetButtonEnabled = viewState.isResetButtonEnabled,
onBack = viewEvent.onBackPressed,
onReset = viewEvent.onReset,
onAddToAlbum = viewEvent.onAddToAlbum,
modifier = modifier.navigationBarsPadding(),
)
}
}
@Composable
fun PickerAlbumScreen(
addToAlbumTitle: String,
isAddToAlbumButtonEnabled: Boolean,
isAddToAlbumInProgress: Boolean,
isResetButtonEnabled: Boolean,
onBack: () -> Unit,
onReset: () -> Unit,
onAddToAlbum: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
) {
AlbumScreen(
navigateToAlbumOptions = {},
navigateToPhotosOptions = { _, _ -> },
navigateToMultiplePhotosOptions = { _, _ -> },
navigateToPreview = { _, _ -> },
navigateToPicker = { _ -> },
navigateBack = onBack,
)
BottomActions(
addToAlbumTitle = addToAlbumTitle,
onReset = onReset,
onAddToAlbum = onAddToAlbum,
isAddToAlbumButtonEnabled = isAddToAlbumButtonEnabled,
isAddToAlbumInProgress = isAddToAlbumInProgress,
isResetButtonEnabled = isResetButtonEnabled,
modifier = Modifier
.align(Alignment.BottomCenter)
.shadow(),
)
}
}
@@ -0,0 +1,262 @@
/*
* 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.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.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.PhotosAndAlbumsViewModel
import me.proton.android.drive.ui.viewmodel.PickerPhotosAndAlbumsViewModel
import me.proton.android.drive.ui.viewstate.PhotosAndAlbumsViewState.Tab
import me.proton.android.drive.ui.viewstate.rememberHomeScaffoldState
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.link.domain.entity.AlbumId
import me.proton.core.drive.base.presentation.component.TopAppBar as BaseTopAppBar
import me.proton.core.presentation.R as CorePresentation
@Composable
fun PickerPhotosAndAlbumsScreen(
navigateToAlbum: (AlbumId, AlbumId?) -> Unit,
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<PickerPhotosAndAlbumsViewModel>()
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 ->
PickerPhotosAndAlbumsScreen(
addToAlbumTitle = viewState.addToAlbumButtonTitle,
isAddToAlbumButtonEnabled = viewState.isAddToAlbumButtonEnabled,
isAddToAlbumInProgress = viewState.isAddingInProgress,
isResetButtonEnabled = viewState.isResetButtonEnabled,
onTopAppBarNavigationIcon = viewEvent.onBackPressed,
navigateToAlbum = { albumId: AlbumId ->
navigateToAlbum(albumId, viewModel.destinationAlbumId)
},
onReset = viewEvent.onReset,
onAddToAlbum = viewEvent.onAddToAlbum,
modifier = modifier.navigationBarsPadding(),
)
}
}
@Composable
fun PickerPhotosAndAlbumsScreen(
addToAlbumTitle: String,
isAddToAlbumButtonEnabled: Boolean,
isAddToAlbumInProgress: Boolean,
isResetButtonEnabled: Boolean,
onTopAppBarNavigationIcon: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
onReset: () -> Unit,
onAddToAlbum: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<PhotosAndAlbumsViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(
initialValue = viewModel.initialViewState
)
val lifecycle = LocalLifecycleOwner.current.lifecycle
val viewEvent = remember(lifecycle) {
viewModel.viewEvent()
}
PickerPhotosAndAlbums(
title = { titleModifier ->
Tabs(
viewState = viewState,
viewEvent = viewEvent,
modifier = titleModifier,
)
},
selectedTab = viewState.selectedTab,
onTopAppBarNavigationIcon = onTopAppBarNavigationIcon,
navigateToAlbum = navigateToAlbum,
onReset = onReset,
onAddToAlbum = onAddToAlbum,
addToAlbumTitle = addToAlbumTitle,
isAddToAlbumButtonEnabled = isAddToAlbumButtonEnabled,
isAddToAlbumInProgress = isAddToAlbumInProgress,
isResetButtonEnabled = isResetButtonEnabled,
modifier = modifier
.testTag(PickerPhotosAndAlbumsScreenTestTag.screen),
)
}
@Composable
fun PickerPhotosAndAlbums(
title: @Composable (Modifier) -> Unit,
addToAlbumTitle: String,
isAddToAlbumButtonEnabled: Boolean,
isAddToAlbumInProgress: Boolean,
isResetButtonEnabled: Boolean,
selectedTab: Tab,
onTopAppBarNavigationIcon: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
onReset: () -> Unit,
onAddToAlbum: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize(),
) {
TopAppBar(
title = title,
onNavigationIcon = onTopAppBarNavigationIcon,
)
Box(
modifier = Modifier.fillMaxSize(),
) {
val homeScaffoldState = rememberHomeScaffoldState().apply {
drawerGesturesEnabled.value = false
bottomNavigationEnabled.value = false
}
when (selectedTab) {
Tab.PHOTOS -> PhotosTab(
homeScaffoldState = homeScaffoldState,
navigateToPhotosPermissionRationale = {},
navigateToPhotosPreview = {},
navigateToPhotosOptions = {},
navigateToMultiplePhotosOptions = {},
navigateToSubscription = {},
navigateToPhotosIssues = {},
navigateToPhotosUpsell = {},
navigateToBackupSettings = {},
navigateToNotificationPermissionRationale = {},
defaultTitle = {},
)
Tab.ALBUMS -> AlbumsTab(
homeScaffoldState = homeScaffoldState,
navigateToCreateNewAlbum = {},
navigateToAlbum = navigateToAlbum,
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(PickerPhotosAndAlbumsScreenTestTag.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(PickerPhotosAndAlbumsScreenTestTag.addToAlbumButton)
.widthIn(min = 200.dp),
enabled = isAddToAlbumButtonEnabled,
loading = isAddToAlbumInProgress,
onClick = onAddToAlbum,
)
}
}
object PickerPhotosAndAlbumsScreenTestTag {
const val screen = "picker photos and albums screen"
const val resetButton = "reset button"
const val addToAlbumButton = "add to album button"
}
@@ -46,13 +46,14 @@ import me.proton.core.drive.base.presentation.component.ModalBottomSheet
import me.proton.core.drive.files.preview.presentation.component.Preview
import me.proton.core.drive.files.preview.presentation.component.PreviewEmpty
import me.proton.core.drive.files.preview.presentation.component.state.PreviewContentState
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.LinkId
import kotlin.time.Duration.Companion.seconds
@Composable
fun PreviewScreen(
navigateBack: () -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToFileOrFolderOptions: (LinkId, AlbumId?) -> Unit,
navigateToProtonDocsInsertImageOptions: () -> Unit,
) = PreviewScreen(
viewModel = hiltViewModel(),
@@ -67,7 +68,7 @@ fun PreviewScreen(
fun PreviewScreen(
viewModel: PreviewViewModel,
navigateBack: () -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToFileOrFolderOptions: (LinkId, AlbumId?) -> Unit,
navigateToProtonDocsInsertImageOptions: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -23,26 +23,18 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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
@@ -52,16 +44,12 @@ import me.proton.android.drive.ui.viewmodel.SharedTabsViewModel
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
import me.proton.android.drive.ui.viewstate.SharedTab
import me.proton.android.drive.ui.viewstate.SharedTabsViewState
import me.proton.core.compose.component.ProtonButton
import me.proton.core.compose.component.protonTextButtonColors
import me.proton.core.compose.theme.ProtonDimens.DefaultButtonMinHeight
import me.proton.core.compose.theme.ProtonDimens.SmallSpacing
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.drive.base.presentation.component.ProtonTab
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.base.presentation.component.TopAppBar as BaseTopAppBar
import me.proton.core.drive.i18n.R as I18N
@Composable
fun SharedTabsScreen(
@@ -116,8 +104,8 @@ fun SharedTabs(
horizontalArrangement = Arrangement.SpaceEvenly,
) {
viewState.tabs.forEach { sharedTab ->
SharedTab(
sharedTab = sharedTab,
ProtonTab(
titleResId = sharedTab.titleResId,
isSelected = viewState.selectedTab == sharedTab,
onTab = { viewEvent.onTab(sharedTab) }
)
@@ -163,78 +151,3 @@ private fun TopAppBar(
modifier = modifier,
)
}
@Composable
private fun SharedTab(
sharedTab: SharedTab,
isSelected: Boolean,
modifier: Modifier = Modifier,
onTab: (SharedTab) -> Unit,
) {
val brandColor = ProtonTheme.colors.brandNorm
val dividerColor = remember(isSelected) { if (isSelected) brandColor else Color.Transparent }
ProtonButton(
modifier = modifier,
onClick = { onTab(sharedTab) },
contentPadding = PaddingValues(horizontal = SmallSpacing),
colors = ButtonDefaults.protonTextButtonColors(false),
shape = RoundedCornerShape(0.dp),
border = null,
elevation = null,
) {
Box(
modifier = Modifier
.height(DefaultButtonMinHeight),
) {
Text(
text = stringResource(id = sharedTab.titleResId),
style = ProtonTheme.typography.body2Regular.copy(
color = if (isSelected) brandColor else ProtonTheme.colors.textWeak
),
modifier = Modifier
.align(Alignment.Center),
)
Box(
modifier = Modifier
.matchParentSize(),
contentAlignment = Alignment.BottomCenter,
) {
Divider(
color = dividerColor,
thickness = 4.dp,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(100.dp, 100.dp, 0.dp, 0.dp)),
)
}
}
}
}
@Preview
@Composable
fun PreviewSharedTab() {
ProtonTheme {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
SharedTab(
sharedTab = SharedTab(
SharedTab.Type.SHARED_WITH_ME,
I18N.string.shared_with_me_title,
),
isSelected = true,
onTab = { _ -> }
)
SharedTab(
sharedTab = SharedTab(
SharedTab.Type.SHARED_BY_ME,
I18N.string.shared_by_me_title,
),
isSelected = false,
onTab = { _ -> }
)
}
}
}
@@ -19,11 +19,15 @@
package me.proton.android.drive.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -34,6 +38,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
@@ -54,22 +59,23 @@ import me.proton.core.compose.theme.defaultSmallStrong
import me.proton.core.drive.base.presentation.component.ActionButton
import me.proton.core.drive.base.presentation.component.ModalBottomSheet
import me.proton.core.drive.base.presentation.component.ProtonPullToRefresh
import me.proton.core.drive.base.presentation.component.ProtonTab
import me.proton.core.drive.base.presentation.component.TopAppBarHeight
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.LinkId
import me.proton.core.drive.sorting.domain.entity.Sorting
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.util.kotlin.exhaustive
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
@Composable
@ExperimentalCoroutinesApi
@OptIn(ExperimentalMaterialApi::class)
fun TrashScreen(
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
navigateToEmptyTrash: () -> Unit,
navigateToEmptyTrash: (VolumeId) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToSortingDialog: (Sorting) -> Unit,
) {
@@ -95,10 +101,10 @@ fun TrashScreen(
type = ProtonSnackbarType.ERROR,
message = effect.message
)
TrashEffect.MoreOptions -> {
is TrashEffect.MoreOptions -> {
modalBottomSheetContentState.sheetContent.value = { runAction ->
TrashMoreOptions {
runAction { navigateToEmptyTrash() }
runAction { navigateToEmptyTrash(effect.volumeId) }
}
}
modalBottomSheetContentState.sheetState.show()
@@ -112,7 +118,7 @@ fun TrashScreen(
sheetContent = modalBottomSheetContentState.sheetContent.value,
viewState = remember { ModalBottomSheetViewState() },
) {
Box(
Column(
modifier = modifier.systemBarsPadding()
) {
ProtonPullToRefresh(
@@ -125,6 +131,28 @@ fun TrashScreen(
driveLinks = DriveLinksFlow.PagingList(viewModel.driveLinks, viewModel.listEffect),
viewState = viewState,
viewEvent = viewEvent,
tabs = {
if (viewState.volumesEntries.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
viewState.volumesEntries.forEach { entry ->
ProtonTab(
titleResId = entry.title,
isSelected = entry.isSelected,
onTab = { viewEvent.onTab(entry) }
)
}
}
Divider(
color = ProtonTheme.colors.separatorNorm,
modifier = Modifier
.fillMaxWidth()
.height(1.dp),
)
}
}
) {
Crossfade(trashIconState) { trashIconState ->
when (trashIconState) {
@@ -0,0 +1,25 @@
/*
* 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
interface PickerPhotosAndAlbumsViewEvent {
val onBackPressed: () -> Unit
val onReset: () -> Unit
val onAddToAlbum: () -> Unit
}
@@ -19,8 +19,9 @@
package me.proton.android.drive.ui.viewmodel
import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import androidx.paging.CombinedLoadStates
import androidx.paging.PagingData
@@ -44,12 +45,18 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
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.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.onSuccess
import me.proton.core.drive.base.data.extension.log
@@ -59,18 +66,26 @@ import me.proton.core.drive.base.domain.extension.onFailure
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.effect.ListEffect
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.UserViewModel
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.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.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
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.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.domain.entity.Share
import me.proton.core.drive.share.domain.entity.ShareId
@@ -87,12 +102,32 @@ import me.proton.core.presentation.R as CorePresentation
class AlbumViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getDecryptedDriveLink: GetDecryptedDriveLink,
selectLinks: SelectLinks,
deselectLinks: DeselectLinks,
selectAll: SelectAll,
getSelectedDriveLinks: GetSelectedDriveLinks,
getPhotoListingCount: GetPhotoListingCount,
addToAlbumInfo: AddToAlbumInfo,
removeFromAlbumInfo: RemoveFromAlbumInfo,
getAddToAlbumPhotoListings: GetAddToAlbumPhotoListings,
@ApplicationContext private val appContext: Context,
private val onFilesDriveLinkError: OnFilesDriveLinkError,
private val photoDriveLinks: PhotoDriveLinks,
private val getPagedAlbumPhotoListingsList: GetPagedAlbumPhotoListingsList,
private val configurationProvider: ConfigurationProvider,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val broadcastMessages: BroadcastMessages,
) : PhotosPickerAndSelectionViewModel(
savedStateHandle = savedStateHandle,
selectLinks = selectLinks,
deselectLinks = deselectLinks,
selectAll = selectAll,
getSelectedDriveLinks = getSelectedDriveLinks,
getPhotoListingCount = getPhotoListingCount,
addToAlbumInfo = addToAlbumInfo,
removeFromAlbumInfo = removeFromAlbumInfo,
getAddToAlbumPhotoListings = getAddToAlbumPhotoListings,
) {
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 var fetchingJob: Job? = null
@@ -123,6 +158,7 @@ class AlbumViewModel @Inject constructor(
driveLink.coverLinkId?.let { coverLinkId ->
photoDriveLinks.load(setOf(coverLinkId))
}
parentId.value = driveLink.id
return@mapWithPrevious driveLink
}
.onFailure { error ->
@@ -151,7 +187,17 @@ class AlbumViewModel @Inject constructor(
val viewState: Flow<AlbumViewState> = combine(
driveLink.filterNotNull(),
listContentState,
) { album, contentState ->
selected,
) { album, contentState, selected ->
topBarActions.value = when {
inPickerMode -> emptySet()
selected.isNotEmpty() -> setOf(
selectedOptionsAction {
viewEvent?.onSelectedOptions?.invoke()
}
)
else -> setOf(albumOptionsAction)
}
AlbumViewState(
name = album.name,
details = album.details(appContext),
@@ -159,6 +205,21 @@ class AlbumViewModel @Inject constructor(
listContentState = contentState,
isRefreshEnabled = contentState !is ListContentState.Loading,
topBarActions = topBarActions,
selected = this@AlbumViewModel.selected,
inMultiselect = selected.isNotEmpty() || inPickerMode,
showActions = !inPickerMode,
navigationIconResId = if (selected.isEmpty() || inPickerMode) {
CorePresentation.drawable.ic_arrow_back
} else {
CorePresentation.drawable.ic_proton_close
},
title = takeIf { selected.isNotEmpty() && !inPickerMode }
?.let {
appContext.quantityString(
I18N.plurals.common_selected,
selected.size
)
},
)
}
@@ -189,8 +250,37 @@ class AlbumViewModel @Inject constructor(
fun viewEvent(
navigateToAlbumOptions: (AlbumId) -> Unit,
navigateToPhotosOptions: (FileId, AlbumId?) -> Unit,
navigateToMultiplePhotosOptions: (selectionId: SelectionId, AlbumId?) -> Unit,
navigateToPreview: (FileId, AlbumId) -> Unit,
navigateToPicker: (AlbumId) -> Unit,
navigateBack: () -> Unit,
lifecycle: Lifecycle,
) : AlbumViewEvent = object : AlbumViewEvent {
private val driveLinkShareFlow =
MutableSharedFlow<DriveLink>(extraBufferCapacity = 1).also { flow ->
viewModelScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
flow.take(1).collect { driveLink ->
driveLink.onClick(
navigateToFolder = { _, _ -> error("Photos should not have folders") },
navigateToPreview = { linkId ->
viewModelScope.launch {
navigateToPreview(
linkId,
this@AlbumViewModel.driveLink.filterNotNull().first().id,
)
}
},
)
}
}
}
}
override val onTopAppBarNavigation = onTopAppBarNavigation {
onBackPressed()
}
override val onBackPressed = { navigateBack() }
override val onLoadState: (CombinedLoadStates, Int) -> Unit = onLoadState(
appContext = appContext,
@@ -200,14 +290,17 @@ class AlbumViewModel @Inject constructor(
coroutineScope = viewModelScope,
emptyState = MutableStateFlow(
ListContentState.Empty(
imageResId = BasePresentation.drawable.empty_folder_dark, //TODO: replace with proper illustration once available
titleId = I18N.string.albums_new_album_name_hint, //TODO: replace with proper text once available
imageResId = BasePresentation.drawable.ic_proton_images_50,
titleId = I18N.string.albums_empty_album_screen_title,
descriptionResId = I18N.string.albums_empty_album_screen_description,
)
),
) { message ->
viewModelScope.launch {
//TODO: we need a snackbar
}
broadcastMessages(
userId = userId,
message = message,
type = BroadcastMessage.Type.ERROR,
)
}
override val onScroll = this@AlbumViewModel::onScroll
override val onErrorAction = { retry() }
@@ -218,10 +311,37 @@ class AlbumViewModel @Inject constructor(
}
Unit
}
override val onDriveLink = { driveLink: DriveLink ->
onDriveLink(driveLink) {
driveLinkShareFlow.tryEmit(driveLink)
Unit
}
}
override val onSelectDriveLink = { driveLink: DriveLink ->
onSelectDriveLink(driveLink)
}
override val onAddToAlbum = { onAddToAlbum(navigateToPicker) }
override val onSelectedOptions =
{ onSelectedOptions(navigateToPhotosOptions, navigateToMultiplePhotosOptions, albumId) }
override val onBack = { onBack() }
}.also { viewEvent ->
this.viewEvent = viewEvent
}
override fun onTopAppBarNavigation(nonSelectedBlock: () -> Unit): () -> Unit = {
if (inPickerMode) {
viewEvent?.onBackPressed?.invoke()
} else {
super.onTopAppBarNavigation(nonSelectedBlock).invoke()
}
}
private fun onAddToAlbum(navigateToPicker: (AlbumId) -> Unit) {
viewModelScope.launch {
navigateToPicker(this@AlbumViewModel.driveLink.filterNotNull().first().id)
}
}
private fun onScroll(driveLinkIds: Set<LinkId>) {
if (driveLinkIds.isNotEmpty()) {
fetchingJob?.cancel()
@@ -77,6 +77,7 @@ import me.proton.core.util.kotlin.CoreLogger
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
import me.proton.core.drive.base.presentation.R as BasePresentation
@HiltViewModel
class AlbumsViewModel @Inject constructor(
@@ -198,7 +199,11 @@ class AlbumsViewModel @Inject constructor(
is DataResult.Success -> emit(result.value).also {
if (result.value.isEmpty()) {
listContentState.value = ListContentState.Empty(0, 0)
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,
)
} else {
listContentState.value = ListContentState.Content()
}
@@ -25,8 +25,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import me.proton.android.drive.ui.viewevent.ConfirmEmptyTrashViewEvent
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.log.LogTag.TRASH
import me.proton.core.drive.base.domain.log.logId
import me.proton.core.drive.base.presentation.extension.require
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.trash.domain.usecase.EmptyTrash
import me.proton.core.drive.volume.domain.entity.VolumeId
import javax.inject.Inject
@HiltViewModel
@@ -36,13 +41,22 @@ class ConfirmEmptyTrashDialogViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val volumeId: VolumeId = VolumeId(savedStateHandle.require(VOLUME_ID))
fun viewEvent(dismiss: () -> Unit) = object : ConfirmEmptyTrashViewEvent {
override val onConfirm = {
viewModelScope.launch {
emptyTrash(userId)
emptyTrash(userId, volumeId).onFailure { error ->
error.log(TRASH, "Cannot empty trash volumeId: ${volumeId.id.logId()}")
}
dismiss()
}
Unit
}
}
companion object {
const val VOLUME_ID = "volumeId"
}
}
@@ -26,9 +26,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import me.proton.android.drive.ui.navigation.Screen
import me.proton.android.drive.ui.viewevent.ConfirmStopSharingViewEvent
@@ -38,7 +36,7 @@ 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.presentation.extension.require
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.drivelink.shared.domain.usecase.DeleteShareUrl
import me.proton.core.drive.drivelink.shared.domain.usecase.DeleteShareUrlAndShare
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.extension.userId
@@ -52,7 +50,7 @@ import me.proton.core.drive.i18n.R as I18N
@Suppress("StaticFieldLeak")
class ConfirmStopLinkSharingDialogViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val deleteShareUrl: DeleteShareUrl,
private val deleteShareUrlAndShare: DeleteShareUrlAndShare,
private val broadcastMessages: BroadcastMessages,
savedStateHandle: SavedStateHandle,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
@@ -78,16 +76,16 @@ class ConfirmStopLinkSharingDialogViewModel @Inject constructor(
fun viewEvent(confirm: () -> Unit) = object : ConfirmStopSharingViewEvent {
override val onConfirm = {
viewModelScope.launch {
deleteShareUrl(confirm)
stopSharing(confirm)
}
Unit
}
}
private suspend fun deleteShareUrl(confirm: () -> Unit) {
private suspend fun stopSharing(confirm: () -> Unit) {
isLoading.emit(true)
errorMessage.emit(null)
val result = deleteShareUrl(linkId)
val result = deleteShareUrlAndShare(linkId)
isLoading.emit(false)
result.onSuccess {
broadcastMessages(
@@ -84,6 +84,7 @@ class CreateNewAlbumViewModel @Inject constructor(
private val currentAlbumName = MutableStateFlow<String?>(null)
private val initialAlbumName = flowOf {
getNewAlbumName(userId)
.onSuccess { albumName -> currentAlbumName.value = albumName }
.getOrNull(VIEW_MODEL, "Get new album info failed")
?: ""
}
@@ -97,6 +98,7 @@ class CreateNewAlbumViewModel @Inject constructor(
isDoneEnabled = true,
isAlbumNameEnabled = true,
isAddEnabled = true,
isRemoveEnabled = true,
isCreationInProgress = isCreationInProgress.value,
name = initialAlbumName,
hint = appContext.getString(I18N.string.albums_new_album_name_hint),
@@ -107,10 +109,11 @@ class CreateNewAlbumViewModel @Inject constructor(
isCreationInProgress,
) { isEnabled, isInProgress ->
initialViewState.copy(
isDoneEnabled = isEnabled,
isDoneEnabled = isEnabled && !isInProgress,
isCreationInProgress = isInProgress,
isAlbumNameEnabled = !isInProgress,
isAddEnabled = !isInProgress,
isRemoveEnabled = !isInProgress,
)
}
@@ -129,6 +132,7 @@ class CreateNewAlbumViewModel @Inject constructor(
fun viewEvent(
navigateBack: () -> Unit,
navigateToAlbum: (AlbumId) -> Unit,
navigateToPicker: () -> Unit,
): CreateNewAlbumViewEvent = object : CreateNewAlbumViewEvent {
override val onBackPressed = { navigateBack() }
override val onDone = { onCreateAlbum(navigateToAlbum) }
@@ -136,6 +140,7 @@ class CreateNewAlbumViewModel @Inject constructor(
override val onLoadState = { _: CombinedLoadStates, _: Int -> }
override val onScroll = this@CreateNewAlbumViewModel::onScroll
override val onRemove = this@CreateNewAlbumViewModel::onRemove
override val onAdd = { navigateToPicker() }
}
private fun onCreateAlbum(navigateToAlbum: (AlbumId) -> Unit) {
@@ -32,9 +32,12 @@ 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.RemovePhotosFromAlbum
import me.proton.android.drive.photos.presentation.extension.processRemove
import me.proton.android.drive.ui.options.Option
import me.proton.android.drive.ui.options.OptionsFilter
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.filterProtonDocs
import me.proton.android.drive.ui.options.filterRoot
@@ -59,14 +62,18 @@ 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.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.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.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
@@ -84,6 +91,7 @@ class FileOrFolderOptionsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getDriveLink: GetDecryptedDriveLink,
getFeatureFlagFlow: GetFeatureFlagFlow,
albumsFeatureFlag: AlbumsFeatureFlag,
private val toggleOffline: ToggleOffline,
private val toggleTrashState: ToggleTrashState,
private val exportTo: ExportTo,
@@ -92,12 +100,20 @@ class FileOrFolderOptionsViewModel @Inject constructor(
private val configurationProvider: ConfigurationProvider,
private val broadcastMessages: BroadcastMessages,
private val openProtonDocumentInBrowser: OpenProtonDocumentInBrowser,
private val updateAlbumCover: UpdateAlbumCover,
private val removePhotosFromAlbum: RemovePhotosFromAlbum,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private var dismiss: (() -> Unit)? = null
private val linkId: LinkId = FileId(
ShareId(userId, savedStateHandle.require(KEY_SHARE_ID)),
savedStateHandle.require(KEY_LINK_ID)
)
private val albumId: AlbumId? = savedStateHandle.get<String>(KEY_ALBUM_ID)?.let { albumId ->
AlbumId(
ShareId(userId, savedStateHandle.require(KEY_ALBUM_SHARE_ID)),
albumId
)
}
val driveLink: Flow<DriveLink?> = getDriveLink(
linkId = linkId,
failOnDecryptionError = false,
@@ -110,7 +126,6 @@ class FileOrFolderOptionsViewModel @Inject constructor(
new
}
.stateIn(viewModelScope, Eagerly, null)
private val optionsFilter = savedStateHandle.require<OptionsFilter>(OPTIONS_FILTER)
private val sharingDevelopment = getFeatureFlagFlow(driveSharingDevelopment(userId))
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveSharingDevelopment(userId), NOT_FOUND))
@@ -118,6 +133,11 @@ 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))
fun <T : DriveLink> entries(
runAction: (suspend () -> Unit) -> Unit,
navigateToInfo: (linkId: LinkId) -> Unit,
@@ -133,10 +153,12 @@ class FileOrFolderOptionsViewModel @Inject constructor(
this.driveLink.filterNotNull(),
sharingDevelopment,
docsKillSwitch,
) { driveLink, sharingDevelopment, protonDocsKillSwitch ->
albumsFeature,
albumsKillSwitch,
) { driveLink, sharingDevelopment, protonDocsKillSwitch, albumsFeatureFlagOn, albumsKillSwitch ->
options
.filter(driveLink)
.filter(optionsFilter)
.filterAlbums(albumsFeatureFlagOn, albumsKillSwitch, albumId)
.filterRoot(driveLink, sharingDevelopment)
.filterShareMember(driveLink.isShareMember)
.filterPermissions(driveLink.sharePermissions ?: Permissions.owner)
@@ -176,6 +198,16 @@ class FileOrFolderOptionsViewModel @Inject constructor(
openProtonDocumentInBrowser(driveLink)
}
}
is Option.SetAsAlbumCover -> option.build(runAction) { driveLink ->
viewModelScope.launch {
setAsAlbumCover(driveLink)
}
}
is Option.RemoveFromAlbum -> option.build(runAction) { driveLink ->
viewModelScope.launch {
removePhotosFromAlbum(driveLink)
}
}
else -> throw IllegalStateException(
"Option ${option.javaClass.simpleName} is not found. Did you forget to add it?"
)
@@ -185,6 +217,61 @@ class FileOrFolderOptionsViewModel @Inject constructor(
}
}
private suspend fun removePhotosFromAlbum(
driveLink: DriveLink.File,
) {
removePhotosFromAlbum(
albumId = requireNotNull(albumId),
fileIds = listOf(driveLink.id),
)
.onFailure { error ->
error.log(VIEW_MODEL, "Failed to remove file from album")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage,
),
type = BroadcastMessage.Type.ERROR,
)
}
.onSuccess { result ->
result.processRemove(appContext) { message, type ->
broadcastMessages(
userId = userId,
message = message,
type = type,
)
}
}
}
private suspend fun setAsAlbumCover(
driveLink: DriveLink.File
) {
updateAlbumCover(
volumeId = driveLink.volumeId,
albumId = requireNotNull(albumId),
newCoverFileId = driveLink.id
).onFailure { error ->
error.log(LogTag.ALBUM, "Cannot update album cover: ${driveLink.id.id.logId()}")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.ERROR
)
}.onSuccess {
broadcastMessages(
userId = userId,
message = appContext.getString(I18N.string.albums_set_album_as_cover_success),
type = BroadcastMessage.Type.INFO,
)
}
}
private suspend fun leaveShare(driveLink: DriveLink) {
val shareId = driveLink.sharingDetails?.shareId
val memberId = driveLink.shareUser?.id
@@ -224,9 +311,12 @@ class FileOrFolderOptionsViewModel @Inject constructor(
companion object {
const val KEY_SHARE_ID = "shareId"
const val KEY_LINK_ID = "linkId"
const val OPTIONS_FILTER = "optionsFilter"
const val KEY_ALBUM_ID = "albumId"
const val KEY_ALBUM_SHARE_ID = "albumShareId"
private val options = setOf(
Option.SetAsAlbumCover,
Option.RemoveFromAlbum,
Option.OfflineToggle,
Option.ShareViaInvitations,
Option.ManageAccess,
@@ -146,7 +146,7 @@ class FilesViewModel @Inject constructor(
result
.onSuccess { driveLink ->
CoreLogger.d(VIEW_MODEL, "drive link onSuccess")
parentFolderId.value = driveLink.id
parentId.value = driveLink.id
return@mapWithPrevious driveLink
}
.onFailure { error ->
@@ -363,7 +363,10 @@ class FilesViewModel @Inject constructor(
override val onAppendErrorAction = { retry() }
override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) }
override val onSelectedOptions = {
onSelectedOptions(navigateToFileOrFolderOptions, navigateToMultipleFileOrFolderOptions)
onSelectedOptions(
{ linkId: LinkId, _ -> navigateToFileOrFolderOptions(linkId) },
{ selectionId: SelectionId, _ -> navigateToMultipleFileOrFolderOptions(selectionId) },
)
}
override val onParentFolderOptions = { onParentFolderOptions(navigateToParentFolderOptions) }
override val onCancelUpload = { uploadFileLink: UploadFileLink -> onCancelUpload(uploadFileLink) }
@@ -61,8 +61,8 @@ 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.GetUserInvitationCountFlow
import me.proton.core.drive.share.user.domain.usecase.HasUserInvitationFlow
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.entity.User
import me.proton.core.util.kotlin.CoreLogger
@@ -39,16 +39,19 @@ import me.proton.core.compose.component.ProtonSnackbarType
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.base.presentation.common.getThemeDrawableId
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.list.domain.usecase.GetPagedDriveLinksList
import me.proton.core.drive.files.presentation.state.FilesViewState
import me.proton.core.drive.base.presentation.effect.ListEffect
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.effect.ListEffect
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
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.list.domain.usecase.GetPagedDriveLinksList
import me.proton.core.drive.files.presentation.state.FilesViewState
import me.proton.core.drive.link.domain.entity.Folder
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.ParentId
import me.proton.core.drive.link.domain.extension.requireFolderId
import me.proton.core.drive.sorting.domain.entity.Sorting
import me.proton.core.drive.base.presentation.R as BasePresentation
import me.proton.core.drive.i18n.R as I18N
@@ -85,18 +88,18 @@ abstract class HostFilesViewModel(
isClickEnabled = { driveLink -> driveLink is Folder },
isTextEnabled = { driveLink -> FilesViewState.defaultIsTextEnabled(driveLink) && driveLink is Folder }
)
protected val trigger = MutableSharedFlow<FolderId?>(replay = 1).apply { tryEmit(parentId) }
protected val trigger = MutableSharedFlow<ParentId?>(replay = 1).apply { tryEmit(parentId) }
protected val parentLink = trigger
.transformLatest { folderId ->
.transformLatest { parentId ->
listContentState.value = ListContentState.Loading
emitAll(getDriveLink(userId, folderId = folderId))
emitAll(getDriveLink(userId = userId, parentId = parentId))
}
.mapSuccessValueOrNull()
.stateIn(viewModelScope, SharingStarted.Lazily, null)
val driveLinks = parentLink
.transformLatest { driveLink ->
emit(PagingData.empty())
if (driveLink != null) {
if (driveLink != null && driveLink is Folder) {
emitAll(getPagedDriveLinks(folderId = driveLink.id))
}
}.cachedIn(viewModelScope)
@@ -155,7 +158,11 @@ abstract class HostFilesViewModel(
open fun onCreateFolder(
navigateToCreateFolder: (FolderId) -> Unit,
) {
parentLink.value?.let { folder -> navigateToCreateFolder(folder.id) }
parentLink.value?.let { parent ->
(parent as? DriveLink.Folder)?.let { folder ->
navigateToCreateFolder(folder.id)
}
}
}
private fun retry() {
@@ -188,7 +188,7 @@ class MoveToFolderViewModel @Inject constructor(
}
private fun confirmMove(navigateBack: () -> Unit) = viewModelScope.launch {
val folder = parentLink.value
val folder = parentLink.value as? DriveLink.Folder
if (folder != null) {
if (folder.id != parentId) {
moveFile(userId, driveLinksToMove.value.map { driveLink -> driveLink.id }, folder.id)
@@ -18,25 +18,31 @@
package me.proton.android.drive.ui.viewmodel
import android.content.Context
import android.os.Build
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.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.proton.android.drive.extension.getDefaultMessage
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
import me.proton.android.drive.photos.presentation.extension.processRemove
import me.proton.android.drive.ui.options.Option
import me.proton.android.drive.ui.options.OptionsFilter
import me.proton.android.drive.ui.options.filter
import me.proton.android.drive.ui.options.filterAlbums
import me.proton.android.drive.ui.options.filterAll
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
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.require
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.documentsprovider.domain.usecase.ExportToDownload
@@ -50,9 +56,13 @@ import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.d
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.OptionEntry
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.extension.requireFolderId
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.trash.domain.usecase.SendToTrash
import javax.inject.Inject
@@ -62,17 +72,25 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
getSelectedDriveLinks: GetSelectedDriveLinks,
getFeatureFlagFlow: GetFeatureFlagFlow,
albumsFeatureFlag: AlbumsFeatureFlag,
configurationProvider: ConfigurationProvider,
@ApplicationContext private val appContext: Context,
private val sendToTrash: SendToTrash,
private val exportToDownload: ExportToDownload,
private val deselectLinks: DeselectLinks,
private val addToAlbumInfo: AddToAlbumInfo,
private val removePhotosFromAlbum: RemovePhotosFromAlbum,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val selectionId = SelectionId(requireNotNull(savedStateHandle.get(KEY_SELECTION_ID)))
val selectedDriveLinks: Flow<List<DriveLink>> = getSelectedDriveLinks(selectionId)
// Send -> ACTION_SEND_MULTIPLE (mime type aggregation) - we need to update sendfiledialog with multiple file download
// Mime type aggregation - all the same use that one, all same prefix use prefix/* else use */*
private val optionsFilter = savedStateHandle.require<OptionsFilter>(OPTIONS_FILTER)
private val albumId: AlbumId? = savedStateHandle.get<String>(KEY_ALBUM_ID)?.let { albumId ->
AlbumId(
ShareId(userId, savedStateHandle.require(KEY_ALBUM_SHARE_ID)),
albumId
)
}
private val albumsFeature = albumsFeatureFlag(userId)
.stateIn(viewModelScope, Eagerly, configurationProvider.albumsFeatureFlag)
private val albumsKillSwitch = getFeatureFlagFlow(driveAlbumsDisabled(userId))
@@ -90,8 +108,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
) { albumsFeatureFlagOn, albumsKillSwitch ->
options
.filterAll(driveLinks)
.filter(optionsFilter)
.filterAlbums(albumsFeatureFlagOn, albumsKillSwitch)
.filterAlbums(albumsFeatureFlagOn, albumsKillSwitch, albumId)
.map { option ->
when (option) {
is Option.Trash -> option.build(
@@ -106,7 +123,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
is Option.Move -> option.build(
runAction = runAction,
navigateToMoveAll = {
navigateToMove(selectionId, driveLinks.first().parentId)
navigateToMove(selectionId, driveLinks.first().requireFolderId())
}
)
is Option.Download -> option.build(
@@ -136,6 +153,18 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
}
},
)
is Option.RemoveFromAlbum -> option.build(
runAction = runAction,
removeSelectedFromAlbum = {
viewModelScope.launch {
removePhotosFromAlbum(
driveLinks
.filterIsInstance<DriveLink.File>()
)
deselectLinks(selectionId)
}
},
)
else -> throw IllegalStateException(
"Option ${option.javaClass.simpleName} is not found. Did you forget to add it?"
)
@@ -146,11 +175,42 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
}
}
private suspend fun removePhotosFromAlbum(
driveLinks: List<DriveLink.File>,
) {
removePhotosFromAlbum(
albumId = requireNotNull(albumId),
fileIds = driveLinks.map { driveLink -> driveLink.id },
)
.onFailure { error ->
error.log(VIEW_MODEL, "Failed to remove file from album")
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage,
),
type = BroadcastMessage.Type.ERROR,
)
}
.onSuccess { result ->
result.processRemove(appContext) { message, type ->
broadcastMessages(
userId = userId,
message = message,
type = type,
)
}
}
}
companion object {
const val KEY_SELECTION_ID = "selectionId"
const val OPTIONS_FILTER = "optionsFilter"
const val KEY_ALBUM_ID = "albumId"
const val KEY_ALBUM_SHARE_ID = "albumShareId"
private val options = setOfNotNull(
Option.RemoveFromAlbum,
Option.CreateAlbum,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Option.Download else null,
Option.Move,
@@ -0,0 +1,126 @@
/*
* 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.viewModelScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
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.core.drive.base.domain.extension.getOrNull
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
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
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
import me.proton.core.drive.link.domain.entity.AlbumId
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.share.domain.entity.ShareId
open class PhotosPickerAndSelectionViewModel(
savedStateHandle: SavedStateHandle,
selectLinks: SelectLinks,
deselectLinks: DeselectLinks,
selectAll: SelectAll,
getSelectedDriveLinks: GetSelectedDriveLinks,
private val getPhotoListingCount: GetPhotoListingCount,
private val addToAlbumInfo: AddToAlbumInfo,
private val removeFromAlbumInfo: RemoveFromAlbumInfo,
private val getAddToAlbumPhotoListings: GetAddToAlbumPhotoListings,
) : SelectionViewModel(
savedStateHandle = savedStateHandle,
selectLinks = selectLinks,
deselectLinks = deselectLinks,
selectAll = selectAll,
getSelectedDriveLinks = getSelectedDriveLinks,
) {
private val destinationAlbumId: AlbumId? = savedStateHandle.get<String?>(DESTINATION_SHARE_ID)
?.let { destinationShareId ->
savedStateHandle.get<String?>(DESTINATION_ALBUM_ID)?.let { destinationAlbumId ->
AlbumId(ShareId(userId, destinationShareId), destinationAlbumId)
}
}
protected val inPickerMode: Boolean = savedStateHandle[IN_PICKER_MODE] ?: false
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))
} else {
addToAlbum(driveLink)
addSelected(listOf(driveLink.id))
}
} else {
super.onDriveLink(driveLink, nonSelectedBlock)
}
}
suspend fun initializeSelectionInPickerMode() {
if (inPickerMode) {
getAddToAlbumPhotoListings(userId, destinationAlbumId)
.getOrNull(VIEW_MODEL, "Failed to get add to album photo listings")
?.map { photoListing ->
photoListing.linkId
}
?.let { photoIds ->
if (photoIds.isNotEmpty()) {
removeAllSelected()
addSelected(photoIds)
}
}
getPhotoListingCount(userId, destinationAlbumId)
.onEach { count ->
if (count == 0) {
removeAllSelected()
}
}
.launchIn(viewModelScope)
}
}
private fun addToAlbum(driveLink: DriveLink.File) = viewModelScope.launch {
val photoListings = setOf(driveLink.toVolumePhotoListing())
if (destinationAlbumId == null) {
addToAlbumInfo(photoListings)
} else {
addToAlbumInfo(destinationAlbumId, photoListings)
}
}
private fun removeFromAlbum(driveLink: DriveLink.File) = viewModelScope.launch {
val photoListings = setOf(driveLink.toVolumePhotoListing())
if (destinationAlbumId == null) {
removeFromAlbumInfo(photoListings)
} else {
removeFromAlbumInfo(destinationAlbumId, photoListings)
}
}
companion object {
const val IN_PICKER_MODE = "inPickerMode"
const val DESTINATION_SHARE_ID = "destinationShareId"
const val DESTINATION_ALBUM_ID = "destinationAlbumId"
}
}
@@ -51,8 +51,12 @@ import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import me.proton.android.drive.photos.domain.entity.PhotoBackupState
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
import me.proton.android.drive.photos.domain.usecase.EnablePhotosBackup
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.ShowUpsell
import me.proton.android.drive.photos.presentation.R
import me.proton.android.drive.photos.presentation.state.PhotosItem
@@ -144,6 +148,10 @@ class PhotosViewModel @Inject constructor(
selectAll: SelectAll,
selectLinks: SelectLinks,
deselectLinks: DeselectLinks,
addToAlbumInfo: AddToAlbumInfo,
removeFromAlbumInfo: RemoveFromAlbumInfo,
getAddToAlbumPhotoListings: GetAddToAlbumPhotoListings,
getPhotoListingCount: GetPhotoListingCount,
val backupPermissionsViewModel: BackupPermissionsViewModel,
private val photoDriveLinks: PhotoDriveLinks,
private val onFilesDriveLinkError: OnFilesDriveLinkError,
@@ -151,7 +159,17 @@ class PhotosViewModel @Inject constructor(
private val checkMissingFolders: CheckMissingFolders,
private val cancelUserMessage: CancelUserMessage,
shouldUpgradeStorage: ShouldUpgradeStorage,
) : SelectionViewModel(savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks),
) : PhotosPickerAndSelectionViewModel(
savedStateHandle = savedStateHandle,
selectLinks = selectLinks,
deselectLinks = deselectLinks,
selectAll = selectAll,
getSelectedDriveLinks = getSelectedDriveLinks,
addToAlbumInfo = addToAlbumInfo,
removeFromAlbumInfo = removeFromAlbumInfo,
getAddToAlbumPhotoListings = getAddToAlbumPhotoListings,
getPhotoListingCount = getPhotoListingCount,
),
HomeTabViewModel,
NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) {
@@ -203,7 +221,7 @@ class PhotosViewModel @Inject constructor(
result
.onSuccess { driveLink ->
CoreLogger.d(VIEW_MODEL, "drive link onSuccess")
parentFolderId.value = driveLink.id
parentId.value = driveLink.id
return@mapWithPrevious driveLink
}
.onFailure { error ->
@@ -224,10 +242,10 @@ class PhotosViewModel @Inject constructor(
val driveLinksMap: Flow<Map<LinkId, DriveLink>> = photoDriveLinks.getDriveLinksMapFlow(userId)
val driveLinks: Flow<PagingData<PhotosItem>> =
parentFolderId
parentId
.filterNotNull()
.distinctUntilChanged()
.transformLatest { folderId ->
.transformLatest { _ ->
emitAll(
getPagedPhotoListingsList(userId)
.map { pagingData ->
@@ -295,11 +313,11 @@ class PhotosViewModel @Inject constructor(
dayNight = R.drawable.empty_photos_daynight,
)
private val backupState = parentFolderId.flatMapLatest { folderId ->
if (folderId == null) {
private val backupState = parentId.flatMapLatest { parentId ->
if (parentId == null || parentId !is FolderId) {
getDisabledBackupState()
} else {
getBackupState(folderId = folderId)
getBackupState(folderId = parentId)
}
}
@@ -345,11 +363,11 @@ class PhotosViewModel @Inject constructor(
CorePresentation.drawable.ic_proton_cross
},
notificationDotVisible = showHamburgerMenuIcon && notificationDotRequested,
inMultiselect = selected.isNotEmpty(),
inMultiselect = selected.isNotEmpty() || inPickerMode,
listContentState = listContentState,
showEmptyList = backupState.isBackupEnabled || backupState.hasDefaultFolder == false ,
showPhotosStateIndicator = showPhotosStateIndicator,
showPhotosStateBanner = showPhotosStateBanner,
showPhotosStateIndicator = showPhotosStateIndicator && !inPickerMode,
showPhotosStateBanner = showPhotosStateBanner && !inPickerMode,
backupStatusViewState = backupStatusFormatter.toViewState(
backupState = backupState,
count = count.takeIf { configurationProvider.photosSavedCounter },
@@ -407,8 +425,12 @@ class PhotosViewModel @Inject constructor(
}
override val onRefresh = this@PhotosViewModel::onRefresh
override val onErrorAction = this@PhotosViewModel::onErrorAction
override val onSelectedOptions =
{ onSelectedOptions(navigateToPhotosOptions, navigateToMultiplePhotosOptions) }
override val onSelectedOptions = {
onSelectedOptions(
{ linkId: FileId, _ -> navigateToPhotosOptions(linkId) },
{ selectionId: SelectionId, _ -> navigateToMultiplePhotosOptions(selectionId) },
)
}
override val onSelectDriveLink = { driveLink: DriveLink -> onSelectDriveLink(driveLink) }
override val onDeselectDriveLink =
{ driveLink: DriveLink -> onDeselectDriveLink(driveLink) }
@@ -429,7 +451,7 @@ class PhotosViewModel @Inject constructor(
dismissBackgroundRestrictions()
}
override val onResolve: () -> Unit = {
parentFolderId.value?.let { folderId ->
(parentId.value as? FolderId)?.let { folderId ->
navigateToPhotosIssues(folderId)
}
}
@@ -451,11 +473,10 @@ class PhotosViewModel @Inject constructor(
}
private fun onEnable() {
parentFolderId.value?.let { folderId ->
(parentId.value as? FolderId)?.let { folderId ->
backupPermissionsViewModel.toggleBackup(folderId) { state ->
onPhotoBackupState(state)
}
}
}
@@ -475,7 +496,7 @@ class PhotosViewModel @Inject constructor(
}
private suspend fun enablePhotosBackup() {
parentFolderId.value?.let { folderId ->
(parentId.value as? FolderId)?.let { folderId ->
enablePhotosBackup(folderId).onSuccess { state ->
onPhotoBackupState(state)
}.onFailure { error ->
@@ -523,7 +544,7 @@ class PhotosViewModel @Inject constructor(
private fun onRetry() {
viewModelScope.launch {
parentFolderId.value?.let { folderId ->
(parentId.value as? FolderId)?.let { folderId ->
retryBackup(folderId).onFailure { error ->
error.log(BACKUP, "Cannot retry on backup")
broadcastMessages(
@@ -573,7 +594,7 @@ class PhotosViewModel @Inject constructor(
private fun onRefresh() {
viewModelScope.launch {
parentFolderId.value?.let { folderId ->
(parentId.value as? FolderId)?.let { folderId ->
checkMissingFolders(folderId).onFailure { error ->
error.log(VIEW_MODEL, "Failed check missing folders")
}
@@ -0,0 +1,156 @@
/*
* 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 PickerPhotosAndAlbumsViewModel @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@PickerPhotosAndAlbumsViewModel::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
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
)
)
}
}
}
companion object {
const val DESTINATION_SHARE_ID = "destinationShareId"
const val DESTINATION_ALBUM_ID = "destinationAlbumId"
}
}
@@ -99,18 +99,23 @@ import me.proton.core.drive.files.preview.presentation.component.state.ContentSt
import me.proton.core.drive.files.preview.presentation.component.state.PreviewContentState
import me.proton.core.drive.files.preview.presentation.component.state.PreviewViewState
import me.proton.core.drive.files.preview.presentation.component.toComposable
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.Link
import me.proton.core.drive.link.domain.entity.LinkId
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
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.photo.domain.entity.PhotoListing
import me.proton.core.drive.photo.domain.repository.AlbumRepository
import me.proton.core.drive.photo.domain.repository.PhotoRepository
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.domain.usecase.GetShare
import me.proton.core.drive.sorting.domain.entity.Direction
import me.proton.core.drive.sorting.domain.usecase.GetSorting
import me.proton.core.drive.thumbnail.presentation.entity.ThumbnailVO
import me.proton.core.drive.thumbnail.presentation.extension.thumbnailVO
@@ -140,11 +145,20 @@ class PreviewViewModel @Inject constructor(
getDriveLinksCount: GetDriveLinksCount,
getSorting: GetSorting,
getPhotoShare: GetPhotoShare,
getShare: GetShare,
photoRepository: PhotoRepository,
albumRepository: AlbumRepository,
sortDriveLinks: SortDriveLinks,
val savedStateHandle: SavedStateHandle,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val albumId =
savedStateHandle.get<String>(Screen.PagerPreview.ALBUM_ID)?.let { albumIdString ->
AlbumId(
shareId = ShareId(userId, savedStateHandle.require(Screen.PagerPreview.ALBUM_SHARE_ID)),
id = albumIdString,
)
}
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)
@@ -198,6 +212,15 @@ class PreviewViewModel @Inject constructor(
configurationProvider = configurationProvider,
coroutineScope = viewModelScope,
)
PagerType.ALBUM -> AlbumContentProvider(
userId = userId,
albumId = requireNotNull(albumId) {"Missing albumId"} ,
getDecryptedDriveLink = getDecryptedDriveLink,
getShare = getShare,
albumRepository = albumRepository,
configurationProvider = configurationProvider,
coroutineScope = viewModelScope,
)
}
private val contentStatesCache = mutableMapOf<FileId, Flow<ContentState>>()
@@ -261,11 +284,11 @@ class PreviewViewModel @Inject constructor(
fun viewEvent(
navigateBack: () -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToFileOrFolderOptions: (LinkId, AlbumId?) -> Unit,
navigateToProtonDocsInsertImageOptions: () -> Unit,
): PreviewViewEvent = object : PreviewViewEvent {
override val onTopAppBarNavigation = { navigateBack() }
override val onMoreOptions = { navigateToFileOrFolderOptions(fileId) }
override val onMoreOptions = { navigateToFileOrFolderOptions(fileId, albumId) }
override val onSingleTap = { toggleFullscreen() }
override val onRenderFailed = { throwable: Throwable, source: Any -> renderFailed.value = throwable to source }
override val mediaControllerVisibility = { visible: Boolean ->
@@ -559,7 +582,7 @@ class FolderContentProvider(
getDriveLink(fileId)
.transformSuccess { (_, driveLink) ->
emitAll(
getDriveLink(userId, folderId = driveLink.parentId)
getDriveLink(userId, folderId = driveLink.requireFolderId())
)
}
.mapSuccessValueOrNull()
@@ -734,55 +757,113 @@ class PhotoContentProvider(
}
}
}
private fun PhotoListing.placeholderDriveLink(photoShare: Share): DriveLink.File =
DriveLink.File(
link = Link.File(
id = linkId as FileId,
parentId = photoShare.rootFolderId,
name = "",
size = 0.bytes,
lastModified = TimestampS(),
mimeType = "",
isShared = false,
key = "",
passphrase = "",
passphraseSignature = "",
numberOfAccesses = 0,
shareUrlExpirationTime = null,
uploadedBy = "",
isFavorite = false,
attributes = Attributes(0),
permissions = Permissions(),
state = Link.State.ACTIVE,
nameSignatureEmail = null,
hash = nameHash.orEmpty(),
expirationTime = null,
nodeKey = "",
nodePassphrase = "",
nodePassphraseSignature = "",
signatureEmail = "",
creationTime = TimestampS(),
trashedTime = null,
hasThumbnail = false,
activeRevisionId = "",
xAttr = null,
sharingDetails = null,
contentKeyPacket = "",
contentKeyPacketSignature = null,
photoCaptureTime = captureTime,
photoContentHash = contentHash,
mainPhotoLinkId = null,
),
volumeId = photoShare.volumeId,
isMarkedAsOffline = false,
isAnyAncestorMarkedAsOffline = false,
downloadState = null,
trashState = null,
cryptoName = CryptoProperty.Encrypted(""),
cryptoXAttr = CryptoProperty.Encrypted(""),
shareInvitationCount = null,
shareMemberCount = null,
shareUser = null,
)
}
class AlbumContentProvider(
private val getDecryptedDriveLink: GetDecryptedDriveLink,
getShare: GetShare,
albumRepository: AlbumRepository,
userId: UserId,
configurationProvider: ConfigurationProvider,
coroutineScope: CoroutineScope,
albumId: AlbumId,
) : PreviewContentProvider {
private val photoShare: StateFlow<Share?> = getShare(albumId.shareId)
.filterSuccessOrError()
.mapSuccessValueOrNull()
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
private val albumPhotoListings: StateFlow<List<PhotoListing>> = photoShare
.filterNotNull()
.distinctUntilChanged()
.transform { photoShare ->
emitAll(
albumRepository.getAlbumPhotoListingCount(userId, photoShare.volumeId, albumId)
.distinctUntilChanged()
.transformLatest {
emit(
pagedList(
pageSize = configurationProvider.dbPageSize,
) { fromIndex, count ->
albumRepository.getAlbumPhotoListings(
userId = userId,
volumeId = photoShare.volumeId,
albumId = albumId,
fromIndex = fromIndex,
count = count,
sortingBy = PhotoListing.Album.SortBy.CAPTURED,
sortingDirection = Direction.DESCENDING
)
}
)
}
)
}
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
override fun getDriveLinks(fileId: FileId): Flow<List<DriveLink.File>> = combine(
photoShare.filterNotNull(),
getDecryptedDriveLink(fileId).filterSuccessOrError().mapSuccessValueOrNull(),
albumPhotoListings,
) { photoShare, driveLink, albumPhotoListings ->
albumPhotoListings.map { albumPhotoListing ->
if (albumPhotoListing.linkId == driveLink?.id) {
driveLink
} else {
albumPhotoListing.placeholderDriveLink(photoShare)
}
}
}
}
private fun PhotoListing.placeholderDriveLink(
photoShare: Share,
): DriveLink.File = DriveLink.File(
link = Link.File(
id = linkId as FileId,
parentId = photoShare.rootFolderId,
name = "",
size = 0.bytes,
lastModified = TimestampS(),
mimeType = "",
isShared = false,
key = "",
passphrase = "",
passphraseSignature = "",
numberOfAccesses = 0,
shareUrlExpirationTime = null,
uploadedBy = "",
attributes = Attributes(0),
permissions = Permissions(),
state = Link.State.ACTIVE,
nameSignatureEmail = null,
hash = nameHash.orEmpty(),
expirationTime = null,
nodeKey = "",
nodePassphrase = "",
nodePassphraseSignature = "",
signatureEmail = "",
creationTime = TimestampS(),
trashedTime = null,
hasThumbnail = false,
activeRevisionId = "",
xAttr = null,
sharingDetails = null,
contentKeyPacket = "",
contentKeyPacketSignature = null,
photoCaptureTime = captureTime,
photoContentHash = contentHash,
mainPhotoLinkId = null,
),
volumeId = photoShare.volumeId,
isMarkedAsOffline = false,
isAnyAncestorMarkedAsOffline = false,
downloadState = null,
trashState = null,
cryptoName = CryptoProperty.Encrypted(""),
cryptoXAttr = CryptoProperty.Encrypted(""),
shareInvitationCount = null,
shareMemberCount = null,
shareUser = null,
)
@@ -40,13 +40,14 @@ import me.proton.core.drive.drivelink.domain.entity.DriveLink
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.i18n.R
import me.proton.core.drive.link.domain.entity.FolderId
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.entity.ParentId
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
import me.proton.core.presentation.R as CorePresentation
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
@@ -59,17 +60,23 @@ open class SelectionViewModel(
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
protected open val driveLinkFilter: (DriveLink) -> Boolean = { true }
protected open val filterByParentId: Boolean = true
protected val selectionId = MutableStateFlow(
savedStateHandle.get<String?>(KEY_SELECTION_ID)?.let { SelectionId(it) }
)
protected val parentFolderId = MutableStateFlow<FolderId?>(null)
protected val parentId = MutableStateFlow<ParentId?>(null)
protected val selected: StateFlow<Set<LinkId>> = selectionId
.filterNotNull()
.transformLatest { id ->
val parentId = parentFolderId.filterNotNull().first()
val selectedDriveLinks = if (filterByParentId) {
val parentId = parentId.filterNotNull().first()
getSelectedDriveLinks(id, parentId)
} else {
getSelectedDriveLinks(id)
}
emitAll(
getSelectedDriveLinks(id, parentId).map { driveLinks ->
selectedDriveLinks.map { driveLinks ->
driveLinks.map { driveLink -> driveLink.id }.toSet()
}
)
@@ -80,7 +87,7 @@ open class SelectionViewModel(
onAction = {
viewModelScope.launch {
selectAll(
parentId = parentFolderId.filterNotNull().first(),
parentId = parentId.filterNotNull().first(),
selectionId = selectionId.value,
driveLinkFilter = driveLinkFilter,
)
@@ -103,7 +110,7 @@ open class SelectionViewModel(
onAction = { onAction?.invoke() }
)
protected fun onTopAppBarNavigation(nonSelectedBlock: () -> Unit): () -> Unit = {
protected open fun onTopAppBarNavigation(nonSelectedBlock: () -> Unit): () -> Unit = {
Unit.also {
if (selected.value.isNotEmpty()) {
selectionId.value?.let { viewModelScope.launch { deselectLinks(it) } }
@@ -113,7 +120,7 @@ open class SelectionViewModel(
}
}
protected fun onDriveLink(driveLink: DriveLink, nonSelectedBlock: () -> Unit) {
protected open fun onDriveLink(driveLink: DriveLink, nonSelectedBlock: () -> Unit) {
if (selected.value.isNotEmpty()) {
if (selected.value.contains(driveLink.id)) {
removeSelected(listOf(driveLink.id))
@@ -126,13 +133,14 @@ open class SelectionViewModel(
}
protected inline fun <reified T : LinkId> onSelectedOptions(
navigateToFileOrFolderOptions: (linkId: T) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
navigateToFileOrFolderOptions: (linkId: T, albumId: AlbumId?) -> Unit,
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, albumId: AlbumId?) -> Unit,
albumId: AlbumId? = null,
) {
if (selected.value.size == 1) {
navigateToFileOrFolderOptions(selected.value.first() as T)
navigateToFileOrFolderOptions(selected.value.first() as T, albumId)
} else {
selectionId.value?.let { selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) }
selectionId.value?.let { selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, albumId) }
}
}
@@ -142,7 +150,7 @@ open class SelectionViewModel(
protected fun onBack() { removeAllSelected() }
private fun addSelected(linkIds: List<LinkId>) {
protected open fun addSelected(linkIds: List<LinkId>) {
viewModelScope.launch {
selectionId.value?.let { selectionId ->
selectLinks(selectionId, linkIds)
@@ -150,7 +158,7 @@ open class SelectionViewModel(
}
}
private fun removeSelected(linkIds: List<LinkId>) {
protected open fun removeSelected(linkIds: List<LinkId>) {
viewModelScope.launch {
selectionId.value?.let { selectionId ->
deselectLinks(selectionId, linkIds)
@@ -158,7 +166,7 @@ open class SelectionViewModel(
}
}
private fun removeAllSelected() {
protected fun removeAllSelected() {
if (selected.value.isNotEmpty()) {
viewModelScope.launch {
selectionId.value?.let { selectionId -> deselectLinks(selectionId) }
@@ -33,28 +33,37 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import me.proton.android.drive.ui.effect.TrashEffect
import me.proton.android.drive.ui.navigation.Screen
import me.proton.android.drive.ui.screen.EmptyTrashIconState
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
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.state.ListContentAppendingState
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.viewmodel.onLoadState
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.trash.domain.usecase.GetPagedTrashedDriveLinks
import me.proton.core.drive.files.presentation.event.FilesViewEvent
import me.proton.core.drive.files.presentation.state.FilesViewState
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.effect.ListEffect
import me.proton.core.drive.base.presentation.viewmodel.onLoadState
import me.proton.core.drive.files.presentation.state.VolumeEntry
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.sorting.domain.entity.Sorting
import me.proton.core.drive.sorting.domain.usecase.GetSorting
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.drive.android.settings.domain.entity.LayoutType
import me.proton.drive.android.settings.domain.usecase.GetLayoutType
import me.proton.drive.android.settings.domain.usecase.ToggleLayoutType
@@ -75,8 +84,11 @@ class TrashViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val toggleLayoutType: ToggleLayoutType,
private val configurationProvider: ConfigurationProvider,
getVolumes: GetVolumes,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val volumeIdFlow = MutableStateFlow<VolumeId?>(null)
private val listContentState = MutableStateFlow<ListContentState>(ListContentState.Loading)
private val listContentAppendingState = MutableStateFlow<ListContentAppendingState>(ListContentAppendingState.Idle)
private val _listEffect = MutableSharedFlow<ListEffect>()
@@ -96,24 +108,49 @@ class TrashViewModel @Inject constructor(
listContentState,
listContentAppendingState,
layoutType,
) { sorting, contentState, appendingState, layoutType ->
getVolumes(userId).mapSuccessValueOrNull(),
) { sorting, contentState, appendingState, layoutType, volumes ->
val listContentState = when (contentState) {
is ListContentState.Empty -> contentState.copy(
imageResId = emptyStateImageResId,
)
else -> contentState
}
if (volumeIdFlow.value == null) {
volumeIdFlow.emit(
volumes?.firstOrNull { volume -> volume.type == Volume.Type.REGULAR }?.id
?: volumes?.firstOrNull { volume -> volume.type == Volume.Type.PHOTO }?.id
)
}
initialViewState.copy(
sorting = sorting,
listContentState = listContentState,
listContentAppendingState = appendingState,
isGrid = layoutType == LayoutType.GRID,
volumesEntries = volumes?.takeIf { it.size > 1 }
.orEmpty()
.sortedBy { volume -> volume.type }
.map { volume ->
VolumeEntry(
id = volume.id,
title = when (volume.type) {
Volume.Type.UNKNOWN -> I18N.string.common_unknown
Volume.Type.REGULAR -> I18N.string.title_files
Volume.Type.PHOTO -> I18N.string.photos_title
},
isSelected = volume.id == volumeIdFlow.value
)
},
)
}.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val driveLinks: Flow<PagingData<DriveLink>> = getTrashedDriveLinks(userId).cachedIn(viewModelScope)
val driveLinks: Flow<PagingData<DriveLink>> = volumeIdFlow.filterNotNull().transformLatest { volumeId ->
emitAll(getTrashedDriveLinks(userId, volumeId))
}.cachedIn(viewModelScope)
val listEffect: Flow<ListEffect> = _listEffect.asSharedFlow()
val trashEffect: Flow<TrashEffect> = _trashEffect.asSharedFlow()
val emptyTrashState = combine(listContentState, getEmptyTrashState(userId)) { content, state ->
val emptyTrashState = combine(listContentState, volumeIdFlow.filterNotNull().transformLatest { volumeId ->
emitAll(getEmptyTrashState(userId, volumeId))
}) { content, state ->
if (content !is ListContentState.Content) {
EmptyTrashIconState.HIDDEN
} else when (state) {
@@ -158,6 +195,10 @@ class TrashViewModel @Inject constructor(
override val onAppendErrorAction = { retry() }
override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) }
override val onToggleLayout = this@TrashViewModel::onToggleLayout
override val onTab = { volumeEntry: VolumeEntry ->
volumeIdFlow.tryEmit(volumeEntry.id)
Unit
}
}
private fun retry() {
@@ -174,7 +215,9 @@ class TrashViewModel @Inject constructor(
fun onMoreOptionsClicked() {
viewModelScope.launch {
_trashEffect.emit(TrashEffect.MoreOptions)
volumeIdFlow.firstOrNull()?.let { volumeId ->
_trashEffect.emit(TrashEffect.MoreOptions(volumeId))
}
}
}
@@ -139,7 +139,7 @@ class UploadToViewModel @Inject constructor(
}
private fun upload(navigateToStorageFull: () -> Unit, exitApp: () -> Unit) = viewModelScope.launch {
parentLink.value?.let { folder ->
(parentLink.value as? DriveLink.Folder)?.let { folder ->
val copiedUris = mutableListOf<String>()
coRunCatching {
val job = launch {
@@ -0,0 +1,29 @@
/*
* 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 androidx.compose.runtime.Immutable
@Immutable
data class PickerPhotosAndAlbumsViewState(
val addToAlbumButtonTitle: String,
val isAddToAlbumButtonEnabled: Boolean = true,
val isAddingInProgress: Boolean = false,
val isResetButtonEnabled: Boolean = true,
)
@@ -26,7 +26,7 @@ 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.ui.options.OptionsFilter
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
import me.proton.android.drive.usecase.NotifyActivityNotFound
import me.proton.android.drive.usecase.OpenProtonDocumentInBrowser
import me.proton.core.crypto.common.pgp.VerificationStatus
@@ -43,19 +43,23 @@ 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.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
import me.proton.core.drive.files.presentation.entry.ManageAccessEntry
import me.proton.core.drive.files.presentation.entry.MoveFileEntry
import me.proton.core.drive.files.presentation.entry.OpenInBrowserProtonDocsEntry
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.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.ToggleOfflineEntry
import me.proton.core.drive.files.presentation.entry.ToggleTrashEntry
@@ -70,7 +74,6 @@ 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 org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@@ -90,15 +93,20 @@ class FileOrFolderOptionsViewModelTest {
private val notifyActivityNotFound = mockk<NotifyActivityNotFound>()
private val getFeatureFlagFlow = mockk<GetFeatureFlagFlow>()
private val openProtonDocumentInBrowser = mockk<OpenProtonDocumentInBrowser>()
private val updateAlbumCover = mockk<UpdateAlbumCover>()
private val removePhotosFromAlbum = mockk<RemovePhotosFromAlbum>()
@Before
fun before() {
coEvery { savedStateHandle.get<String>(any()) } returns "value"
coEvery { savedStateHandle.get<OptionsFilter>("optionsFilter") } returns OptionsFilter.FILES
coEvery { getFeatureFlagFlow.invoke(any())} answers {
coEvery { getFeatureFlagFlow.invoke(any(), any(), any())} answers {
val id: FeatureFlagId = arg(0)
flowOf(FeatureFlag(id, State.NOT_FOUND))
}
coEvery { getFeatureFlagFlow.refreshAfterDuration} answers {
{ false }
}
coEvery { configurationProvider.albumsFeatureFlag} returns true
}
@Test
@@ -275,7 +283,7 @@ class FileOrFolderOptionsViewModelTest {
}
@Test
fun `file options on photo share`() = runTest {
fun `file options on photo share without album feature flag`() = runTest {
// Given
coEvery { getDriveLink.invoke(any<LinkId>(), any()) } returns flowOf(photoDriveLink.asSuccess)
@@ -283,15 +291,46 @@ class FileOrFolderOptionsViewModelTest {
val entries = fileOptionEntries()
// Then
Assert.assertTrue(entries.any { it is ToggleOfflineEntry })
Assert.assertTrue(entries.any { it is ShareViaInvitationsEntry })
Assert.assertTrue(entries.any { it is ManageAccessEntry })
Assert.assertTrue(entries.any { it is SendFileEntry })
Assert.assertTrue(entries.any { it is DownloadFileEntry })
Assert.assertFalse(entries.any { it is MoveFileEntry })
Assert.assertFalse(entries.any { it is RenameFileEntry })
Assert.assertTrue(entries.any { it is FileInfoEntry })
Assert.assertTrue(entries.any { it is ToggleTrashEntry })
assertEquals(
listOf(
ToggleOfflineEntry::class,
ShareViaInvitationsEntry::class,
ManageAccessEntry::class,
SendFileEntry::class,
DownloadFileEntry::class,
FileInfoEntry::class,
ToggleTrashEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
@Test
fun `file options on photo share with album feature flag`() = 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))
// When
val entries = fileOptionEntries()
// Then
assertEquals(
listOf(
SetAsAlbumCoverEntry::class,
RemoveFromAlbumFileEntry::class,
ToggleOfflineEntry::class,
ShareViaInvitationsEntry::class,
ManageAccessEntry::class,
SendFileEntry::class,
DownloadFileEntry::class,
FileInfoEntry::class,
ToggleTrashEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
@Test
@@ -345,10 +384,13 @@ class FileOrFolderOptionsViewModelTest {
exportTo = exportTo,
notifyActivityNotFound = notifyActivityNotFound,
getFeatureFlagFlow = getFeatureFlagFlow,
albumsFeatureFlag = AlbumsFeatureFlag(getFeatureFlagFlow, configurationProvider),
leaveShare = leaveShare,
configurationProvider = configurationProvider,
broadcastMessages = broadcastMessages,
openProtonDocumentInBrowser = openProtonDocumentInBrowser,
updateAlbumCover = updateAlbumCover,
removePhotosFromAlbum = removePhotosFromAlbum,
)
private val fileLink = Link.File(
@@ -368,7 +410,6 @@ class FileOrFolderOptionsViewModelTest {
passphraseSignature = "signature",
contentKeyPacket = "contentKeyPacket",
contentKeyPacketSignature = null,
isFavorite = false,
attributes = Attributes(0),
permissions = Permissions(0),
state = Link.State.ACTIVE,
@@ -0,0 +1,248 @@
/*
* Copyright (c) 2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewmodel
import androidx.lifecycle.SavedStateHandle
import androidx.test.core.app.ApplicationProvider
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
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.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.provider.ConfigurationProvider
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.documentsprovider.domain.usecase.ExportToDownload
import me.proton.core.drive.drivelink.domain.entity.DriveLink
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
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.TrashEntry
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.SharingDetails
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.linkdownload.domain.entity.DownloadState
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 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 MultipleFileOrFolderOptionsViewModelTest {
private val savedStateHandle = mockk<SavedStateHandle>()
private val getSelectedDriveLinks = mockk<GetSelectedDriveLinks>()
private val sendToTrash = mockk<SendToTrash>()
private val exportToDownload = mockk<ExportToDownload>()
private val deselectLinks = mockk<DeselectLinks>()
private val addToAlbumInfo = mockk<AddToAlbumInfo>()
private val getFeatureFlagFlow = mockk<GetFeatureFlagFlow>()
private val configurationProvider = mockk<ConfigurationProvider>()
private val removePhotosFromAlbum = mockk<RemovePhotosFromAlbum>()
private val broadcastMessages = mockk<BroadcastMessages>()
@Before
fun before() {
coEvery { savedStateHandle.get<String>(any()) } returns "value"
coEvery { getSelectedDriveLinks.invoke(any()) } returns flowOf()
coEvery { getFeatureFlagFlow.invoke(any(), any(), any())} answers {
val id: FeatureFlagId = arg(0)
flowOf(FeatureFlag(id, State.NOT_FOUND))
}
coEvery { getFeatureFlagFlow.refreshAfterDuration} answers {
{ false }
}
coEvery { configurationProvider.albumsFeatureFlag} returns true
}
@Test
fun `files options`() = runTest {
// Given
val files = listOf(fileDriveLink)
// When
val entries = files.fileOptionEntries()
// Then
assertEquals(
listOf(
DownloadEntry::class,
MoveEntry::class,
TrashEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
@Test
fun `photos options without album feature flag`() = runTest {
// Given
val files = listOf(photoDriveLink)
// When
val entries = files.fileOptionEntries()
// Then
assertEquals(
listOf(
DownloadEntry::class,
TrashEntry::class,
),
entries.map { it.javaClass.kotlin }
)
}
@Test
fun `photos options with album feature flag `() = runTest {
// Given
val featureFlagId = driveAlbums(UserId("value"))
coEvery { getFeatureFlagFlow(featureFlagId, any(), any()) } returns
flowOf( FeatureFlag(featureFlagId, State.ENABLED))
val files = listOf(photoDriveLink)
// When
val entries = files.fileOptionEntries()
// Then
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 = { },
dismiss = {},
).filterNotNull().first()
private fun fileOrFolderOptionsViewModel() = MultipleFileOrFolderOptionsViewModel(
appContext = ApplicationProvider.getApplicationContext(),
savedStateHandle = savedStateHandle,
getSelectedDriveLinks = getSelectedDriveLinks,
sendToTrash = sendToTrash,
exportToDownload = exportToDownload,
deselectLinks = deselectLinks,
addToAlbumInfo = addToAlbumInfo,
removePhotosFromAlbum = removePhotosFromAlbum,
getFeatureFlagFlow = getFeatureFlagFlow,
albumsFeatureFlag = AlbumsFeatureFlag(getFeatureFlagFlow, configurationProvider),
broadcastMessages = broadcastMessages,
configurationProvider = configurationProvider,
)
private val fileLink = Link.File(
id = FileId(ShareId(UserId("USER_ID"), "SHARE_ID"), "ID"),
parentId = FolderId(ShareId(UserId("USER_ID"), "SHARE_ID"), "PARENT_ID"),
activeRevisionId = "revision",
size = Bytes(123),
lastModified = TimestampS(System.currentTimeMillis() / 1000),
mimeType = "video/mp4",
numberOfAccesses = 2,
isShared = true,
uploadedBy = "m4@proton.black",
hasThumbnail = false,
name = "Link name",
key = "key",
passphrase = "passphrase",
passphraseSignature = "signature",
contentKeyPacket = "contentKeyPacket",
contentKeyPacketSignature = null,
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"), "")
),
photoCaptureTime = null,
photoContentHash = null,
mainPhotoLinkId = null,
)
private val fileDriveLink = DriveLink.File(
link = fileLink,
volumeId = VolumeId("VOLUME_ID"),
isMarkedAsOffline = true,
isAnyAncestorMarkedAsOffline = false,
downloadState = DownloadState.Downloaded(),
trashState = null,
cryptoName = CryptoProperty.Decrypted("Link name", 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 photoDriveLink = fileDriveLink.copy(
link = fileLink.copy(
photoCaptureTime = TimestampS(0),
photoContentHash = "",
mainPhotoLinkId = "MAIN_ID"
)
)
}
@@ -18,17 +18,32 @@
package me.proton.android.drive.ui.robot
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.i18n.R as I18N
import me.proton.core.drive.files.presentation.extension.ItemType
import me.proton.core.drive.files.presentation.extension.LayoutType
import me.proton.test.fusion.Fusion.node
import me.proton.test.fusion.FusionConfig.targetContext
import me.proton.core.drive.i18n.R as I18N
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)
fun clickOnMoreButton() = moreButton.clickTo(AlbumOptionsRobot)
fun clickOnAdd() = addButton.clickTo(PickerPhotosAndAlbumsRobot)
fun clickOnPhoto(name: String) =
photoWithName(name).clickTo(PreviewRobot)
private fun photoWithName(name: String) = linkWithName(name)
.withItemType(ItemType.File)
.withLayoutType(LayoutType.Grid)
fun assertAlbumNameIsDisplayed(name: String) = node.withText(name)
.await { assertIsDisplayed() }
@@ -39,6 +54,11 @@ object AlbumRobot : LinksRobot, NavigationBarRobot {
).format(count)
).await { assertIsDisplayed() }
fun assertCoverAlbum(name: String) = node.withLinkName(name)
.withItemType(ItemType.File)
.withLayoutType(LayoutType.Cover)
.await { assertIsDisplayed() }
override fun robotDisplayed() {
albumScreen.await { assertIsDisplayed() }
}
@@ -18,6 +18,9 @@
package me.proton.android.drive.ui.robot
import me.proton.android.drive.photos.presentation.extension.albumDetails
import me.proton.android.drive.ui.test.AbstractBaseTest.Companion.targetContext
import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.test.fusion.Fusion.node
import me.proton.core.drive.i18n.R as I18N
@@ -35,9 +38,8 @@ object AlbumsTabRobot :
private val filterSharedByMe get() = node.withText(I18N.string.albums_filter_shared_by_me)
private val filterSharedWithMe get() = node.withText(I18N.string.albums_filter_shared_with_me)
private val emptyTitle get() = node.withText(I18N.string.photos_empty_title)
private val emptyDescription get() = node.withText(I18N.string.photos_empty_description)
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 plusButton get() = node.withContentDescription(I18N.string.content_description_albums_new)
@@ -64,9 +66,23 @@ object AlbumsTabRobot :
node.withText(name).await { assertIsDisplayed() }
}
fun assertAlbumIsDisplayed(name: String, size:Long) {
fun assertAlbumIsDisplayed(
name: String,
photoCount: Long,
isShared: Boolean = false,
creationTime: TimestampS? = null,
) {
node.withText(name)
.hasSibling(node.withText(size.toString()))
.hasSibling(
node.withText(
albumDetails(
appContext = targetContext,
photoCount = photoCount,
isShared = isShared,
creationTime = creationTime,
)
)
)
.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 addButton get() = node.withText(I18N.string.common_add_action)
fun typeName(text: String) = apply { newAlbumHint.typeText(text) }
@@ -41,6 +42,8 @@ object CreateAlbumTabRobot : Robot {
fun <T : Robot> clickOnDone(goesTo: T) = doneButton.clickTo(goesTo)
fun clickOnAdd() = addButton.clickTo(PickerPhotosAndAlbumsRobot)
override fun robotDisplayed() {
newAlbumHint.assertIsDisplayed()
}
@@ -33,6 +33,7 @@ object FileFolderOptionsRobot : Robot {
private val removeAvailableOfflineButton get() = node
.withText(I18N.string.common_remove_from_offline_available_action)
private val manageLinkButton get() = node.withText(I18N.string.common_manage_link_action)
private val setAsAlbumCoverButton get() = node.withText(I18N.string.common_set_as_album_cover_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 removeMeButton get() = node.withText(I18N.string.files_remove_me_action)
@@ -42,6 +43,7 @@ object FileFolderOptionsRobot : Robot {
private val stopSharingButton get() = node.withText(I18N.string.common_stop_sharing_action)
private val deletePermanentlyButton get() = node.withText(I18N.string.common_delete_permanently_action)
private val deleteConfirm get() = node.withText(I18N.string.title_files_confirm_deletion)
private val removeFromAlbum get() = node.withText(I18N.string.common_remove_from_album_action)
fun clickMove() = MoveToFolderRobot.apply {
moveButton.scrollTo().click()
@@ -52,6 +54,12 @@ object FileFolderOptionsRobot : Robot {
fun clickManageLink() = ShareRobot.apply {
manageLinkButton.scrollTo().click()
}
fun clickSetAsAlbumCover() = ShareRobot.apply {
setAsAlbumCoverButton.scrollTo().click()
}
fun clickRemoveFromAlbum() = AlbumRobot.apply {
removeFromAlbum.scrollTo().click()
}
fun clickShare() = ShareUserRobot.apply {
shareButton.scrollTo().click()
}
@@ -103,6 +103,9 @@ interface LinksRobot : PullToRefreshRobot, Robot {
fun clickOnAlbum(name: String, layoutType: LayoutType = LayoutType.Grid) =
clickOnItem(name, layoutType, ItemType.Album, AlbumRobot)
fun <T : Robot> clickOnAlbum(name: String, goesTo: T, layoutType: LayoutType = LayoutType.Grid) =
clickOnItem(name, layoutType, ItemType.Album, goesTo)
fun <T : Robot> clickOnUndo(after: Duration = 0.seconds, goesTo: T): T = runBlocking {
delay(after)
node.withText(I18N.string.common_undo_action).clickTo(goesTo)
@@ -19,18 +19,22 @@
package me.proton.android.drive.ui.robot
import me.proton.android.drive.ui.dialog.MultipleFileFolderOptionsDialogTestTag
import me.proton.core.drive.i18n.R
import me.proton.test.fusion.Fusion.node
import me.proton.core.drive.i18n.R as I18N
object MultipleFileFolderOptionsRobot : Robot {
private val multipleFileFolderOptionsScreen get() = node.withTag(
MultipleFileFolderOptionsDialogTestTag.fileOrFolderOptions
)
private val createAlbumButton get() = node.withText(R.string.common_create_album_action)
private val createAlbumButton get() = node.withText(I18N.string.common_create_album_action)
private val removeFromAlbum get() = node.withText(I18N.string.common_remove_from_album_action)
fun clickOnCreateAlbum() = CreateAlbumTabRobot.apply {
createAlbumButton.scrollTo().click()
}
fun clickRemoveFromAlbum() = AlbumRobot.apply {
removeFromAlbum.scrollTo().click()
}
override fun robotDisplayed() {
multipleFileFolderOptionsScreen.await { assertIsDisplayed() }
@@ -48,6 +48,8 @@ object PhotosTabRobot :
HomeRobot,
LinksRobot,
NavigationBarRobot {
private val photosContent get() = node.withTag(PhotosTestTag.content)
private val enableBackupButton
get() = allNodes.withText(I18N.string.photos_permissions_action).onFirst()
private val enableErrorBackupButton
@@ -92,6 +94,10 @@ object PhotosTabRobot :
fun dismissQuotaBanner() = apply { closeQuotaBannerButton.click() }
fun scrollToPhoto(imageName: ImageName): PhotosTabRobot = apply {
photosContent.scrollTo(photoWithName(imageName.fileName))
}
fun longClickOnPhoto(fileName: String) =
photoWithName(fileName).longClickTo(this)
@@ -0,0 +1,40 @@
/*
* 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.extension.withItemType
import me.proton.android.drive.ui.extension.withLayoutType
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.node
object PickerAlbumRobot : PickerPhotosRobot, LinksRobot, NavigationBarRobot {
private val albumScreen get() = node.withTag(AlbumScreenTestTag.screen)
fun clickOnPhoto(name: String) = photoWithName(name).clickTo(PickerAlbumRobot)
private fun photoWithName(name: String) = linkWithName(name)
.withItemType(ItemType.File)
.withLayoutType(LayoutType.Grid)
override fun robotDisplayed() {
albumScreen.await { 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.extension.withItemType
import me.proton.android.drive.ui.extension.withLayoutType
import me.proton.android.drive.ui.screen.PickerPhotosAndAlbumsScreenTestTag
import me.proton.core.drive.files.presentation.extension.ItemType
import me.proton.core.drive.files.presentation.extension.LayoutType
import me.proton.test.fusion.Fusion.node
import me.proton.core.drive.i18n.R as I18N
object PickerPhotosAndAlbumsRobot : PickerPhotosRobot, LinksRobot, NavigationBarRobot {
private val screen get() = node.withTag(PickerPhotosAndAlbumsScreenTestTag.screen)
private val photosTab get() = node.withText(I18N.string.photos_title)
private val albumsTab get() = node.withText(I18N.string.albums_title)
fun clickOnPhotosTab() = photosTab.clickTo(PickerPhotosAndAlbumsRobot)
fun clickOnAlbumsTab() = albumsTab.clickTo(PickerPhotosAndAlbumsRobot)
fun clickOnPhoto(name: String) = photoWithName(name).clickTo(PickerPhotosAndAlbumsRobot)
private fun photoWithName(name: String) = linkWithName(name)
.withItemType(ItemType.File)
.withLayoutType(LayoutType.Grid)
override fun robotDisplayed() {
screen.await { assertIsDisplayed() }
}
}
@@ -0,0 +1,39 @@
/*
* 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.screen.PickerPhotosAndAlbumsScreenTestTag
import me.proton.core.drive.i18n.R
import me.proton.test.fusion.Fusion.node
interface PickerPhotosRobot : Robot {
private val resetButton get() = node.withTag(PickerPhotosAndAlbumsScreenTestTag.resetButton)
private val addToAlbumButton get() = node.withTag(PickerPhotosAndAlbumsScreenTestTag.addToAlbumButton)
fun <T : Robot> clickOnReset(goesTo: T) = resetButton.clickTo(goesTo)
fun <T : Robot> clickOnAddToAlbum(goesTo: T) = addToAlbumButton.clickTo(goesTo)
fun assertTotalPhotosToAddToAlbum(count: Int) =
if (count == 0) {
node.withText(R.string.albums_add_zero_to_album_button)
} else {
node.withPluralTextResource(R.plurals.albums_add_non_zero_to_album_button, count)
}.await { assertIsDisplayed() }
}
@@ -25,13 +25,17 @@ import me.proton.android.drive.ui.robot.AlbumRobot
import me.proton.android.drive.ui.robot.AlbumsTabRobot
import me.proton.android.drive.ui.robot.ConfirmDeleteAlbumRobot
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.robot.PickerAlbumRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_ALBUMS
import me.proton.core.drive.files.presentation.extension.ItemType
import me.proton.core.drive.files.presentation.extension.LayoutType
import me.proton.core.test.rule.annotation.PrepareUser
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import me.proton.core.drive.i18n.R as I18N
@HiltAndroidTest
class AlbumFlowTest : BaseTest() {
@@ -122,4 +126,83 @@ class AlbumFlowTest : BaseTest() {
assertAlbumIsNotDisplayed(albumName)
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
fun setAsAlbumCover() {
val albumName = "album-for-photos"
val image = "activeTaggedFileInAlbum-3.jpg"
AlbumsTabRobot
.clickOnAlbum(albumName)
.clickOnPhoto(image)
.clickOnContextualButton()
.clickSetAsAlbumCover()
.verify {
nodeWithTextDisplayed(I18N.string.albums_set_album_as_cover_success)
}
.clickBack(AlbumRobot)
.verify {
assertCoverAlbum(image)
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
fun addPhotosIntoAlbum() {
val albumName = "album-for-photos-uploaded-by-other-user"
val pickedAlbumName = "album-for-photos-in-stream"
val photoInStream = "activeTaggedFileInStream-2.jpg"
val photoInAlbum = "activeFileInStreamAndAlbum.jpg"
AlbumsTabRobot
.clickOnAlbum(albumName)
.clickOnAdd()
.clickOnPhoto(photoInStream)
.verify {
assertTotalPhotosToAddToAlbum(1)
}
.clickOnAlbumsTab()
.clickOnAlbum(pickedAlbumName, PickerAlbumRobot)
.clickOnPhoto(photoInAlbum)
.verify {
assertTotalPhotosToAddToAlbum(2)
}
.clickOnAddToAlbum(AlbumRobot)
.verify {
assertAlbumNameIsDisplayed(albumName)
assertItemsInAlbum(3)
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
fun removePhotosFromAlbum() {
val albumName = "album-for-photos"
val photo1 = "activeFileInAlbum.jpg"
val photo2 = "activeTaggedFileInAlbum-3.jpg"
val photo3 = "activeTaggedFileInAlbum-2.jpg"
AlbumsTabRobot
.clickOnAlbum(albumName)
.longClickOnItem(photo1)
.clickOptions()
.clickRemoveFromAlbum()
.verify {
assertItemsInAlbum(4)
}
.longClickOnItem(photo2)
.clickOnItem(photo3, LayoutType.Grid, ItemType.File, AlbumRobot)
.clickMultipleOptions()
.clickRemoveFromAlbum()
.verify {
assertItemsInAlbum(2)
}
}
}
@@ -22,7 +22,10 @@ import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.FeatureFlag
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.robot.AlbumRobot
import me.proton.android.drive.ui.robot.CreateAlbumTabRobot
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.robot.PickerAlbumRobot
import me.proton.android.drive.ui.robot.PickerPhotosAndAlbumsRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.android.drive.utils.getRandomString
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
@@ -100,4 +103,45 @@ class CreatingAlbumFlowTest : BaseTest() {
assertAlbumIsDisplayed(albumName, 1)
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
fun inEmptyAlbumAddPhotosThenCreate() {
val albumName = "new-album"
val album = "album-for-photos-in-stream"
val photo1 = "activeTaggedFileInStream-2.jpg"
val photo2 = "activeFileInStreamAndAlbum.jpg"
PhotosTabRobot
.clickOnAlbumsTab()
.clickPlusButton()
.typeName(albumName)
.clickOnAdd()
.verify {
assertTotalPhotosToAddToAlbum(0)
}
.clickOnPhoto(photo1)
.clickOnAlbumsTab()
.clickOnAlbum(album, PickerAlbumRobot)
.clickOnPhoto(photo2)
.verify {
assertTotalPhotosToAddToAlbum(2)
}
.clickOnReset(PickerAlbumRobot)
.verify {
assertTotalPhotosToAddToAlbum(0)
}
.clickOnPhoto(photo2)
.clickBack(PickerPhotosAndAlbumsRobot)
.clickOnPhotosTab()
.clickOnPhoto(photo1)
.clickOnAddToAlbum(CreateAlbumTabRobot)
.clickOnDone(AlbumRobot)
.verify {
assertAlbumNameIsDisplayed(albumName)
assertItemsInAlbum(2)
}
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2023-2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.test.flow.preview
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.FeatureFlag
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.data.ImageName
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.android.drive.ui.annotation.SmokeTest
import me.proton.android.drive.ui.robot.AlbumRobot
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_ALBUMS
import me.proton.core.test.rule.annotation.PrepareUser
import me.proton.test.fusion.FusionConfig
import me.proton.test.fusion.ui.common.enums.SwipeDirection
import org.junit.Before
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@HiltAndroidTest
class PreviewAlbumFlowTest : BaseTest() {
@Before
fun setUp() {
PhotosTabRobot.waitUntilLoaded()
FusionConfig.Compose.waitTimeout.set(30.seconds)
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
fun previewAlbumTest() {
val firstImage = "activeTaggedFileInAlbum-3.jpg"
val secondImage = "activeTaggedFileInAlbum-2.jpg"
val thirdImage = "activeTaggedFileInAlbum-1.jpg"
PhotosTabRobot
.clickOnAlbumsTab()
.clickOnAlbum("album-for-photos")
.clickOnPhoto(firstImage)
.verify {
assertPreviewIsDisplayed(firstImage)
}
.swipePage(SwipeDirection.Left)
.verify {
assertPreviewIsDisplayed(secondImage)
}
.swipePage(SwipeDirection.Left)
.verify {
assertPreviewIsDisplayed(thirdImage)
}
.clickBack(AlbumRobot)
.clickOnPhoto(thirdImage)
.verify {
assertPreviewIsDisplayed(thirdImage)
}
.swipePage(SwipeDirection.Right)
.verify {
assertPreviewIsDisplayed(secondImage)
}
.swipePage(SwipeDirection.Right)
.verify {
assertPreviewIsDisplayed(firstImage)
}
}
}
@@ -46,9 +46,9 @@ class PreviewPhotosFlowTest : BaseTest() {
@SmokeTest
fun previewPhotoTest() {
val firstImage = ImageName.Main
val secondImage = ImageName.Now
val thirdImage = ImageName.Yesterday
val firstImage = ImageName.Yesterday
val secondImage = ImageName.LastWeek
val thirdImage = ImageName.LastMonth
PhotosTabRobot
.clickOnPhoto(firstImage)
@@ -64,6 +64,7 @@ class PreviewPhotosFlowTest : BaseTest() {
assertPreviewIsDisplayed(thirdImage.fileName)
}
.clickBack(PhotosTabRobot)
.scrollToPhoto(thirdImage)
.clickOnPhoto(thirdImage)
.verify {
assertPreviewIsDisplayed(thirdImage.fileName)
@@ -54,6 +54,7 @@ class DeleteSharedLinkManageAccessFlowTest : BaseTest() {
robotDisplayed()
}
.clickBack(FilesTabRobot)
.scrollToItemWithName(file)
.verify { itemIsDisplayed(file, isSharedByLink = false) }
}
}
@@ -19,12 +19,15 @@
package me.proton.android.drive.ui.test.flow.trash
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.FeatureFlag
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.data.ImageName
import me.proton.android.drive.ui.robot.FilesTabRobot
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.robot.TrashRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_ALBUMS
import me.proton.core.test.rule.annotation.PrepareUser
import org.junit.Test
@@ -108,6 +111,32 @@ class DeletePermanentlyTests : BaseTest() {
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
fun emptyTrashPhotoDeleteAllItems() {
val itemsInTrash = arrayOf(
"trashedFileInAlbumByOtherUser.jpg",
"trashedFileInAlbum.jpg",
"trashedFileInStreamAndInAlbum.jpg",
)
PhotosTabRobot
.openSidebarBySwipe()
.clickTrash()
.verify {
robotDisplayed()
}
.openMoreOptions()
.clickEmptyTrash()
.confirmEmptyTrash()
.verify {
itemIsNotDisplayed(*itemsInTrash)
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2, isPhotos = true)
+1 -1
View File
@@ -22,7 +22,7 @@ object Config {
const val minSdk = 26
const val targetSdk = 34
const val testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
const val versionName = "2.18.0"
const val versionName = "2.19.0"
const val archivesBaseName = "ProtonDrive-$versionName"
val supportedResourceConfigurations = listOf(
"b+es+419",
@@ -58,12 +58,12 @@ class BackupPermissionsManagerImpl @Inject constructor(
private val _permissions = MutableStateFlow(checkForBackupPermissions())
override val backupPermissions: Flow<BackupPermissions> = _permissions
override fun getBackupPermissions(refresh: Boolean): BackupPermissions =
override fun getBackupPermissions(refresh: Boolean): BackupPermissions {
if (refresh) {
checkForBackupPermissions()
} else {
_permissions.value
_permissions.value = checkForBackupPermissions()
}
return _permissions.value
}
override fun onPermissionChanged(permissions: BackupPermissions) {
_permissions.value = permissions
@@ -150,6 +150,8 @@ object Dto {
const val PENDING_HASHES = "PendingHashes"
const val PERMISSIONS = "Permissions"
const val PHOTO = "Photo"
const val PHOTO_DATA = "PhotoData"
const val PHOTO_PROPERTIES = "PhotoProperties"
const val PHOTOS = "Photos"
const val PHOTO_COUNT = "PhotoCount"
const val PUBLIC_URL = "PublicUrl"
@@ -21,6 +21,7 @@ object Column {
const val ADDED_TIME = "added_time"
const val ADDRESS_ID = "address_id"
const val ALBUM_ID = "album_id"
const val ALBUM_SHARE_ID = "album_share_id"
const val ANCHOR_ID = "anchor_id"
const val ATTEMPTS = "attempts"
const val ATTRIBUTES = "attributes"
@@ -141,6 +142,7 @@ object Column {
const val SYNC_STATE = "sync_state"
const val SYNC_TIME = "sync_time"
const val TAG = "tag"
const val TAGS_DATA = "tags_data"
const val THEME_STYLE = "theme_style"
const val THUMBNAIL = "thumbnail"
const val THUMBNAIL_ID_DEFAULT = "thumbnail_id_default"
@@ -35,6 +35,7 @@ object LogTag {
const val SHARING = "$DEFAULT.sharing"
const val PAGING = "$DEFAULT.paging"
const val BACKUP = "$DEFAULT.backup"
const val ALBUM = "$DEFAULT.album"
const val PHOTO = "$DEFAULT.photo"
const val TELEMETRY = "$DEFAULT.telemetry"
const val UPLOAD = "$DEFAULT.upload"
@@ -111,7 +111,7 @@ interface ConfigurationProvider {
val cacheInternalStorageLimit: Bytes get() = 512.MiB
val albumsFeatureFlag: Boolean get() = false
val minimumAlbumListingFetchInterval: Duration get() = 5.days
val addToAlbumMaxApiDataSize: Int get() = 10
val addToRemoveFromAlbumMaxApiDataSize: Int get() = 10
data class Thumbnail(
val maxWidth: Int,
@@ -47,6 +47,23 @@ fun IllustratedMessage(
@StringRes titleResId: Int,
modifier: Modifier = Modifier,
@StringRes descriptionResId: Int? = null,
) {
IllustratedMessage(
imageContent = {
Image(painter = painterResource(id = imageResId), contentDescription = null)
},
titleResId = titleResId,
modifier = modifier,
descriptionResId = descriptionResId,
)
}
@Composable
fun IllustratedMessage(
imageContent: @Composable () -> Unit,
@StringRes titleResId: Int,
modifier: Modifier = Modifier,
@StringRes descriptionResId: Int? = null,
) {
Box(
modifier = modifier,
@@ -55,7 +72,7 @@ fun IllustratedMessage(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painter = painterResource(id = imageResId), contentDescription = null)
imageContent()
Text(
text = stringResource(id = titleResId),
style = ProtonTheme.typography.headlineNorm.copy(textAlign = TextAlign.Center),
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Core.
*
* Proton Core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.base.presentation.component
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ButtonColors
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.proton.core.compose.component.ProtonButton
import me.proton.core.compose.component.protonButtonColors
import me.proton.core.compose.component.protonElevation
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.defaultStrongNorm
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
@Composable
fun ProtonIconTextButton(
iconPainter: Painter,
title: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
contained: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
colors: ButtonColors = ButtonDefaults.protonButtonColors(
backgroundColor = ProtonTheme.colors.interactionWeakNorm,
contentColor = ProtonTheme.colors.textNorm,
disabledBackgroundColor = ProtonTheme.colors.interactionWeakDisabled,
disabledContentColor = ProtonTheme.colors.textDisabled,
loading = loading,
),
onClick: () -> Unit,
) {
ProtonButton(
onClick = onClick,
modifier = modifier.heightIn(min = ProtonDimens.DefaultButtonMinHeight),
enabled = enabled,
loading = loading,
contained = contained,
interactionSource = interactionSource,
elevation = ButtonDefaults.protonElevation(),
shape = RoundedCornerShape(ProtonDimens.DefaultSpacing),
border = null,
colors = colors,
contentPadding = ButtonDefaults.ContentPadding,
content = {
IconTextContent(
iconPainter = iconPainter,
text = title,
enabled = enabled,
)
},
)
}
@Composable
fun IconTextContent(
iconPainter: Painter,
text: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = iconPainter,
contentDescription = null,
)
Spacer(modifier = Modifier.width(ProtonDimens.ExtraSmallSpacing))
Text(
text = text,
style = ProtonTheme.typography.defaultStrongNorm(enabled),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Preview
@Composable
fun ProtonIconTextButtonLightPreview() {
ProtonTheme(isDark = false) {
ProtonIconTextButtonPreview()
}
}
@Preview
@Composable
fun ProtonIconTextButtonDarkPreview() {
ProtonTheme(isDark = true) {
ProtonIconTextButtonPreview()
}
}
@Composable
private fun ProtonIconTextButtonPreview() {
Surface(
modifier = Modifier.fillMaxSize(),
color = ProtonTheme.colors.backgroundNorm,
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 48.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
PreviewHelper(enabled = true)
PreviewHelper(enabled = false)
}
}
}
@Composable
private fun PreviewHelper(
enabled: Boolean = true,
) {
ProtonIconTextButton(
iconPainter = painterResource(CorePresentation.drawable.ic_proton_plus_circle_filled),
title = stringResource(I18N.string.common_add_action),
enabled = enabled,
onClick = {},
)
}
@@ -19,6 +19,7 @@
package me.proton.core.drive.base.presentation.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
@@ -39,7 +40,7 @@ fun ProtonPullToRefresh(
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
topPadding: Dp = 0.dp,
content: @Composable () -> Unit
content: @Composable BoxScope.() -> Unit
) {
val pullRefreshState = rememberPullRefreshState(refreshing = isRefreshing, onRefresh = onRefresh)
Box(
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Core.
*
* Proton Core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.base.presentation.component
import androidx.annotation.StringRes
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateIntOffsetAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import me.proton.core.compose.component.ProtonButton
import me.proton.core.compose.component.protonTextButtonColors
import me.proton.core.compose.theme.ProtonDimens.DefaultButtonMinHeight
import me.proton.core.compose.theme.ProtonDimens.SmallSpacing
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.drive.i18n.R
import kotlin.math.roundToInt
@Composable
fun ProtonTab(
@StringRes titleResId: Int,
isSelected: Boolean,
modifier: Modifier = Modifier,
onTab: () -> Unit,
minWith: Dp = 120.dp,
) {
val brandColor = ProtonTheme.colors.brandNorm
val dividerColor by animateColorAsState(
targetValue = if (isSelected) brandColor else Color.Transparent,
label = "dividerColor"
)
val textColor by animateColorAsState(
targetValue = if (isSelected) brandColor else ProtonTheme.colors.textWeak,
label = "textColor"
)
val thickness = 4.dp
val offset by animateIntOffsetAsState(
targetValue = if (isSelected) {
IntOffset.Zero
} else {
with(LocalDensity.current) {
IntOffset(0, thickness.toPx().roundToInt())
}
},
label = "offset"
)
ProtonButton(
modifier = modifier,
onClick = { onTab() },
contentPadding = PaddingValues(horizontal = SmallSpacing),
colors = ButtonDefaults.protonTextButtonColors(false),
shape = RoundedCornerShape(0.dp),
border = null,
elevation = null,
) {
Box(
modifier = Modifier
.sizeIn(minHeight = DefaultButtonMinHeight, minWidth = minWith),
) {
Text(
text = stringResource(id = titleResId),
style = ProtonTheme.typography.body2Regular.copy(
color = textColor
),
modifier = Modifier
.align(Alignment.Center),
)
Box(
modifier = Modifier
.matchParentSize(),
contentAlignment = Alignment.BottomCenter,
) {
Divider(
color = dividerColor,
thickness = thickness,
modifier = Modifier
.offset { offset }
.fillMaxWidth()
.clip(RoundedCornerShape(100.dp, 100.dp, 0.dp, 0.dp)),
)
}
}
}
}
@Preview
@Composable
fun ProtonTabPreview() {
ProtonTheme {
var isSelected by remember { mutableIntStateOf(0) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
ProtonTab(
titleResId = R.string.shared_with_me_title,
isSelected = isSelected == 0,
onTab = { isSelected = 0 }
)
ProtonTab(
titleResId = R.string.shared_by_me_title,
isSelected = isSelected == 1,
onTab = { isSelected = 1 }
)
}
}
}
@@ -20,8 +20,8 @@ package me.proton.core.drive.base.presentation.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
@@ -38,7 +38,9 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import me.proton.core.compose.flow.rememberFlowWithLifecycle
@@ -47,6 +49,7 @@ import me.proton.core.compose.theme.ProtonDimens.DefaultIconSize
import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.headlineSmall
import me.proton.core.compose.theme.headlineSmallNorm
import me.proton.core.drive.base.presentation.common.Action
import me.proton.core.drive.base.presentation.component.TopAppBarComponentTestTag.navigationButton
@@ -87,6 +90,7 @@ fun TopAppBar(
backgroundColor: Color = ProtonTheme.colors.backgroundNorm,
contentColor: Color = ProtonTheme.colors.textNorm,
notificationDotVisible: Boolean = false,
elevation: Dp = 0.dp,
actions: @Composable RowScope.() -> Unit = {},
) {
TopAppBar(
@@ -104,7 +108,7 @@ fun TopAppBar(
actions = actions,
backgroundColor = backgroundColor,
contentColor = contentColor,
elevation = 0.dp
elevation = elevation,
)
}
@Composable
@@ -112,13 +116,14 @@ fun Title(
title: String,
isTitleEncrypted: Boolean,
modifier: Modifier = Modifier,
style: TextStyle = ProtonTheme.typography.headlineSmallNorm,
) {
if (isTitleEncrypted) {
EncryptedItem(modifier = modifier.height(LARGE_HEIGHT))
} else {
Text(
text = title,
style = ProtonTheme.typography.headlineSmall,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
@@ -182,11 +187,13 @@ fun ActionButton(
@Composable
fun TopBarActions(
actionFlow: Flow<Set<Action>>,
iconTintColor: Color = IconTintColor,
) {
val actions by rememberFlowWithLifecycle(flow = actionFlow).collectAsState(initial = emptySet())
actions.forEach { action ->
ActionButton(
icon = action.iconResId,
iconTintColor = iconTintColor,
contentDescription = action.contentDescriptionResId,
notificationDotVisible = action.notificationDotVisible,
onClick = action.onAction
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Core.
*
* Proton Core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.base.presentation.extension
import androidx.compose.ui.graphics.Color
fun Color.blendToSolid(alpha: Float, bg: Color = Color.White): Color =
alpha.coerceIn(0f, 1f).let { alpha ->
Color(
red = (red * alpha) + (bg.red * (1 - alpha)),
green = (green * alpha) + (bg.green * (1 - alpha)),
blue = (blue * alpha) + (bg.blue * (1 - alpha)),
alpha = 1f,
)
}
@@ -0,0 +1,161 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="320dp"
android:height="185dp"
android:viewportWidth="320"
android:viewportHeight="185">
<path
android:pathData="M66.08,66.2C63.22,55.54 69.55,44.57 80.22,41.71L176.96,15.79C187.63,12.93 198.6,19.26 201.46,29.93L225.27,118.79C228.13,129.46 221.8,140.43 211.13,143.29L114.39,169.21C103.72,172.07 92.75,165.74 89.89,155.07L66.08,66.2Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="188.9"
android:startY="114.61"
android:endX="121.27"
android:endY="-28.94"
android:type="linear">
<item android:offset="0" android:color="#FF6D4AFF"/>
<item android:offset="1" android:color="#FFF4E5FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M66.08,66.2C63.22,55.54 69.55,44.57 80.22,41.71L176.96,15.79C187.63,12.93 198.6,19.26 201.46,29.93L225.27,118.79C228.13,129.46 221.8,140.43 211.13,143.29L114.39,169.21C103.72,172.07 92.75,165.74 89.89,155.07L66.08,66.2Z"
android:fillAlpha="0.3">
<aapt:attr name="android:fillColor">
<gradient
android:startX="130.4"
android:startY="-9.39"
android:endX="128.93"
android:endY="33.06"
android:type="linear">
<item android:offset="0" android:color="#FFFFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M66.08,66.2C63.22,55.54 69.55,44.57 80.22,41.71L176.81,15.83C187.48,12.97 198.45,19.3 201.31,29.97L213.21,74.4L77.98,110.64L66.08,66.2Z"
android:fillAlpha="0.2">
<aapt:attr name="android:fillColor">
<gradient
android:startX="128.52"
android:startY="28.77"
android:endX="134.21"
android:endY="50.02"
android:type="linear">
<item android:offset="0" android:color="#FFFFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M89.89,155.07C92.75,165.74 103.72,172.07 114.39,169.21L210.98,143.33C221.65,140.47 227.98,129.51 225.12,118.83L213.21,74.4L77.98,110.64L89.89,155.07Z"
android:fillAlpha="0.1">
<aapt:attr name="android:fillColor">
<gradient
android:startX="162.68"
android:startY="156.27"
android:endX="156.99"
android:endY="135.02"
android:type="linear">
<item android:offset="0" android:color="#FFFFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M90.9,59.61C90.9,48.57 99.86,39.61 110.9,39.61H211.06C222.1,39.61 231.06,48.57 231.06,59.61V151.61C231.06,162.66 222.1,171.61 211.06,171.61H110.9C99.86,171.61 90.9,162.66 90.9,151.61V59.61Z"
android:fillAlpha="0.8">
<aapt:attr name="android:fillColor">
<gradient
android:startX="261.59"
android:startY="160.11"
android:endX="147.94"
android:endY="5.72"
android:type="linear">
<item android:offset="0" android:color="#FF6D4AFF"/>
<item android:offset="1" android:color="#FFF4E5FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M90.9,59.61C90.9,48.57 99.86,39.61 110.9,39.61H211.06C222.1,39.61 231.06,48.57 231.06,59.61V151.61C231.06,162.66 222.1,171.61 211.06,171.61H110.9C99.86,171.61 90.9,162.66 90.9,151.61V59.61Z"
android:fillAlpha="0.3">
<aapt:attr name="android:fillColor">
<gradient
android:startX="156.82"
android:startY="34.11"
android:endX="164.56"
android:endY="82.7"
android:type="linear">
<item android:offset="0" android:color="#FFFFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M90.9,59.61C90.9,48.57 99.86,39.61 110.9,39.61H210.9C221.95,39.61 230.9,48.57 230.9,59.61V105.61H90.9V59.61Z"
android:fillAlpha="0.2">
<aapt:attr name="android:fillColor">
<gradient
android:startX="160.9"
android:startY="39.61"
android:endX="160.9"
android:endY="61.61"
android:type="linear">
<item android:offset="0" android:color="#FFFFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M90.9,151.61C90.9,162.66 99.86,171.61 110.9,171.61H210.9C221.95,171.61 230.9,162.66 230.9,151.61V105.61H90.9V151.61Z"
android:fillAlpha="0.1">
<aapt:attr name="android:fillColor">
<gradient
android:startX="160.9"
android:startY="171.61"
android:endX="160.9"
android:endY="149.61"
android:type="linear">
<item android:offset="0" android:color="#FFFFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M110.9,39.61C99.86,39.61 90.9,48.57 90.9,59.61V151.61C90.9,162.66 99.86,171.61 110.9,171.61H211.06C222.1,171.61 231.06,162.66 231.06,151.61V59.61C231.06,48.57 222.1,39.61 211.06,39.61H110.9ZM152.12,98.85C157.47,98.85 161.8,94.69 161.8,89.56C161.8,84.43 157.47,80.26 152.12,80.26C146.78,80.26 142.45,84.43 142.45,89.56C142.45,94.69 146.78,98.85 152.12,98.85ZM172.02,96.87L153.56,120.11C153.56,120.2 153.47,120.2 153.38,120.11L143.16,107.63C142,106.17 139.76,106.17 138.59,107.63L123.9,125.7C122.37,127.51 123.72,130.18 126.14,130.18H145.49H161.62H198.09C200.15,130.18 201.32,127.86 200.06,126.31L176.68,96.87C175.51,95.41 173.18,95.41 172.02,96.87Z"
android:fillColor="#ffffff"
android:fillAlpha="0.5"
android:fillType="evenOdd"/>
<path
android:pathData="M90.9,59.61C90.9,48.57 99.86,39.61 110.9,39.61H210.9C221.95,39.61 230.9,48.57 230.9,59.61V105.61H90.9V59.61Z"
android:fillAlpha="0.2">
<aapt:attr name="android:fillColor">
<gradient
android:startX="160.9"
android:startY="39.61"
android:endX="160.9"
android:endY="61.61"
android:type="linear">
<item android:offset="0" android:color="#FFFFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M90.9,151.61C90.9,162.66 99.86,171.61 110.9,171.61H210.9C221.95,171.61 230.9,162.66 230.9,151.61V105.61H90.9V151.61Z"
android:fillAlpha="0.1">
<aapt:attr name="android:fillColor">
<gradient
android:startX="160.9"
android:startY="171.61"
android:endX="160.9"
android:endY="149.61"
android:type="linear">
<item android:offset="0" android:color="#FFFFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19.867,17.465C20.198,17.896 19.872,18.5 19.308,18.5L8.192,18.5C7.628,18.5 7.302,17.896 7.633,17.465L10.258,14.043C10.424,13.828 10.764,13.828 10.93,14.043C11.721,15.074 13.275,15.074 14.066,14.043L15.318,12.412C15.483,12.196 15.824,12.196 15.99,12.412L19.867,17.465Z"
android:fillColor="#999693"/>
<path
android:pathData="M13.125,11C13.125,11.69 12.565,12.25 11.875,12.25C11.185,12.25 10.625,11.69 10.625,11C10.625,10.31 11.185,9.75 11.875,9.75C12.565,9.75 13.125,10.31 13.125,11Z"
android:fillColor="#999693"/>
<path
android:pathData="M19,6L20,6C21.381,6 22.5,7.119 22.5,8.5L22.5,18.5C22.5,19.881 21.381,21 20,21L7.5,21C6.119,21 5,19.881 5,18.5L5,18L4,18C2.619,18 1.5,16.881 1.5,15.5L1.5,5.5C1.5,4.119 2.619,3 4,3L16.5,3C17.881,3 19,4.119 19,5.5L19,6ZM16.5,4.25L4,4.25C3.31,4.25 2.75,4.81 2.75,5.5L2.75,15.5C2.75,16.19 3.31,16.75 4,16.75L5,16.75L5,8.5C5,7.119 6.119,6 7.5,6L17.75,6L17.75,5.5C17.75,4.81 17.19,4.25 16.5,4.25ZM7.5,7.25L20,7.25C20.69,7.25 21.25,7.81 21.25,8.5L21.25,18.5C21.25,19.19 20.69,19.75 20,19.75L7.5,19.75C6.81,19.75 6.25,19.19 6.25,18.5L6.25,8.5C6.25,7.81 6.81,7.25 7.5,7.25Z"
android:fillColor="#999693"
android:fillType="evenOdd"/>
</vector>
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="50dp"
android:height="50dp"
android:viewportWidth="50"
android:viewportHeight="50">
<path
android:pathData="M41.39,36.384C42.08,37.283 41.4,38.542 40.224,38.542L17.068,38.542C15.892,38.542 15.212,37.283 15.901,36.384L21.372,29.257C21.717,28.807 22.426,28.807 22.771,29.257C24.419,31.404 27.656,31.404 29.304,29.257L31.912,25.858C32.257,25.409 32.967,25.409 33.312,25.858L41.39,36.384Z"
android:fillColor="#706D6B"/>
<path
android:pathData="M27.344,22.917C27.344,24.355 26.178,25.521 24.74,25.521C23.301,25.521 22.135,24.355 22.135,22.917C22.135,21.478 23.301,20.313 24.74,20.313C26.178,20.313 27.344,21.478 27.344,22.917Z"
android:fillColor="#706D6B"/>
<path
android:pathData="M39.583,12.5L41.667,12.5C44.543,12.5 46.875,14.832 46.875,17.708L46.875,38.542C46.875,41.418 44.543,43.75 41.667,43.75L15.625,43.75C12.748,43.75 10.417,41.418 10.417,38.542L10.417,37.5L8.333,37.5C5.457,37.5 3.125,35.168 3.125,32.292L3.125,11.458C3.125,8.582 5.457,6.25 8.333,6.25L34.375,6.25C37.251,6.25 39.583,8.582 39.583,11.458L39.583,12.5ZM34.375,8.854L8.333,8.854C6.895,8.854 5.729,10.02 5.729,11.458L5.729,32.292C5.729,33.73 6.895,34.896 8.333,34.896L10.417,34.896L10.417,17.708C10.417,14.832 12.748,12.5 15.625,12.5L36.979,12.5L36.979,11.458C36.979,10.02 35.813,8.854 34.375,8.854ZM15.625,15.104L41.667,15.104C43.105,15.104 44.271,16.27 44.271,17.708L44.271,38.542C44.271,39.98 43.105,41.146 41.667,41.146L15.625,41.146C14.187,41.146 13.021,39.98 13.021,38.542L13.021,17.708C13.021,16.27 14.187,15.104 15.625,15.104Z"
android:fillColor="#706D6B"
android:fillType="evenOdd"/>
</vector>
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Proton AG.
* This file is part of Proton Core.
*
* Proton Core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.base.presentation.extension
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class ColorKtTest(
private val fgColor: Int,
private val alpha: Float,
private val bgColor: Int,
private val expected: Int,
) {
private fun assertColorEquals(expected: Color, actual: Color, delta: Float = 0.01f) {
assertEquals(expected.red, actual.red, delta)
assertEquals(expected.green, actual.green, delta)
assertEquals(expected.blue, actual.blue, delta)
assertEquals(expected.alpha, actual.alpha, delta)
}
@Test
fun testBlendToSolid() {
val actual = Color(fgColor).blendToSolid(
alpha = alpha,
bg = Color(bgColor),
)
assertColorEquals(Color(expected), actual)
}
companion object {
@get:Parameterized.Parameters(name = "argb({0}).blendToSolid(alpha={1}, bgColor=argb({2})) = argb({3})")
@get:JvmStatic
val data = listOf(
arrayOf(Color(0xFF007B58).toArgb(), 0.3f, Color.White.toArgb(), Color(0xFFB2D7CD).toArgb()),
arrayOf(Color(0xFFFF5733).toArgb(), 0.5f, Color.Black.toArgb(), Color(0xFF7F2B19).toArgb()),
arrayOf(Color(0xFF123456).toArgb(), 0.7f, Color.White.toArgb(), Color(0xFF597189).toArgb()),
arrayOf(Color(0xFFABCDEF).toArgb(), 0.2f, Color.Black.toArgb(), Color(0xFF222930).toArgb()),
arrayOf(Color(0xFF800080).toArgb(), 0.4f, Color(0xFF00FF00).toArgb(), Color(0xFF339933).toArgb()),
)
}
}
@@ -29,9 +29,10 @@ import me.proton.core.drive.key.domain.usecase.GetAddressKeys
import me.proton.core.drive.key.domain.usecase.GetNodeHashKey
import me.proton.core.drive.key.domain.usecase.GetNodeKey
import me.proton.core.drive.key.domain.usecase.MoveNodeKey
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.MoveInfo
import me.proton.core.drive.link.domain.entity.ParentId
import me.proton.core.drive.link.domain.extension.requireParentId
import me.proton.core.drive.link.domain.extension.shareId
import me.proton.core.drive.link.domain.extension.userId
@@ -52,35 +53,39 @@ class CreateMoveInfo @Inject constructor(
) {
suspend operator fun invoke(
linkId: LinkId,
newParentId: FolderId,
newParentId: ParentId,
): Result<MoveInfo> = coRunCatching {
val link = getLink(linkId).toResult().getOrThrow()
val decryptedLinkName = decryptLinkName(link).getOrThrow().text
val currentParentFolder = getLink(link.requireParentId()).toResult().getOrThrow()
val currentParentFolderKey = getNodeKey(currentParentFolder).getOrThrow()
val newParentFolder = getLink(newParentId).toResult().getOrThrow()
val newParentFolderKey = getNodeKey(newParentFolder).getOrThrow()
val newParentFolderHashKey = getNodeHashKey(newParentFolder, newParentFolderKey).getOrThrow()
val currentParent = getLink(link.requireParentId()).toResult().getOrThrow()
val currentParentKey = getNodeKey(currentParent).getOrThrow()
val newParent = getLink(newParentId).toResult().getOrThrow()
val newParentKey = getNodeKey(newParent).getOrThrow()
val newParentHashKey = when(newParent) {
is Link.Album -> getNodeHashKey(newParent, newParentKey).getOrThrow()
is Link.Folder -> getNodeHashKey(newParent, newParentKey).getOrThrow()
else -> error("Either folder of album can be parent")
}
val userId = linkId.userId
val signatureAddress = getSignatureAddress(link.shareId).getOrThrow()
val newLinkKey = moveNodeKey(
userId = userId,
key = getNodeKey(linkId).getOrThrow(),
oldParentKey = currentParentFolderKey,
newParentKey = newParentFolderKey,
oldParentKey = currentParentKey,
newParentKey = newParentKey,
signatureAddress = signatureAddress,
).getOrThrow()
MoveInfo(
name = changeMessage(
oldMessage = link.name,
oldMessageDecryptionKey = currentParentFolderKey.keyHolder,
oldMessageDecryptionKey = currentParentKey.keyHolder,
newMessage = decryptedLinkName,
newMessageEncryptionKey = newParentFolderKey.keyHolder,
newMessageEncryptionKey = newParentKey.keyHolder,
signKey = getAddressKeys(userId, signatureAddress).keyHolder,
).getOrThrow(),
hash = hmacSha256(newParentFolderHashKey, decryptedLinkName).getOrThrow(),// calculate with new parent,
hash = hmacSha256(newParentHashKey, decryptedLinkName).getOrThrow(),// calculate with new parent,
previousHash = link.hash,
parentLinkId = newParentFolder.id.id,
parentLinkId = newParent.id.id,
signatureEmail = if (link.signatureEmail.isEmpty()) {
signatureAddress
} else {
@@ -26,6 +26,7 @@ import me.proton.core.drive.link.data.db.entity.LinkAlbumPropertiesEntity
import me.proton.core.drive.link.data.db.entity.LinkEntity
import me.proton.core.drive.link.data.db.entity.LinkFilePropertiesEntity
import me.proton.core.drive.link.data.db.entity.LinkFolderPropertiesEntity
import me.proton.core.drive.link.data.db.entity.LinkTagEntity
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
@@ -130,6 +131,7 @@ suspend fun FolderContext.folder(
suspend fun FolderContext.file(
id: String,
sharingDetailsShareId: String? = null,
tags: List<Long> = emptyList(),
block: suspend FileContext.() -> Unit = {},
): FileId {
file(
@@ -139,6 +141,7 @@ suspend fun FolderContext.file(
type = 2L,
sharingDetailsShareId = sharingDetailsShareId,
),
tags = tags,
block = block,
)
return FileId(ShareId(user.userId, share.id), id)
@@ -156,6 +159,7 @@ suspend fun FolderContext.file(
contentKeyPacketSignature = null,
activeRevisionSignatureAddress = null,
),
tags: List<Long> = emptyList(),
block: suspend FileContext.() -> Unit = {},
) {
db.driveLinkDao.insertOrUpdate(link)
@@ -174,6 +178,9 @@ suspend fun FolderContext.file(
)
)
}
db.driveLinkDao.insertTags(*tags.map { tag ->
LinkTagEntity(userId, share.id, link.id, tag)
}.toTypedArray())
FileContext(db, user, account, volume, share, link, this.link, properties.activeRevisionId).block()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -83,6 +83,7 @@ import me.proton.core.drive.link.data.db.entity.LinkAlbumPropertiesEntity
import me.proton.core.drive.link.data.db.entity.LinkEntity
import me.proton.core.drive.link.data.db.entity.LinkFilePropertiesEntity
import me.proton.core.drive.link.data.db.entity.LinkFolderPropertiesEntity
import me.proton.core.drive.link.data.db.entity.LinkTagEntity
import me.proton.core.drive.link.selection.data.db.LinkSelectionConverters
import me.proton.core.drive.link.selection.data.db.LinkSelectionDatabase
import me.proton.core.drive.link.selection.data.db.entity.LinkSelectionEntity
@@ -252,6 +253,7 @@ import me.proton.core.notification.data.local.db.NotificationDatabase as CoreNot
LinkFilePropertiesEntity::class,
LinkFolderPropertiesEntity::class,
LinkAlbumPropertiesEntity::class,
LinkTagEntity::class,
LinkOfflineEntity::class,
LinkDownloadStateEntity::class,
DownloadBlockEntity::class,
@@ -420,7 +422,7 @@ abstract class DriveDatabase :
DriveObservabilityDatabase {
companion object {
const val VERSION = 82
const val VERSION = 84
private val migrations = listOf(
DriveDatabaseMigrations.MIGRATION_1_2,
@@ -504,6 +506,8 @@ abstract class DriveDatabase :
DriveDatabaseMigrations.MIGRATION_79_80,
DriveDatabaseMigrations.MIGRATION_80_81,
DriveDatabaseMigrations.MIGRATION_81_82,
DriveDatabaseMigrations.MIGRATION_82_83,
DriveDatabaseMigrations.MIGRATION_83_84,
)
fun buildDatabase(context: Context): DriveDatabase =
@@ -530,4 +530,16 @@ object DriveDatabaseMigrations {
LinkUploadDatabase.MIGRATION_7.migrate(db)
}
}
val MIGRATION_82_83 = object : Migration(82, 83) {
override fun migrate(db: SupportSQLiteDatabase) {
PhotosDatabase.MIGRATION_2.migrate(db)
}
}
val MIGRATION_83_84 = object : Migration(83, 84) {
override fun migrate(db: SupportSQLiteDatabase) {
LinkDatabase.MIGRATION_3.migrate(db)
}
}
}
@@ -29,6 +29,8 @@ 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.ParentId
import me.proton.core.drive.link.domain.extension.userId
import javax.inject.Inject
class GetDecryptedDriveLink @Inject constructor(
@@ -63,6 +65,22 @@ class GetDecryptedDriveLink @Inject constructor(
decryptDriveLink(driveLink, failOnDecryptionError).toDataResult()
}
operator fun invoke(
parentId: ParentId,
failOnDecryptionError: Boolean = true,
) = when (parentId) {
is FolderId -> invoke(parentId.userId, parentId, failOnDecryptionError)
is AlbumId -> invoke(parentId, failOnDecryptionError)
}
operator fun invoke(
userId: UserId,
parentId: ParentId?,
failOnDecryptionError: Boolean = true,
) = parentId?.let {
invoke(parentId, failOnDecryptionError)
} ?: invoke(userId, null, failOnDecryptionError)
operator fun invoke(
linkId: LinkId,
failOnDecryptionError: Boolean = true,
@@ -20,6 +20,7 @@ package me.proton.core.drive.drivelink.download.data.extension
import me.proton.core.drive.drivelink.domain.entity.DriveLink
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.ParentId
import me.proton.core.drive.linkdownload.domain.entity.DownloadState
internal val DriveLink.uniqueWorkName: String
@@ -35,5 +36,8 @@ internal fun uniqueFolderWorkName(folderId: FolderId) =
internal fun uniqueAlbumWorkName(albumId: AlbumId) =
"${albumId.shareId.id}.${albumId.id}"
internal fun uniqueParentWorkName(parentId: ParentId) =
"${parentId.shareId.id}.${parentId.id}"
internal val DriveLink.isNotDownloading: Boolean
get() = downloadState != DownloadState.Downloading
@@ -123,12 +123,12 @@ class FolderDownloadWorker @AssistedInject constructor(
while (mutableDescendants.isNotEmpty()) {
// We peek at the first node in the list
val currentDescendant = mutableDescendants.first()
val parentId = currentDescendant.parentId
val folderId = currentDescendant.parentId as? FolderId
// If descendants list does not contain any child of parent folder, we can mark parent folder as downloaded
if (mutableDescendants.none { link -> link.parentId == parentId }) {
if (mutableDescendants.none { link -> link.parentId == folderId }) {
mutableDescendants
.filterIsInstance<Link.Folder>()
.find { link -> link.id == parentId}
.find { link -> link.id == folderId}
?.let { parent ->
workContinuation = workContinuation.then(parent.setAsDownloaded(userId, folder.id))
}
@@ -147,7 +147,7 @@ class FolderDownloadWorker @AssistedInject constructor(
// folder to treat them at the same time and we create a task to download each file
// We also remove them from the list of unhandled children
mutableDescendants
.filter { link -> link.parentId == parentId && link is Link.File }
.filter { link -> link.parentId == folderId && link is Link.File }
.also { siblings -> mutableDescendants.removeAll(siblings) }
.fold(workContinuation) { continuation, link ->
continuation.then((link as Link.File).setAsToDownload(userId, folderTag))
@@ -190,7 +190,10 @@ class FolderDownloadWorker @AssistedInject constructor(
fileId = id,
revisionId = activeRevisionId,
isRetryable = true,
fileTags = listOfNotNull(folderTag, parentId?.let { uniqueFolderWorkName(it) }),
fileTags = listOfNotNull(
folderTag,
(parentId as? FolderId)?.let { uniqueFolderWorkName(it) },
),
)
companion object {
@@ -26,6 +26,7 @@ import me.proton.core.drive.documentsprovider.domain.usecase.GetContentDigest
import me.proton.core.drive.drivelink.domain.usecase.GetDriveLinks
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.photo.domain.entity.AddToRemoveFromAlbumResult
import me.proton.core.drive.photo.domain.repository.AlbumRepository
import me.proton.core.drive.volume.domain.entity.VolumeId
import javax.inject.Inject
@@ -42,21 +43,24 @@ class AddPhotosToAlbum @Inject constructor(
volumeId: VolumeId,
albumId: AlbumId,
photoIds: List<FileId>,
) = coRunCatching {
): Result<AddToRemoveFromAlbumResult> = coRunCatching {
val (volumePhotoIds, sharedWithMePhotoIds) = volumeAndSharedWithMeFileIds(
volumeId = volumeId,
photoIds = photoIds,
)
addVolumePhotosToAlbum(
val addVolumePhotosToAlbumResult = addVolumePhotosToAlbum(
volumeId = volumeId,
albumId = albumId,
photoIds = volumePhotoIds,
)
addSharedWithMePhotosToAlbum(
val addSharedWithMePhotosToAlbumResult = addSharedWithMePhotosToAlbum(
volumeId = volumeId,
albumId = albumId,
photoIds = sharedWithMePhotoIds,
)
AddToRemoveFromAlbumResult(
addVolumePhotosToAlbumResult.results + addSharedWithMePhotosToAlbumResult.results
)
}
private suspend fun addVolumePhotosToAlbum(
@@ -79,10 +83,11 @@ class AddPhotosToAlbum @Inject constructor(
volumeId: VolumeId,
albumId: AlbumId,
photoIds: List<FileId>,
) {
): AddToRemoveFromAlbumResult {
if (photoIds.isNotEmpty()) {
TODO("addSharedWithMePhotosToAlbum is not implemented yet")
}
return AddToRemoveFromAlbumResult()
}
private suspend fun volumeAndSharedWithMeFileIds(
@@ -70,9 +70,11 @@ class RenameLink @Inject constructor(
linkName: String,
): Result<Unit> = coRunCatching {
val link = getLink(linkId).toResult().getOrThrow()
val parentFolderId = requireNotNull(link.parentId) { "Parent folder must not be null" }
val parentFolder = getLink(parentFolderId).toResult().getOrThrow()
invoke(parentFolder, link, linkName).getOrThrow()
val parentId = requireNotNull(link.parentId) { "Parent must not be null" }
when (val parent = getLink(parentId).toResult().getOrThrow()) {
is Link.Folder -> invoke(parent, link, linkName).getOrThrow()
else -> error("Album should not be renamed through this endpoint")
}
}
suspend operator fun invoke(
@@ -24,11 +24,11 @@ import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.drivelink.data.extension.toDriveLinks
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase
import javax.inject.Inject
import me.proton.core.drive.drivelink.selection.domain.repository.DriveLinkSelectionRepository
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.ParentId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import javax.inject.Inject
class DriveLinkSelectionRepositoryImpl @Inject constructor(
private val db: DriveLinkSelectionDatabase,
@@ -41,7 +41,7 @@ class DriveLinkSelectionRepositoryImpl @Inject constructor(
}
override suspend fun selectAll(
parentId: FolderId,
parentId: ParentId,
selectionId: SelectionId?,
getDriveLinks: (fromIndex: Int, count: Int) -> Flow<List<DriveLink>>,
selectLinks: suspend (SelectionId?, List<LinkId>) -> Result<SelectionId>,
@@ -19,8 +19,8 @@ package me.proton.core.drive.drivelink.selection.domain.repository
import kotlinx.coroutines.flow.Flow
import me.proton.core.drive.drivelink.domain.entity.DriveLink
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.ParentId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
interface DriveLinkSelectionRepository {
@@ -28,7 +28,7 @@ interface DriveLinkSelectionRepository {
fun getSelectedDriveLinks(selectionId: SelectionId): Flow<List<DriveLink>>
suspend fun selectAll(
parentId: FolderId,
parentId: ParentId,
selectionId: SelectionId?,
getDriveLinks: (fromIndex: Int, count: Int) -> Flow<List<DriveLink>>,
selectLinks: suspend (SelectionId?, List<LinkId>) -> Result<SelectionId>,
@@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.selection.domain.repository.DriveLinkSelectionRepository
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.ParentId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
import javax.inject.Inject
@@ -34,7 +34,7 @@ class GetSelectedDriveLinks @Inject constructor(
operator fun invoke(selectionId: SelectionId): Flow<List<DriveLink>> =
repository.getSelectedDriveLinks(selectionId)
operator fun invoke(selectionId: SelectionId, parentId: FolderId): Flow<List<DriveLink>> =
operator fun invoke(selectionId: SelectionId, parentId: ParentId): Flow<List<DriveLink>> =
repository
.getSelectedDriveLinks(selectionId)
.map { driveLinks ->
@@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.map
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.repository.DriveLinkRepository
import me.proton.core.drive.drivelink.selection.domain.repository.DriveLinkSelectionRepository
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.ParentId
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
import javax.inject.Inject
@@ -33,7 +33,7 @@ class SelectAll @Inject constructor(
) {
suspend operator fun invoke(
parentId: FolderId,
parentId: ParentId,
selectionId: SelectionId?,
driveLinkFilter: (DriveLink) -> Boolean = { true },
) = driveLinkSelectionRepository.selectAll(
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2022-2024 Proton AG.
* This file is part of Proton Core.
*
* Proton Core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.drivelink.shared.domain.usecase
import me.proton.core.drive.base.domain.extension.getOrNull
import me.proton.core.drive.base.domain.extension.toResult
import me.proton.core.drive.base.domain.log.LogTag.SHARING
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.eventmanager.base.domain.usecase.UpdateEventAction
import me.proton.core.drive.link.domain.entity.LinkId
import me.proton.core.drive.link.domain.usecase.GetLink
import me.proton.core.drive.share.domain.usecase.DeleteShare
import javax.inject.Inject
class DeleteShareUrlAndShare @Inject constructor(
private val getLink: GetLink,
private val deleteShareUrl: me.proton.core.drive.shareurl.base.domain.usecase.DeleteShareUrl,
private val deleteShare: DeleteShare,
private val updateEventAction: UpdateEventAction,
) {
suspend operator fun invoke(linkId: LinkId): Result<Unit> = coRunCatching {
updateEventAction(linkId.shareId) {
val sharingDetails = getLink(linkId).toResult().getOrThrow().sharingDetails
deleteShareUrl(
shareUrlId = requireNotNull(sharingDetails?.shareUrlId) {
"ShareUrlId not found"
},
).getOrThrow()
deleteShare(requireNotNull(sharingDetails?.shareId), force = false)
.getOrNull(SHARING, "Cannot delete standard share")
}
}
}
@@ -43,6 +43,7 @@ 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
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.domain.api.ProtonApiCode.NOT_EXISTS
import me.proton.core.drive.base.domain.extension.combine
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
import me.proton.core.drive.base.domain.extension.onFailure
@@ -82,6 +83,7 @@ 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.entity.ShareUser
import me.proton.core.drive.share.user.domain.usecase.GetShareUsers
import me.proton.core.network.domain.hasProtonErrorCode
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import me.proton.core.drive.i18n.R as I18N
@@ -118,17 +120,19 @@ class ManageAccessViewModel @Inject constructor(
private val sharedDriveLink = driveLink.filterNotNull().transformLatest { driveLink ->
emitAll(getSharedDriveLink(driveLink))
}.onEach { dataResult ->
dataResult.onFailure { error ->
if (error.cause !is NoSuchElementException) {
error.cause?.log(SHARING)
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.WARNING,
)
dataResult.onFailure { resultError ->
resultError.cause?.let { error ->
if (error !is NoSuchElementException && !error.hasProtonErrorCode(NOT_EXISTS)) {
error.log(SHARING)
broadcastMessages(
userId = userId,
message = error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
type = BroadcastMessage.Type.WARNING,
)
}
}
}
}
@@ -111,7 +111,6 @@ private fun driveLinkFile(
passphraseSignature = "signature",
contentKeyPacket = "contentKeyPacket",
contentKeyPacketSignature = null,
isFavorite = false,
attributes = Attributes(0),
permissions = Permissions(0),
state = Link.State.ACTIVE,
@@ -169,7 +168,6 @@ private fun driveLinkFolder(
key = "key",
passphrase = "passphrase",
passphraseSignature = "signature",
isFavorite = false,
attributes = Attributes(0),
permissions = Permissions(0),
state = Link.State.ACTIVE,
@@ -30,30 +30,31 @@ import me.proton.core.drive.linktrash.data.db.dao.LinkTrashDao
@Dao
interface DriveLinkTrashDao : DriveLinkDao {
//TODO: Once LinkEntity gets volumeId, WHERE clause should check for volumeId also
@Transaction
@Query("""
SELECT ${DriveLinkDao.DRIVE_LINK_SELECT} FROM ${DriveLinkDao.DRIVE_LINK_ENTITY}
WHERE
LinkEntity.user_id = :userId AND
ShareEntity.volume_id = :volumeId AND
${LinkTrashDao.TRASHED_CONDITION}
LIMIT :limit OFFSET :offset
""")
fun getTrashLinks(
userId: UserId,
volumeId: String,
limit: Int,
offset: Int,
): Flow<List<DriveLinkEntity>>
//TODO: Once LinkEntity gets volumeId, WHERE clause should check for volumeId also
@Transaction
@Query("""
SELECT COUNT(*) FROM (
SELECT ${DriveLinkDao.DRIVE_LINK_SELECT} FROM ${DriveLinkDao.DRIVE_LINK_ENTITY}
WHERE
LinkEntity.user_id = :userId AND
ShareEntity.volume_id = :volumeId AND
${LinkTrashDao.TRASHED_CONDITION}
)
""")
fun getTrashedLinksCount(userId: UserId): Flow<Int>
fun getTrashedLinksCount(userId: UserId, volumeId: String): Flow<Int>
}
@@ -38,10 +38,10 @@ class DriveLinkTrashRepositoryImpl @Inject constructor(
fromIndex: Int,
count: Int,
): Flow<List<DriveLink>> =
db.driveLinkTrashDao.getTrashLinks(userId, count, fromIndex).map { entities ->
db.driveLinkTrashDao.getTrashLinks(userId, volumeId.id, count, fromIndex).map { entities ->
entities.toDriveLinks()
}
override fun getTrashDriveLinksCount(userId: UserId, volumeId: VolumeId): Flow<Int> =
db.driveLinkTrashDao.getTrashedLinksCount(userId)
db.driveLinkTrashDao.getTrashedLinksCount(userId, volumeId.id)
}
@@ -108,6 +108,7 @@ interface DriveLinkDao : LinkDao {
LinkFilePropertiesEntity.*,
LinkFolderPropertiesEntity.*,
LinkAlbumPropertiesEntity.*,
LinkTagsData.*,
LinkOfflineEntity.${Column.USER_ID} AS ${OFFLINE_PREFIX}_${Column.USER_ID},
LinkOfflineEntity.${Column.SHARE_ID} AS ${OFFLINE_PREFIX}_${Column.SHARE_ID},
LinkOfflineEntity.${Column.LINK_ID} AS ${OFFLINE_PREFIX}_${Column.LINK_ID},
@@ -18,7 +18,6 @@
package me.proton.core.drive.drivelink.data.repository
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
@@ -29,12 +28,11 @@ import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.domain.repository.DriveLinkRepository
import me.proton.core.drive.link.data.extension.toLink
import me.proton.core.drive.link.data.extension.toLinkWithProperties
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.ParentId
import me.proton.core.drive.link.domain.extension.userId
import javax.inject.Inject
@OptIn(FlowPreview::class)
class DriveLinkRepositoryImpl @Inject constructor(
private val driveLinkDao: DriveLinkDao,
) : DriveLinkRepository {
@@ -44,10 +42,10 @@ class DriveLinkRepositoryImpl @Inject constructor(
.distinctUntilChanged()
.map { entities -> entities.toDriveLinks().firstOrNull() }
override fun getDriveLinksCount(parentId: FolderId): Flow<Int> =
override fun getDriveLinksCount(parentId: ParentId): Flow<Int> =
driveLinkDao.getLinksCountFlow(parentId.userId, parentId.shareId.id, parentId.id)
override fun getDriveLinks(parentId: FolderId, fromIndex: Int, count: Int): Flow<List<DriveLink>> =
override fun getDriveLinks(parentId: ParentId, fromIndex: Int, count: Int): Flow<List<DriveLink>> =
driveLinkDao.getLinks(parentId.userId, parentId.shareId.id, parentId.id, count, fromIndex)
.map { entities -> entities.toDriveLinks() }
@@ -20,16 +20,16 @@ package me.proton.core.drive.drivelink.domain.repository
import kotlinx.coroutines.flow.Flow
import me.proton.core.drive.drivelink.domain.entity.DriveLink
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.ParentId
interface DriveLinkRepository {
fun getDriveLink(linkId: LinkId): Flow<DriveLink?>
fun getDriveLinks(parentId: FolderId, fromIndex: Int, count: Int): Flow<List<DriveLink>>
fun getDriveLinks(parentId: ParentId, fromIndex: Int, count: Int): Flow<List<DriveLink>>
fun getDriveLinksCount(parentId: FolderId): Flow<Int>
fun getDriveLinksCount(parentId: ParentId): Flow<Int>
fun getDriveLinks(linkIds: List<LinkId>): Flow<List<DriveLink>>
}
@@ -30,7 +30,7 @@ open class BaseGetFeatureFlag(
private val configurationProvider: ConfigurationProvider,
) {
protected val refreshAfterDuration: FeatureFlagCachePolicy = { featureFlagId ->
val refreshAfterDuration: FeatureFlagCachePolicy = { featureFlagId ->
featureFlagRepository
.getLastRefreshTimestamp(featureFlagId.userId)
.isOlderThen(configurationProvider.featureFlagFreshDuration)
@@ -221,7 +221,6 @@ fun PreviewFileInfoContent() {
passphraseSignature = "signature",
contentKeyPacket = "contentKeyPacket",
contentKeyPacketSignature = null,
isFavorite = false,
attributes = Attributes(0),
permissions = Permissions(0),
state = Link.State.ACTIVE,
@@ -24,8 +24,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.files.data.operation.move.worker.MoveFileWorker
import me.proton.core.drive.files.domain.operation.FileOperationManager
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.ParentId
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
import javax.inject.Inject
import javax.inject.Singleton
@@ -39,7 +39,7 @@ class FileOperationManagerImpl @Inject constructor(
private val String.uniqueMoveWorkName: String get() = "moving=$this"
override suspend fun changeParent(userId: UserId, linkIds: List<LinkId>, folderId: FolderId, allowUndo: Boolean) {
override suspend fun changeParent(userId: UserId, linkIds: List<LinkId>, parentId: ParentId, allowUndo: Boolean) {
val selectionId = selectLinks(linkIds).getOrThrow()
workManager.enqueueUniqueWork(
selectionId.id.uniqueMoveWorkName,
@@ -47,7 +47,7 @@ class FileOperationManagerImpl @Inject constructor(
MoveFileWorker.getWorkRequest(
userId = userId,
selectionId = selectionId,
folderId = folderId,
parentId = parentId,
allowUndo = allowUndo,
)
)
@@ -36,9 +36,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.supervisorScope
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.domain.api.ProtonApiCode
import me.proton.core.drive.base.data.workmanager.addTags
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.extension.resultValueOrNull
import me.proton.core.drive.base.domain.log.LogTag
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
@@ -46,9 +46,10 @@ import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
import me.proton.core.drive.files.domain.operation.notification.MoveFileExtra
import me.proton.core.drive.files.domain.usecase.ChangeParent
import me.proton.core.drive.link.domain.entity.AlbumId
import me.proton.core.drive.link.domain.entity.Folder
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.ParentId
import me.proton.core.drive.link.domain.usecase.GetLink
import me.proton.core.drive.link.selection.domain.entity.SelectionId
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
@@ -73,16 +74,14 @@ class MoveFileWorker @AssistedInject constructor(
private val userId = UserId(requireNotNull(inputData.getString(KEY_USER_ID)))
private val selectionId = SelectionId(requireNotNull(inputData.getString(KEY_SELECTION_ID)))
private val moveToFolderId = FolderId(
ShareId(userId, requireNotNull(inputData.getString(KEY_MOVE_TO_SHARE_ID))),
requireNotNull(inputData.getString(KEY_MOVE_TO_FOLDER_ID)),
)
private val shareId = ShareId(userId, requireNotNull(inputData.getString(KEY_MOVE_TO_SHARE_ID)))
private val moveToParentId = getMoveToParentId()
private val allowUndo = inputData.getBoolean(KEY_ALLOW_UNDO, true)
override suspend fun doWork(): Result = supervisorScope {
val driveLinks = getSelectedDriveLinks(selectionId).first()
val folder = getLink(moveToFolderId).resultValueOrNull()
if (driveLinks.isEmpty() || folder == null) {
val parentId = getLink(moveToParentId).resultValueOrNull()?.id
if (driveLinks.isEmpty() || parentId == null || parentId !is ParentId) {
broadcastMessages(
userId = userId,
message = applicationContext.getString(I18N.string.file_operation_error_occurred_moving_file),
@@ -94,7 +93,7 @@ class MoveFileWorker @AssistedInject constructor(
val succeeded: MutableList<DriveLink> = Collections.synchronizedList(arrayListOf())
val deferred = driveLinks.map { driveLink ->
async {
changeParent(driveLink.id, folder.id)
changeParent(driveLink.id, parentId)
.onSuccess {
succeeded.add(driveLink)
}
@@ -108,7 +107,7 @@ class MoveFileWorker @AssistedInject constructor(
extra = MoveFileExtra(
userId = userId,
links = listOf(driveLink.parentId to driveLink.id),
folderId = moveToFolderId,
parentId = moveToParentId,
allowUndo = allowUndo,
exception = error,
)
@@ -126,7 +125,7 @@ class MoveFileWorker @AssistedInject constructor(
extra = MoveFileExtra(
userId = userId,
links = succeeded.map { driveLink -> driveLink.parentId to driveLink.id },
folderId = moveToFolderId,
parentId = moveToParentId,
allowUndo = allowUndo,
)
)
@@ -170,18 +169,30 @@ class MoveFileWorker @AssistedInject constructor(
}
} ?: false
private fun getMoveToParentId(): ParentId =
inputData.getString(KEY_MOVE_TO_FOLDER_ID)
?.let { folderId ->
FolderId(shareId, folderId)
}
?: inputData.getString(KEY_MOVE_TO_ALBUM_ID)
?.let { albumId ->
AlbumId(shareId, albumId)
}
?: error("Parent not found")
companion object {
private const val KEY_USER_ID = "KEY_USER_ID"
private const val KEY_SELECTION_ID = "KEY_SELECTION_ID"
private const val KEY_MOVE_TO_FOLDER_ID = "KEY_MOVE_TO_FOLDER_ID"
private const val KEY_MOVE_TO_ALBUM_ID = "KEY_MOVE_TO_ALBUM_ID"
private const val KEY_MOVE_TO_SHARE_ID = "KEY_MOVE_TO_SHARE_ID"
private const val KEY_ALLOW_UNDO = "KEY_ALLOW_UNDO"
fun getWorkRequest(
userId: UserId,
selectionId: SelectionId,
folderId: LinkId,
parentId: ParentId,
allowUndo: Boolean,
tags: Collection<String> = emptyList(),
): OneTimeWorkRequest = OneTimeWorkRequest.Builder(MoveFileWorker::class.java)
@@ -189,8 +200,17 @@ class MoveFileWorker @AssistedInject constructor(
Data.Builder()
.putString(KEY_USER_ID, userId.id)
.putString(KEY_SELECTION_ID, selectionId.id)
.putString(KEY_MOVE_TO_FOLDER_ID, folderId.id)
.putString(KEY_MOVE_TO_SHARE_ID, folderId.shareId.id)
.apply {
putString(
when (parentId) {
is FolderId -> KEY_MOVE_TO_FOLDER_ID
is AlbumId -> KEY_MOVE_TO_ALBUM_ID
},
parentId.id,
)
}
.putString(KEY_MOVE_TO_SHARE_ID, parentId.shareId.id)
.putBoolean(KEY_ALLOW_UNDO, allowUndo)
.build()
)
@@ -38,17 +38,17 @@ class FileOperationActionProvider @Inject constructor(
private fun MoveFileExtra.provideAction(): ActionProvider.Action? = if (exception != null) {
ActionProvider.Action(I18N.string.files_operation_retry_action) {
moveFile(userId, links.map { pair -> pair.second }, folderId)
moveFile(userId, links.map { pair -> pair.second }, parentId)
}
} else if (allowUndo) {
ActionProvider.Action(I18N.string.files_operation_undo_action) {
val folderChildren = links.groupBy { pair -> pair.first }
folderChildren.keys.forEach { originalFolderId ->
originalFolderId?.let {
val children = links.groupBy { pair -> pair.first }
children.keys.forEach { originalParentId ->
originalParentId?.let {
moveFile(
userId = userId,
linkIds = folderChildren[originalFolderId]!!.map { pair -> pair.second },
folderId = originalFolderId,
linkIds = children[originalParentId]!!.map { pair -> pair.second },
parentId = originalParentId,
allowUndo = false,
)
}
@@ -19,10 +19,10 @@
package me.proton.core.drive.files.domain.operation
import me.proton.core.domain.entity.UserId
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.ParentId
interface FileOperationManager {
suspend fun changeParent(userId: UserId, linkIds: List<LinkId>, folderId: FolderId, allowUndo: Boolean)
suspend fun changeParent(userId: UserId, linkIds: List<LinkId>, parentId: ParentId, allowUndo: Boolean)
}

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