This commit is contained in:
Damir Mihaljinec
2025-02-13 11:31:52 +01:00
parent 23978c01a4
commit b49c9bd647
449 changed files with 45600 additions and 619 deletions
+1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle
.kotlin
local.properties
private.properties
.idea/*
+27
View File
@@ -402,6 +402,13 @@ test:firebase:e2e:details:
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.details"
test:firebase:e2e:document:
extends: .app-firebase-tests
rules:
- if: '$CI_MERGE_REQUEST_LABELS =~ /scope:document/'
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.document"
test:firebase:e2e:move:
extends: .app-firebase-tests
resource_group: "shard-3-$CI_COMMIT_SHORT_SHA"
@@ -458,6 +465,26 @@ test:firebase:e2e:settings:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.settings"
DEVICE_CONFIG: "quickTest-3"
test:firebase:e2e:share-link:
extends: .app-firebase-tests
resource_group: "shard-4-$CI_COMMIT_SHORT_SHA"
rules:
- if: '$CI_MERGE_REQUEST_LABELS =~ /scope:share-link/'
- !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.share.link"
DEVICE_CONFIG: "quickTest-2"
test:firebase:e2e:share-list:
extends: .app-firebase-tests
resource_group: "shard-4-$CI_COMMIT_SHORT_SHA"
rules:
- if: '$CI_MERGE_REQUEST_LABELS =~ /scope:share-list/'
- !reference [.app-firebase-tests, rules]
variables:
TEST_TARGETS: "package me.proton.android.drive.ui.test.flow.share.list"
DEVICE_CONFIG: "quickTest-2"
test:firebase:e2e:share-user:
extends: .app-firebase-tests
resource_group: "shard-4-$CI_COMMIT_SHORT_SHA"
+1 -1
View File
@@ -1,4 +1,4 @@
{
"project": "android-drive",
"locale": "d2c34154477243fc633dfe636a8ecfe604cad707"
"locale": "edd85262380858d323198b1112cf4c6b7548c1e1"
}
+12
View File
@@ -0,0 +1,12 @@
# ownership: loose
* @rbac/android/drive
/.idea/ @rbac/android/drive
/app-lock/ @rbac/android/drive
/app-ui-settings/ @rbac/android/drive
/app/ @rbac/android/drive
/buildSrc/ @rbac/android/drive
/drive/ @rbac/android/drive
/gradle/ @rbac/android/drive
/photos/ @rbac/android/drive
/screenshot-tests/ @rbac/android/drive
/verifier/ @rbac/android/drive
@@ -21,6 +21,6 @@ package me.proton.drive.android.settings.domain.entity
import me.proton.core.drive.base.domain.entity.TimestampS
enum class WhatsNewKey(val limit: TimestampS) {
PROTON_DOCS(limit = TimestampS(1735646400)) // end of 2024
PUBLIC_SHARING(limit = TimestampS(1743465600)), // end of march 2025
}
@@ -138,6 +138,7 @@ class DebugSettings(
key = prefsPhotosUpsellPhotoCount,
default = buildConfig.photosUpsellPhotoCount,
)
override val albumsFeatureFlag: Boolean = true
fun reset(coroutineScope: CoroutineScope) {
coroutineScope.launch {
@@ -95,7 +95,6 @@ import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.thumbnail.presentation.coil.ThumbnailEnabled
import me.proton.core.notification.presentation.deeplink.DeeplinkManager
import me.proton.core.notification.presentation.deeplink.onActivityCreate
import me.proton.core.usersettings.presentation.compose.view.SecurityKeysActivity
import me.proton.core.util.kotlin.CoreLogger
import me.proton.drive.android.settings.domain.entity.ThemeStyle
import me.proton.drive.android.settings.domain.usecase.GetHomeTab
@@ -175,7 +174,7 @@ class MainActivity : FragmentActivity() {
exitApp = { finish() },
navigateToPasswordManagement = accountViewModel::startPasswordManagement,
navigateToRecoveryEmail = accountViewModel::startUpdateRecoveryEmail,
navigateToSecurityKeys = { SecurityKeysActivity.start(this@MainActivity) },
navigateToSecurityKeys = accountViewModel::startSecurityKeys,
navigateToBugReport = bugReportViewModel::sendBugReport,
navigateToSubscription = plansViewModel::showCurrentPlans,
navigateToRatingBooster = { showRatingBooster(this@MainActivity) },
@@ -28,4 +28,5 @@ fun DriveLink.onClick(
) = when (this) {
is DriveLink.Folder -> navigateToFolder(id, if (isNameEncrypted) null else name)
is DriveLink.File -> navigateToPreview(id)
is DriveLink.Album -> error("TODO") // navigateToAlbum?
}
@@ -170,6 +170,7 @@ fun FileOrFolderOptions(
.testTag(FileFolderOptionsDialogTestTag.folderOptions),
)
}
is DriveLink.Album -> error("TODO")// AlbumOptions
}
}
@@ -180,10 +180,10 @@ private fun WhatsNewPreview() {
Surface(color = ProtonTheme.colors.backgroundNorm) {
WhatsNew(
viewState = WhatsNewViewState(
title = stringResource(id = I18N.string.whats_new_docs_title),
description = stringResource(id = I18N.string.whats_new_docs_description),
action = stringResource(id = I18N.string.whats_new_docs_action),
image = BasePresentation.drawable.img_whats_new_proton_docs
title = "Title",
description = "Very very long description that can go on two lines",
action = "Action",
image = BasePresentation.drawable.img_onboarding
),
viewEvent = object : WhatsNewViewEvent {}
)
@@ -114,6 +114,7 @@ import me.proton.android.drive.ui.screen.SettingsScreen
import me.proton.android.drive.ui.screen.SigningOutScreen
import me.proton.android.drive.ui.screen.TrashScreen
import me.proton.android.drive.ui.screen.UploadToScreen
import me.proton.android.drive.ui.screen.UserInvitationScreen
import me.proton.android.drive.ui.viewmodel.ConfirmStopSyncFolderDialogViewModel
import me.proton.android.drive.ui.viewmodel.PreviewViewModel
import me.proton.core.account.domain.entity.Account
@@ -157,7 +158,7 @@ fun AppNavGraph(
exitApp: () -> Unit,
navigateToPasswordManagement: (UserId) -> Unit,
navigateToRecoveryEmail: (UserId) -> Unit,
navigateToSecurityKeys: () -> Unit,
navigateToSecurityKeys: (UserId) -> Unit,
navigateToBugReport: () -> Unit,
navigateToSubscription: () -> Unit,
navigateToRatingBooster: () -> Unit,
@@ -242,7 +243,7 @@ fun AppNavGraph(
exitApp: () -> Unit,
navigateToPasswordManagement: (UserId) -> Unit,
navigateToRecoveryEmail: (UserId) -> Unit,
navigateToSecurityKeys: () -> Unit,
navigateToSecurityKeys: (UserId) -> Unit,
navigateToBugReport: () -> Unit,
navigateToSubscription: () -> Unit,
navigateToRatingBooster: () -> Unit,
@@ -340,6 +341,7 @@ fun AppNavGraph(
addInvitationOptions(navController)
addExternalInvitationOptions(navController)
addMemberOptions(navController)
addUserInvitation(navController)
addShareLinkPermissions(navController)
addDiscardShareViaInvitationsChanges(navController)
addShareViaLink(navController)
@@ -662,6 +664,22 @@ fun NavGraphBuilder.addMemberOptions(
)
}
fun NavGraphBuilder.addUserInvitation(
navController : NavHostController,
) = composable(
route = Screen.UserInvitation.route,
arguments = listOf(
navArgument(Screen.UserInvitation.USER_ID) { type = NavType.StringType },
),
) {
UserInvitationScreen (onBack = {
navController.popBackStack(
route = Screen.UserInvitation.route,
inclusive = true,
)
})
}
fun NavGraphBuilder.addShareLinkPermissions(
navController: NavHostController,
) = modalBottomSheet(
@@ -910,6 +928,9 @@ internal fun NavGraphBuilder.addHome(
)
)
},
navigateToUserInvitation = {
navController.navigate(Screen.UserInvitation(userId))
},
modifier = Modifier.fillMaxSize(),
)
}
@@ -1334,7 +1355,7 @@ fun NavGraphBuilder.addAccountSettings(
navController: NavHostController,
navigateToPasswordManagement: (UserId) -> Unit,
navigateToRecoveryEmail: (UserId) -> Unit,
navigateToSecurityKeys: () -> Unit
navigateToSecurityKeys: (UserId) -> Unit
) = composable(
route = Screen.Settings.AccountSettings.route,
enterTransition = defaultEnterSlideTransition { true },
@@ -1349,7 +1370,7 @@ fun NavGraphBuilder.addAccountSettings(
AccountSettingsScreen(
navigateToPasswordManagement = { navigateToPasswordManagement(userId) },
navigateToRecoveryEmail = { navigateToRecoveryEmail(userId) },
navigateToSecurityKeys = navigateToSecurityKeys,
navigateToSecurityKeys = { navigateToSecurityKeys(userId) },
navigateBack = {
navController.popBackStack(
route = Screen.Settings.AccountSettings.route,
@@ -72,6 +72,7 @@ fun HomeNavGraph(
navigateToBackupSettings: () -> Unit,
navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToUserInvitation: () -> Unit,
) = DriveNavHost(
navController = homeNavController,
startDestination = startDestination
@@ -129,6 +130,7 @@ fun HomeNavGraph(
navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES)
},
navigateToParentFolderOptions = navigateToParentFolderOptions,
navigateToUserInvitation = navigateToUserInvitation,
)
}
@@ -410,6 +412,7 @@ fun NavGraphBuilder.addSharedTabs(
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToMultipleFileOrFolderOptions: (SelectionId) -> Unit,
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
navigateToUserInvitation: () -> Unit,
) = composable(
route = Screen.SharedTabs.route,
enterTransition = defaultEnterSlideTransition {
@@ -484,6 +487,7 @@ fun NavGraphBuilder.addSharedTabs(
},
navigateToPreview = navigateToSinglePreview,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToUserInvitation = navigateToUserInvitation,
)
}
} ?: let {
@@ -45,6 +45,7 @@ import me.proton.core.drive.drivelink.rename.presentation.viewmodel.RenameViewMo
import me.proton.core.drive.drivelink.shared.presentation.viewmodel.LinkSettingsViewModel
import me.proton.core.drive.drivelink.shared.presentation.viewmodel.SharedDriveLinkViewModel
import me.proton.core.drive.folder.create.presentation.CreateFolderViewModel
import me.proton.core.drive.link.domain.entity.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
@@ -198,13 +199,14 @@ sealed class Screen(val route: String) {
operator fun invoke(userId: UserId) = "delete/${userId.id}/trash"
}
data object Rename : Screen("rename/{userId}/shares/{shareId}/files?fileId={fileId}&folderId={folderId}") {
data object Rename : Screen("rename/{userId}/shares/{shareId}/files?fileId={fileId}&folderId={folderId}&albumId={albumId}") {
operator fun invoke(
userId: UserId,
linkId: LinkId,
) = when (linkId) {
is FileId -> "rename/${userId.id}/shares/${linkId.shareId.id}/files?fileId=${linkId.id}"
is FolderId -> "rename/${userId.id}/shares/${linkId.shareId.id}/files?folderId=${linkId.id}"
is AlbumId -> "rename/${userId.id}/shares/${linkId.shareId.id}/files?albumId=${linkId.id}"
}
const val FILE_ID = RenameViewModel.KEY_FILE_ID
@@ -553,6 +555,14 @@ sealed class Screen(val route: String) {
const val MEMBER_ID = ShareMemberOptionsViewModel.KEY_MEMBER_ID
}
data object UserInvitation : Screen("shareViaInvitations/{userId}/invitations") {
operator fun invoke(
userId: UserId,
) = "shareViaInvitations/${userId.id}/invitations"
const val USER_ID = Screen.USER_ID
}
data object ShareLinkPermissions : Screen("shareViaLink/{userId}/shares/{shareId}/linkId/{linkId}/permissions") {
operator fun invoke(
linkId: LinkId,
@@ -313,8 +313,8 @@ sealed class Option(
}
sealed class ApplicableQuantity(open val quantity: Long) {
object Single : ApplicableQuantity(1L)
object All : ApplicableQuantity(Long.MAX_VALUE)
data object Single : ApplicableQuantity(1L)
data object All : ApplicableQuantity(Long.MAX_VALUE)
}
enum class ApplicableTo {
@@ -322,7 +322,8 @@ enum class ApplicableTo {
FILE_PHOTO,
FILE_DEVICE,
FILE_PROTON_CLOUD,
FOLDER;
FOLDER,
ALBUM;
companion object {
val ANY_FILE: Set<ApplicableTo> get() = setOf(FILE_MAIN, FILE_PHOTO, FILE_DEVICE, FILE_PROTON_CLOUD)
@@ -359,6 +360,7 @@ fun DriveLink.isApplicableTo(applicableTo: Set<ApplicableTo>): Boolean = when (t
else -> applicableTo.any { it in setOf(ApplicableTo.FILE_MAIN, ApplicableTo.FILE_DEVICE) }
}
is DriveLink.Folder -> applicableTo.contains(ApplicableTo.FOLDER)
is DriveLink.Album -> applicableTo.contains(ApplicableTo.ALBUM)
}
fun Set<Option>.filter(driveLink: DriveLink) =
@@ -104,6 +104,7 @@ fun HomeScreen(
navigateToWhatsNew: (WhatsNewKey) -> Unit,
navigateToRatingBooster: () -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToUserInvitation: () -> Unit,
modifier: Modifier = Modifier,
) {
setLocalSnackbarPadding(BottomNavigationHeight)
@@ -157,6 +158,7 @@ fun HomeScreen(
navigateToBackupSettings = navigateToBackupSettings,
navigateToComputerOptions = navigateToComputerOptions,
navigateToNotificationPermissionRationale = navigateToNotificationPermissionRationale,
navigateToUserInvitation = navigateToUserInvitation,
arguments = arguments,
viewState = currentViewState,
viewEvent = viewEvent,
@@ -195,6 +197,7 @@ internal fun Home(
navigateToBackupSettings: () -> Unit,
navigateToComputerOptions: (deviceId: DeviceId) -> Unit,
navigateToNotificationPermissionRationale: () -> Unit,
navigateToUserInvitation: () -> Unit,
) {
val homeScaffoldState = rememberHomeScaffoldState()
val isDrawerOpen = with(homeScaffoldState.scaffoldState.drawerState) {
@@ -290,6 +293,7 @@ internal fun Home(
navigateToBackupSettings,
navigateToComputerOptions,
navigateToNotificationPermissionRationale,
navigateToUserInvitation,
)
}
}
@@ -69,6 +69,7 @@ fun SharedTabsScreen(
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (FileId) -> Unit,
navigateToFileOrFolderOptions: (LinkId) -> Unit,
navigateToUserInvitation: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = hiltViewModel<SharedTabsViewModel>()
@@ -94,6 +95,7 @@ fun SharedTabsScreen(
navigateToFiles = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToUserInvitation = navigateToUserInvitation,
)
}
@@ -105,6 +107,7 @@ fun SharedTabs(
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (FileId) -> Unit,
navigateToFileOrFolderOptions: (LinkId) -> Unit,
navigateToUserInvitation: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@@ -134,6 +137,7 @@ fun SharedTabs(
navigateToFiles = navigateToFiles,
navigateToPreview = navigateToPreview,
navigateToFileOrFolderOptions = navigateToFileOrFolderOptions,
navigateToUserInvitation = navigateToUserInvitation,
)
SharedTab.Type.SHARED_BY_ME -> SharedByMeScreen(
homeScaffoldState = homeScaffoldState,
@@ -18,19 +18,24 @@
package me.proton.android.drive.ui.screen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalLifecycleOwner
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.ui.effect.HandleHomeEffect
import me.proton.android.drive.ui.viewmodel.SharedWithMeViewModel
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
import me.proton.core.compose.flow.rememberFlowWithLifecycle
import me.proton.core.drive.drivelink.shared.presentation.component.Shared
import me.proton.core.drive.drivelink.shared.presentation.component.UserInvitationBanner
import me.proton.core.drive.link.domain.entity.FileId
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.entity.LinkId
@@ -41,10 +46,14 @@ fun SharedWithMeScreen(
navigateToFiles: (FolderId, String?) -> Unit,
navigateToPreview: (FileId) -> Unit,
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
navigateToUserInvitation: () -> Unit,
modifier: Modifier = Modifier
) {
val viewModel = hiltViewModel<SharedWithMeViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(initialValue = viewModel.initialViewState)
val userInvitationViewState by viewModel.userInvitationBannerViewState.collectAsStateWithLifecycle(
initialValue = null
)
val lifecycle = LocalLifecycleOwner.current.lifecycle
val viewEvent = remember(lifecycle) {
viewModel.viewEvent(
@@ -65,6 +74,17 @@ fun SharedWithMeScreen(
driveLinksFlow = viewModel.driveLinksMap,
modifier = modifier
.testTag(SharedWithMeTestTag.content),
headerContent = {
Box(Modifier.defaultMinSize(minHeight = 1.dp)) {
// minHeight: always draw to have the header visible in the lazy list
userInvitationViewState?.let { viewState ->
UserInvitationBanner(
description = viewState.description,
onClick = navigateToUserInvitation,
)
}
}
}
)
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2024-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.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.android.drive.ui.viewmodel.UserInvitationViewModel
import me.proton.core.drive.drivelink.shared.presentation.component.UserInvitation
@Composable
fun UserInvitationScreen(
modifier: Modifier = Modifier,
onBack: () -> Unit,
) {
val viewModel = hiltViewModel<UserInvitationViewModel>()
val viewState by viewModel.viewState.collectAsStateWithLifecycle(viewModel.initialViewState)
val viewEvent = remember { viewModel.viewEvent(onBack) }
val invitations by viewModel.userInvitations.collectAsStateWithLifecycle()
UserInvitation(
viewState = viewState,
viewEvent = viewEvent,
invitations = invitations.orEmpty(),
modifier = modifier
.statusBarsPadding()
.fillMaxSize()
.testTag(UserInvitationTestFlag.content),
)
}
object UserInvitationTestFlag {
const val content = "user invitation content"
}
@@ -37,7 +37,7 @@ class UiTestHelper @Inject constructor(
}
suspend fun doNotShowWhatsNewAfterLogin() {
appUiSettingsDataStore.WhatsNew(WhatsNewKey.PROTON_DOCS.name).shown = 1L
appUiSettingsDataStore.WhatsNew(WhatsNewKey.PUBLIC_SHARING.name).shown = 1L
}
suspend fun doNotShowRatingBoosterAfterLogin() {
@@ -113,6 +113,10 @@ class AccountViewModel @Inject constructor(
userSettingsOrchestrator.startUpdateRecoveryEmailWorkflow(userId)
}
fun startSecurityKeys(userId: UserId) {
userSettingsOrchestrator.startSecurityKeysWorkflow(userId)
}
sealed class State {
object PrimaryNeeded : State()
object AccountReady : State()
@@ -30,11 +30,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import me.proton.android.drive.BuildConfig
import me.proton.android.drive.ui.navigation.HomeTab
@@ -44,15 +47,22 @@ import me.proton.android.drive.ui.viewstate.HomeViewState
import me.proton.android.drive.usecase.CanGetMoreFreeStorage
import me.proton.android.drive.usecase.GetDynamicHomeTabsFlow
import me.proton.android.drive.usecase.ShouldShowOverlay
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.domain.entity.SessionUserId
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
import me.proton.core.drive.base.domain.extension.getOrNull
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
import me.proton.core.drive.base.presentation.component.NavigationTab
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveMobileSharingInvitationsAcceptReject
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerViewEvent
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerViewState
import me.proton.core.drive.share.user.domain.usecase.GetUserInvitationCountFlow
import me.proton.core.drive.share.user.domain.usecase.HasUserInvitationFlow
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.entity.User
import me.proton.core.util.kotlin.CoreLogger
@@ -70,24 +80,38 @@ class HomeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val canGetMoreFreeStorage: CanGetMoreFreeStorage,
getDynamicHomeTabsFlow: GetDynamicHomeTabsFlow,
hasUserInvitationFlow: HasUserInvitationFlow,
getFeatureFlagFlow: GetFeatureFlagFlow,
private val broadcastMessages: BroadcastMessages,
private val shouldShowOverlay: ShouldShowOverlay,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
) : ViewModel(), NotificationDotViewModel, UserViewModel by UserViewModel(savedStateHandle) {
private var navigateToTab: ((route: String) -> Unit)? = null
private val tabs: StateFlow<Map<out HomeTab, NavigationTab>> = getDynamicHomeTabsFlow(userId)
.map { dynamicHomeTabs ->
dynamicHomeTabs
.filter { dynamicHomeTab -> dynamicHomeTab.isEnabled }
.sortedBy { dynamicHomeTab -> dynamicHomeTab.order }
.map { dynamicHomeTab ->
dynamicHomeTab.screen to NavigationTab(
iconResId = dynamicHomeTab.iconResId,
titleResId = dynamicHomeTab.titleResId,
)
}
.associateBy({ tab -> tab.first }, { tab -> tab.second })
override val notificationDotRequested = getFeatureFlagFlow(
driveMobileSharingInvitationsAcceptReject(userId),
).transform { featureFlag ->
if (featureFlag.on) {
emitAll(hasUserInvitationFlow(userId))
}
}.stateIn(viewModelScope, SharingStarted.Lazily, false)
val tabs: StateFlow<Map<out HomeTab, NavigationTab>> = combine(
getDynamicHomeTabsFlow(userId),
notificationDotRequested,
) { dynamicHomeTabs, notificationDotRequested ->
dynamicHomeTabs
.filter { dynamicHomeTab -> dynamicHomeTab.isEnabled }
.sortedBy { dynamicHomeTab -> dynamicHomeTab.order }
.map { dynamicHomeTab ->
dynamicHomeTab.screen to NavigationTab(
iconResId = dynamicHomeTab.iconResId,
titleResId = dynamicHomeTab.titleResId,
notificationDotVisible = dynamicHomeTab.screen is Screen.SharedTabs
&& notificationDotRequested,
)
}
.associateBy({ tab -> tab.first }, { tab -> tab.second })
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
private val currentDestination = MutableStateFlow<String?>(null)
@@ -155,13 +179,14 @@ class HomeViewModel @Inject constructor(
private val NavigationTab.screen: HomeTab
get() = tabs.value.firstNotNullOf { (screen, value) -> screen.takeIf { value == this } }
private val DynamicHomeTab.screen: HomeTab get() = when (route) {
Screen.Files.route -> Screen.Files
Screen.Photos.route -> Screen.Photos
Screen.Computers.route -> Screen.Computers
Screen.SharedTabs.route -> Screen.SharedTabs
else -> error("Unhandled tab item route: $route")
}
private val DynamicHomeTab.screen: HomeTab
get() = when (route) {
Screen.Files.route -> Screen.Files
Screen.Photos.route -> Screen.Photos
Screen.Computers.route -> Screen.Computers
Screen.SharedTabs.route -> Screen.SharedTabs
else -> error("Unhandled tab item route: $route")
}
private fun getViewState(
user: User?,
@@ -181,7 +206,10 @@ class HomeViewModel @Inject constructor(
)
)
private fun handleInvalidDestination(destinationRoute: String, tabs: Map<out HomeTab, NavigationTab>) {
private fun handleInvalidDestination(
destinationRoute: String,
tabs: Map<out HomeTab, NavigationTab>
) {
val routes = tabs.values.map { navigationTab -> navigationTab.screen.route }
if (destinationRoute !in routes) {
CoreLogger.w(VIEW_MODEL, "Invalid destination route: $destinationRoute")
@@ -98,6 +98,7 @@ import me.proton.core.drive.base.presentation.viewmodel.onLoadState
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.GetPagedPhotoListingsList
import me.proton.core.drive.drivelink.photo.domain.usecase.GetPhotoCount
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
import me.proton.core.drive.link.domain.entity.FileId
@@ -108,7 +109,6 @@ 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.linkupload.domain.entity.UploadFileLink.Companion.RECENT_BACKUP_PRIORITY
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.photo.domain.usecase.GetPhotoCount
import me.proton.core.drive.share.domain.entity.Share
import me.proton.core.drive.user.domain.entity.UserMessage
import me.proton.core.drive.user.domain.usecase.CancelUserMessage
@@ -761,7 +761,7 @@ class PhotoContentProvider(
nodeKey = "",
nodePassphrase = "",
nodePassphraseSignature = "",
signatureAddress = "",
signatureEmail = "",
creationTime = TimestampS(),
trashedTime = null,
hasThumbnail = false,
@@ -30,22 +30,33 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import me.proton.core.domain.arch.mapSuccessValueOrNull
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
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.extension.quantityString
import me.proton.core.drive.base.presentation.state.ListContentState
import me.proton.core.drive.drivelink.shared.domain.usecase.GetPagedSharedWithMeLinkIds
import me.proton.core.drive.drivelink.shared.domain.usecase.SharedDriveLinks
import me.proton.core.drive.drivelink.shared.presentation.entity.SharedItem
import me.proton.core.drive.drivelink.shared.presentation.viewstate.UserInvitationBannerViewState
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.NOT_FOUND
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveMobileSharingInvitationsAcceptReject
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveSharingDisabled
import me.proton.core.drive.feature.flag.domain.extension.on
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
import me.proton.core.drive.share.user.domain.entity.SharedLinkId
import me.proton.core.drive.share.user.domain.usecase.GetAllSharedWithMeIds
import me.proton.core.drive.share.user.domain.usecase.GetUserInvitationCountFlow
import javax.inject.Inject
import me.proton.core.drive.base.presentation.R as BasePresentation
import me.proton.core.drive.i18n.R as I18N
@@ -59,6 +70,7 @@ class SharedWithMeViewModel @Inject constructor(
getPagedSharedWithMeLinkIds: GetPagedSharedWithMeLinkIds,
getFeatureFlagFlow: GetFeatureFlagFlow,
private val getAllSharedWithMeIds: GetAllSharedWithMeIds,
getUserInvitationCountFlow: GetUserInvitationCountFlow,
) : CommonSharedViewModel(
savedStateHandle = savedStateHandle,
appContext = appContext,
@@ -82,24 +94,46 @@ class SharedWithMeViewModel @Inject constructor(
descriptionResId = I18N.string.shared_with_me_empty_description,
actionResId = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
override val driveLinks: Flow<PagingData<SharedItem>> = killSwitch.transformLatest { featureFlag ->
if (featureFlag.state == ENABLED) {
emit(PagingData.empty())
} else {
emitAll(
getPagedSharedWithMeLinkIds(userId)
.map { pagingData ->
pagingData.map { sharedLinkId ->
SharedItem.Listing(
linkId = sharedLinkId.linkId,
) as SharedItem
}
}.cachedIn(viewModelScope)
)
override val driveLinks: Flow<PagingData<SharedItem>> =
killSwitch.transformLatest { featureFlag ->
if (featureFlag.state == ENABLED) {
emit(PagingData.empty())
} else {
emitAll(
getPagedSharedWithMeLinkIds(userId)
.map { pagingData ->
pagingData.map { sharedLinkId ->
SharedItem.Listing(
linkId = sharedLinkId.linkId,
) as SharedItem
}
}.cachedIn(viewModelScope)
)
}
}
}
override suspend fun getAllIds(): Result<List<SharedLinkId>> = getAllSharedWithMeIds(userId)
val userInvitationBannerViewState =
getFeatureFlagFlow(driveMobileSharingInvitationsAcceptReject(userId)).transform { featureFlag ->
if (featureFlag.on) {
emitAll(getUserInvitationCountFlow(userId, refresh = flowOf(true))
.filterSuccessOrError()
.mapSuccessValueOrNull()
.filterNotNull()
.filter { count -> count > 0 }
.map { count ->
UserInvitationBannerViewState(
appContext.quantityString(
I18N.plurals.shared_by_me_invitation_banner_description,
count
)
)
}
)
}
}
}
@@ -0,0 +1,183 @@
/*
* Copyright (c) 2024-2025 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.viewmodel
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import me.proton.core.domain.arch.DataResult
import me.proton.core.domain.arch.onSuccess
import me.proton.core.drive.base.data.extension.getDefaultMessage
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.data.extension.logDefaultMessage
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
import me.proton.core.drive.base.domain.extension.onFailure
import me.proton.core.drive.base.domain.log.LogTag.SHARING
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.R
import me.proton.core.drive.base.presentation.common.getThemeDrawableId
import me.proton.core.drive.base.presentation.state.ListContentState
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
import me.proton.core.drive.drivelink.shared.presentation.viewevent.UserInvitationViewEvent
import me.proton.core.drive.drivelink.shared.presentation.viewstate.UserInvitationViewState
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.share.user.domain.entity.UserInvitation
import me.proton.core.drive.share.user.domain.entity.UserInvitationId
import me.proton.core.drive.share.user.domain.usecase.AcceptUserInvitation
import me.proton.core.drive.share.user.domain.usecase.GetDecryptedUserInvitationsFlow
import me.proton.core.drive.share.user.domain.usecase.RejectUserInvitation
import javax.inject.Inject
import me.proton.core.drive.i18n.R as I18N
import me.proton.core.presentation.R as CorePresentation
@HiltViewModel
class UserInvitationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@ApplicationContext private val appContext: Context,
private val acceptUserInvitation: AcceptUserInvitation,
private val rejectUserInvitation: RejectUserInvitation,
getDecryptedUserInvitationsFlow: GetDecryptedUserInvitationsFlow,
private val broadcastMessages: BroadcastMessages,
private val configurationProvider: ConfigurationProvider,
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
private val emptyState = ListContentState.Empty(
imageResId = getThemeDrawableId(
light = R.drawable.empty_shared_with_me_light,
dark = R.drawable.empty_shared_with_me_dark,
dayNight = R.drawable.empty_shared_with_me_daynight,
),
titleId = I18N.string.shared_user_invitations_title_empty,
descriptionResId = I18N.string.shared_user_invitations_description_empty
)
private val listContentState: MutableStateFlow<ListContentState> =
MutableStateFlow(ListContentState.Loading)
val userInvitations: StateFlow<List<UserInvitation>?> =
getDecryptedUserInvitationsFlow(userId).transform { result ->
when (result) {
is DataResult.Processing -> listContentState.value = ListContentState.Loading
is DataResult.Error -> {
result.log(VIEW_MODEL)
listContentState.value = ListContentState.Error(result.logDefaultMessage(
context = appContext,
useExceptionMessage = configurationProvider.useExceptionMessage,
tag = VIEW_MODEL,
))
}
is DataResult.Success -> {
val invitations = result.value
listContentState.value = if (invitations.isEmpty()) {
emptyState
} else {
ListContentState.Content()
}
emit(invitations)
}
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val initialViewState = UserInvitationViewState(
title = appContext.getString(I18N.string.shared_user_invitations_title).format(0),
navigationIconResId = CorePresentation.drawable.ic_proton_arrow_back,
listContentState = listContentState.value,
)
val viewState: Flow<UserInvitationViewState> = combine(
listContentState,
userInvitations,
) { state, userInvitations ->
initialViewState.copy(
title = appContext.getString(I18N.string.shared_user_invitations_title)
.format(userInvitations.orEmpty().size),
listContentState = state,
)
}
fun viewEvent(onBack: () -> Unit): UserInvitationViewEvent =
object : UserInvitationViewEvent {
override val onTopAppBarNavigation: () -> Unit = onBack
override val onAccept: (UserInvitationId) -> Unit = this@UserInvitationViewModel::onAccept
override val onDecline: (UserInvitationId) -> Unit = this@UserInvitationViewModel::onDecline
}
private fun onAccept(invitationId: UserInvitationId) {
viewModelScope.launch {
acceptUserInvitation(invitationId).filterSuccessOrError()
.last()
.onFailure { error ->
error.log(SHARING, "Cannot accept invitation: $invitationId")
broadcastMessages(
userId,
error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
BroadcastMessage.Type.ERROR
)
}.onSuccess {
broadcastMessages(
userId,
appContext.getString(I18N.string.shared_user_invitations_accept_success),
)
}
}
}
private fun onDecline(invitationId: UserInvitationId) {
viewModelScope.launch {
rejectUserInvitation(invitationId)
.filterSuccessOrError()
.last()
.onFailure { error ->
error.log(SHARING, "Cannot delete invitation: $invitationId")
broadcastMessages(
userId,
error.getDefaultMessage(
appContext,
configurationProvider.useExceptionMessage
),
BroadcastMessage.Type.ERROR
)
}.onSuccess {
broadcastMessages(
userId,
appContext.getString(I18N.string.shared_user_invitations_decline_success),
)
}
}
}
}
@@ -49,11 +49,11 @@ class WhatsNewViewModel @Inject constructor(
val viewState: Flow<WhatsNewViewState?> = flowOf {
when (key) {
WhatsNewKey.PROTON_DOCS -> WhatsNewViewState(
title = appContext.getString(I18N.string.whats_new_docs_title),
description = appContext.getString(I18N.string.whats_new_docs_description),
action = appContext.getString(I18N.string.whats_new_docs_action),
image = BasePresentation.drawable.img_whats_new_proton_docs
WhatsNewKey.PUBLIC_SHARING -> WhatsNewViewState(
title = appContext.getString(I18N.string.whats_new_public_sharing_title),
description = appContext.getString(I18N.string.whats_new_public_sharing_description),
action = appContext.getString(I18N.string.whats_new_public_sharing_action),
image = BasePresentation.drawable.img_whats_new_public_sharing,
)
}
}
@@ -25,14 +25,18 @@ import me.proton.android.drive.extension.getDefaultMessage
import me.proton.core.domain.arch.DataResult
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.base.data.extension.isRetryable
import me.proton.core.drive.base.domain.api.ProtonApiCode
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.getThemeDrawableId
import me.proton.core.drive.drivelink.domain.entity.DriveLink
import me.proton.core.drive.base.presentation.state.ListContentState
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
import me.proton.core.drive.share.domain.entity.Share
import me.proton.core.drive.share.domain.exception.ShareException
import me.proton.core.network.domain.isApiProtonError
import javax.inject.Inject
import me.proton.core.drive.base.presentation.R as BasePresentation
import me.proton.core.drive.i18n.R as I18N
class OnFilesDriveLinkError @Inject constructor(
@@ -68,10 +72,23 @@ class OnFilesDriveLinkError @Inject constructor(
)
}
private fun DataResult.Error.toListContentState() = ListContentState.Error(
message = errorMessage,
actionResId = if (this.isRetryable) I18N.string.common_retry else null
)
private fun DataResult.Error.toListContentState() = if(cause?.isApiProtonError(ProtonApiCode.PHOTO_MIGRATION) == true) {
ListContentState.Error(
message = errorMessage,
titleId = I18N.string.photos_error_backup_migration_title,
descriptionResId = I18N.string.photos_error_backup_migration_description,
imageResId = getThemeDrawableId(
light = BasePresentation.drawable.img_update_required_light,
dark = BasePresentation.drawable.img_update_required_dark,
dayNight = BasePresentation.drawable.img_update_required_daynight,
)
)
} else {
ListContentState.Error(
message = errorMessage,
actionResId = if (this.isRetryable) I18N.string.common_retry else null
)
}
private val DataResult.Error.broadcastMessage: String? get() =
when (val cause = this.cause) {
@@ -42,7 +42,7 @@ class ShouldShowWhatsNew @Inject constructor(
).first().on
) {
when {
canShow(WhatsNewKey.PROTON_DOCS) -> WhatsNewKey.PROTON_DOCS
canShow(WhatsNewKey.PUBLIC_SHARING) -> WhatsNewKey.PUBLIC_SHARING
else -> null
}
} else {
@@ -114,6 +114,9 @@ class BackupNotificationBuilder @Inject constructor(
Event.Backup.BackupState.PREPARING ->
appContext.getString(I18N.string.notification_content_text_backup_preparing)
Event.Backup.BackupState.FAILED_DUE_PHOTO_SHARE_MIGRATION ->
appContext.getString(I18N.string.notification_content_text_backup_preparing)
}
companion object {
+1 -1
View File
@@ -18,10 +18,10 @@
-->
<resources>
<string name="core_app_scheme" translatable="false">protondrive</string>
<bool name="core_feature_key_transparency_enabled">false</bool>
<bool name="core_feature_account_recovery_enabled">true</bool>
<bool name="core_feature_auth_signin_two_step_enabled">true</bool>
<string name="core_feature_auth_sso_redirect_scheme" translatable="false">protondrive</string>
<bool name="core_feature_event_manager_worker_repeat_internal_background_by_bucket">true</bool>
<bool name="core_feature_notifications_enabled">true</bool>
<!-- Show system Notification permission request dialog after login. -->
@@ -380,7 +380,7 @@ class FileOrFolderOptionsViewModelTest {
nodeKey = "",
nodePassphrase = "",
nodePassphraseSignature = "",
signatureAddress = "",
signatureEmail = "",
creationTime = TimestampS(0),
trashedTime = null,
shareUrlExpirationTime = null,
@@ -29,6 +29,7 @@ object FileFolderOptionsRobot : Robot {
private val moveToTrashButton get() = node.withText(I18N.string.files_send_to_trash_action)
private val restoreFromTrashButton get() = node.withText(I18N.string.files_restore_from_trash_action)
private val makeAvailableOfflineButton get() = node.withText(I18N.string.common_make_available_offline_action)
private val openInBrowserButton get() = node.withText(I18N.string.common_open_in_browser_action)
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)
@@ -91,6 +92,8 @@ object FileFolderOptionsRobot : Robot {
makeAvailableOfflineButton.scrollTo().click()
}
fun clickOpenInBrowserButton() = openInBrowserButton.scrollTo().clickTo(FilesTabRobot)
override fun robotDisplayed() {
fileFolderOptionsScreen.await { assertIsDisplayed() }
}
@@ -50,12 +50,15 @@ object PhotosTabRobot :
NavigationBarRobot {
private val enableBackupButton
get() = allNodes.withText(I18N.string.photos_permissions_action).onFirst()
private val enableErrorBackupButton
get() = node.withText(I18N.string.photos_error_backup_disabled_action)
private val backupCompleted get() = node.withText(I18N.string.photos_backup_state_completed)
private val backupPreparing get() = node.withText(I18N.string.photos_backup_state_preparing)
private val backupFailed get() = node.withText(I18N.string.photos_error_backup_failed)
private val noBackupsYet get() = node.withText(I18N.string.photos_empty_title)
private val missingFolder get() = node.withText(I18N.string.photos_error_backup_missing_folder)
private val updateRequired get() = node.withText(I18N.string.photos_error_backup_migration_title)
private val noConnectivityBanner get() = node.withText(I18N.string.photos_error_waiting_wifi_connectivity)
private val getMoreStorageButton get() = node.withText(I18N.string.storage_quotas_get_storage_action)
private val storageFullTitle get() =
@@ -80,6 +83,7 @@ object PhotosTabRobot :
.withLayoutType(LayoutType.Grid)
fun enableBackup() = enableBackupButton.clickTo(PhotosTabRobot)
fun enableBackupWhenDisabled() = enableErrorBackupButton.clickTo(PhotosTabRobot)
fun clickOnMore() = missingFolder.clickTo(PhotosBackupRobot)
fun clickGetMoreStorage() = SubscriptionRobot.apply { getMoreStorageButton.click() }
@@ -109,6 +113,14 @@ object PhotosTabRobot :
missingFolder.await { assertIsDisplayed() }
}
fun assertMigrationInProgressDisplayed() {
assertUpdateRequired()
}
fun assertUpdateRequired() {
updateRequired.await { assertIsDisplayed() }
}
fun assertPhotoCountEquals(count: Int) {
allNodes
.withItemType(ItemType.File)
@@ -43,6 +43,7 @@ object PreviewRobot : NavigationBarRobot {
private val preview get() = allNodes.withTag(ImagePreviewComponentTestTag.image).onFirst()
private val pager get() = node.withTag(PreviewComponentTestTag.pager)
private val mediaPreview get() = node.withTag(PreviewComponentTestTag.mediaPreview)
private val openInBrowserButton get() = node.withText(I18N.string.common_open_in_browser_action)
fun clickOnContextualButton() = contextualButton.clickTo(FileFolderOptionsRobot)
@@ -72,6 +73,8 @@ object PreviewRobot : NavigationBarRobot {
pager.scrollTo(index)
}
fun clickOpenInBrowserButton() = openInBrowserButton.scrollTo().clickTo(FilesTabRobot)
override fun robotDisplayed() {
previewScreen.await { assertIsDisplayed() }
}
@@ -32,7 +32,6 @@ object ShareUserRobot : NavigationBarRobot, Robot {
.hasAncestor(node.withTag(SharedDriveInvitationsTestTags.emailField))
private val sendButton get() = node.withContentDescription(I18N.string.common_send_action)
private val viewerPermissionButton get() = node.withText(I18N.string.common_permission_viewer)
private val sendMessageAndNameLabel get() = node.withText(I18N.string.share_via_invitations_send_message_and_name_label)
private val messageTextField get() = node.isSetText()
.hasAncestor(node.withTag(SharedDriveInvitationsTestTags.messageField))
private val editorPermissionButton get() = node.withText(I18N.string.common_permission_editor)
@@ -45,8 +44,6 @@ object ShareUserRobot : NavigationBarRobot, Robot {
fun clickOnViewerPermission() = viewerPermissionButton.clickTo(this)
fun clickOnSendMessageAndName() = sendMessageAndNameLabel.scrollTo().clickTo(this)
fun typeMessage(text: String) = apply {
messageTextField.typeText(text)
}
@@ -19,12 +19,21 @@
package me.proton.android.drive.ui.robot
import me.proton.android.drive.ui.screen.SharedWithMeTestTag
import me.proton.core.test.android.instrumented.utils.StringUtils
import me.proton.test.fusion.Fusion.node
import me.proton.core.drive.i18n.R as I18N
object SharedWithMeRobot : LinksRobot {
override val filesContent get() = node.withTag(SharedWithMeTestTag.content)
fun clickUserInvitation(count: Int) = node.withText(
StringUtils.pluralStringFromResource(
I18N.plurals.shared_by_me_invitation_banner_description,
count,
count
)
).clickTo(UserInvitationRobot)
fun assertEmpty() {
node.withText(I18N.string.shared_with_me_empty_title).await { assertIsDisplayed() }
node.withText(I18N.string.shared_with_me_empty_description).await { assertIsDisplayed() }
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.robot
import me.proton.android.drive.ui.screen.UserInvitationTestFlag
import me.proton.core.drive.drivelink.shared.presentation.component.UserInvitationContentTestFlag
import me.proton.test.fusion.Fusion.node
import me.proton.core.drive.i18n.R as I18N
object UserInvitationRobot : Robot, NavigationBarRobot {
private val content get() = node.withTag(UserInvitationTestFlag.content)
fun clickAccept(name: String) = node.withText(
I18N.string.shared_user_invitations_accept_button
).hasAncestor(node
.withTag(UserInvitationContentTestFlag.item)
.hasDescendant(node.withText(name))
).clickTo(UserInvitationRobot)
fun clickDecline(name: String) = node.withText(
I18N.string.shared_user_invitations_decline_button
).hasAncestor(node
.withTag(UserInvitationContentTestFlag.item)
.hasDescendant(node.withText(name))
).clickTo(UserInvitationRobot)
fun assertAcceptSucceed() = node.withText(
I18N.string.shared_user_invitations_accept_success
).await { assertIsDisplayed() }
fun assertDeclineSucceed() = node.withText(
I18N.string.shared_user_invitations_decline_success
).await { assertIsDisplayed() }
fun assertEmpty() {
node.withText(I18N.string.shared_user_invitations_title_empty).await { assertIsDisplayed() }
node.withText(I18N.string.shared_user_invitations_description_empty).await { assertIsDisplayed() }
}
override fun robotDisplayed() {
content.await { assertIsDisplayed() }
}
}
@@ -24,7 +24,7 @@ import kotlin.time.Duration.Companion.seconds
import me.proton.core.drive.i18n.R as I18N
object WhatsNewRobot : Robot {
private val gotItButton get() = node.withText(I18N.string.whats_new_docs_action)
private val gotItButton get() = node.withText(I18N.string.whats_new_public_sharing_action)
private val whatsNewScreen get() = node.withTag(WhatsNewTestTag.screen)
fun <T : Robot> clickGotIt(goesTo: T) = gotItButton.clickTo(goesTo)
@@ -24,13 +24,18 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import me.proton.android.drive.log.DriveLogTag
import me.proton.android.drive.log.DriveLogTag.UI_TEST
import me.proton.core.drive.backup.domain.manager.BackupConnectivityManager
import me.proton.core.drive.backup.domain.manager.BackupConnectivityManager.Connectivity.NONE
import me.proton.core.drive.backup.domain.manager.BackupConnectivityManager.Connectivity.UNMETERED
import me.proton.core.drive.base.domain.api.ProtonApiCode.PHOTO_MIGRATION
import me.proton.core.network.data.ProtonErrorException
import me.proton.core.network.domain.ApiResult
import me.proton.core.util.kotlin.CoreLogger
import me.proton.test.fusion.FusionConfig
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import org.junit.rules.ExternalResource
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -46,7 +51,7 @@ object NetworkSimulator: ExternalResource() {
get() = Interceptor {
if (responseDelay > 0.milliseconds) {
CoreLogger.d(
DriveLogTag.UI_TEST,
UI_TEST,
"Delaying response by ${responseDelay.inWholeMilliseconds}ms"
)
runBlocking {
@@ -63,9 +68,39 @@ object NetworkSimulator: ExternalResource() {
it.proceed(it.request())
}
private val interceptors = mutableListOf(testNetworkInterceptor)
private val photosMigrationInterceptor
get() = Interceptor { chain ->
if (enabledPhotosMigration) {
if (chain.request().url.toString().contains("photo")) {
CoreLogger.d(UI_TEST, "Throw migration error for photo request")
throw ProtonErrorException(
response = Response.Builder()
.code(422)
.message("Migration in progress")
.request(chain.request())
.protocol(chain.connection()?.protocol() ?: okhttp3.Protocol.HTTP_1_1)
.build(),
protonData = ApiResult.Error.ProtonData(
code = PHOTO_MIGRATION,
error = "Migration in progress",
)
)
} else {
chain.proceed(chain.request())
}
} else {
chain.proceed(chain.request())
}
}
private val interceptors = mutableListOf(
testNetworkInterceptor,
photosMigrationInterceptor,
)
private var isNetworkTimeout: Boolean = false
private var responseDelay: Duration = 0.milliseconds
private var enabledPhotosMigration: Boolean = false
val client: OkHttpClient = OkHttpClient
.Builder()
@@ -102,6 +137,10 @@ object NetworkSimulator: ExternalResource() {
NetworkSimulator.isNetworkTimeout = isNetworkTimeout
}
fun enabledPhotosMigration() {
enabledPhotosMigration = true
}
private fun setConnectivity(connectivity: BackupConnectivityManager.Connectivity) = apply {
_connectivity.value = connectivity
}
@@ -0,0 +1,132 @@
/*
* 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.document
import android.app.Instrumentation
import android.content.Intent
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
import androidx.test.espresso.intent.matcher.UriMatchers.hasHost
import androidx.test.espresso.intent.matcher.UriMatchers.hasParamWithName
import androidx.test.espresso.intent.matcher.UriMatchers.hasPath
import androidx.test.espresso.intent.matcher.UriMatchers.hasScheme
import androidx.test.espresso.intent.rule.IntentsRule
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.robot.FilesTabRobot
import me.proton.android.drive.ui.robot.MoveToFolderRobot
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.core.drive.files.presentation.extension.SemanticsDownloadState.Downloaded
import me.proton.core.test.rule.annotation.PrepareUser
import org.hamcrest.Matchers.allOf
import org.junit.Test
@HiltAndroidTest
class DocumentFlowTest : BaseTest() {
private val expectedIntent = allOf(
hasAction(Intent.ACTION_VIEW),
hasData(
allOf(
hasScheme("https"),
hasHost("docs.${envConfig.host}"),
hasPath("/doc"),
hasParamWithName("volumeId"),
hasParamWithName("linkId"),
hasParamWithName("email"),
)
),
)
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 8)
fun openDocumentFromFiles() {
val file = "doc9.protondoc"
PhotosTabRobot
.clickFilesTab()
.scrollToItemWithName(file)
.clickOnFile(file)
.verify {
nodeWithTextDisplayed(file)
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 8)
fun openDocumentFromOffline() {
val file = "doc1.protondoc"
val folder = "folder1"
PhotosTabRobot
.clickFilesTab()
.clickMoreOnItem(folder)
.clickMakeAvailableOffline(FilesTabRobot)
.verify {
itemIsDisplayed(folder, downloadState = Downloaded)
}
.openSidebarBySwipe()
.clickOffline()
.clickOnFolder(folder)
.clickOnFile(file)
.verify {
nodeWithTextDisplayed(file)
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 8)
fun previewDocumentFromFiles() {
intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null));
val file = "doc9.protondoc"
PhotosTabRobot
.clickFilesTab()
.scrollToItemWithName(file)
.clickOnFile(file)
.clickOnContextualButton()
.clickOpenInBrowserButton()
.verify {
intended(expectedIntent)
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 8)
fun openDocumentFromMenu() {
intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null));
val file = "doc9.protondoc"
PhotosTabRobot
.clickFilesTab()
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickOpenInBrowserButton()
.verify {
intended(expectedIntent)
}
}
}
@@ -36,7 +36,6 @@ class WhatsNewFlowTest : BaseTest() {
@Test
@FeatureFlag(DRIVE_ANDROID_WHATS_NEW, ENABLED)
@PrepareUser(loginBefore = true)
@Ignore("PROTON_DOCS was limited to 2024, enable the test for a new whats new")
fun whatsNewShouldShow() {
WhatsNewRobot
.verify {
@@ -0,0 +1,140 @@
/*
* 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.photos
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import dagger.hilt.components.SingletonComponent
import me.proton.android.drive.extension.debug
import me.proton.android.drive.photos.data.di.PhotosConfigurationModule
import me.proton.android.drive.photos.domain.provider.PhotosDefaultConfigurationProvider
import me.proton.android.drive.provider.PhotosConnectedDefaultConfigurationProvider
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.data.ImageName
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.robot.SettingsRobot
import me.proton.android.drive.ui.robot.settings.PhotosBackupRobot
import me.proton.android.drive.ui.rules.NetworkSimulator
import me.proton.android.drive.ui.test.PhotosBaseTest
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.test.rule.annotation.PrepareUser
import org.junit.Before
import org.junit.Test
import javax.inject.Inject
import javax.inject.Singleton
@HiltAndroidTest
@UninstallModules(PhotosConfigurationModule::class)
class PhotosMigrationFlowTest : PhotosBaseTest() {
@Inject
lateinit var configurationProvider: ConfigurationProvider
@Before
fun setUp() {
configurationProvider.debug.photosUpsellPhotoCount = Int.MAX_VALUE
dcimCameraFolder.copyFileFromAssets("boat.jpg")
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2)
fun creatingShareShouldFailed() {
NetworkSimulator.enabledPhotosMigration()
PhotosTabRobot
.verify { assertUpdateRequired() }
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2, isPhotos = true)
fun loadingPhotosListingShouldFailed() {
NetworkSimulator.enabledPhotosMigration()
PhotosTabRobot
.verify { nodeWithTextDisplayed("Migration in progress") }
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2, isPhotos = true)
fun startingBackupOnCreatedShareShouldFailed() {
PhotosTabRobot.verify {
assertPhotoDisplayed(ImageName.Main.fileName)
}
NetworkSimulator.enabledPhotosMigration()
PhotosTabRobot
.enableBackupWhenDisabled()
.verify { assertUpdateRequired() }
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2)
fun startingBackupFromSettingShouldFailed() {
NetworkSimulator.enabledPhotosMigration()
PhotosTabRobot
.openSidebarBySwipe()
.clickSettings()
.clickPhotosBackup()
.clickBackupToggle(PhotosBackupRobot)
.verify { nodeWithTextDisplayed("Migration in progress") }
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2, isPhotos = true)
fun startingBackupOnCreatedShareFromSettingShouldFailed() {
PhotosTabRobot.verify {
assertPhotoDisplayed(ImageName.Main.fileName)
}
NetworkSimulator.enabledPhotosMigration()
PhotosTabRobot
.openSidebarBySwipe()
.clickSettings()
.clickPhotosBackup()
.clickBackupToggle(PhotosBackupRobot)
.verify {
assertPhotosBackupTurnOn()
}
.clickBack(SettingsRobot)
.clickBack(PhotosTabRobot)
.verify { assertMigrationInProgressDisplayed() }
}
@Module
@InstallIn(SingletonComponent::class)
@Suppress("Unused")
interface TestPhotosConfigurationModule {
@Binds
@Singleton
fun bindPhotosDefaultConfigurationProvider(
impl: PhotosConnectedDefaultConfigurationProvider,
): PhotosDefaultConfigurationProvider
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.test.flow.share.link
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.extension.tomorrow
import me.proton.android.drive.ui.robot.FilesTabRobot
import me.proton.android.drive.ui.robot.ManageAccessRobot
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.core.test.rule.annotation.PrepareUser
import org.junit.Test
@HiltAndroidTest
class ChangeExpirationDateManageAccessTest : BaseTest() {
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 4)
fun setExpirationDate() {
val fileName = "image.jpg"
PhotosTabRobot
.clickFilesTab()
.scrollToItemWithName(fileName)
.clickMoreOnItem(fileName)
.clickManageAccess()
.clickAllowToAnyone()
.clickSettings()
.clickExpirationDateTextField()
.verify { robotDisplayed() }
.selectDate(tomorrow)
.clickOk()
.clickSave()
.clickUpdateSuccessfulGrowler()
.clickBack(ManageAccessRobot)
.clickBack(FilesTabRobot)
.verify { robotDisplayed() }
.scrollToItemWithName(fileName)
.clickMoreOnItem(fileName)
.clickManageAccess()
.clickSettings()
.verify {
robotDisplayed()
expirationDateToggleIsOn()
expirationDateIsShown(tomorrow)
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 4)
fun changeExpirationDateOfExpiredLink() {
val fileName = "expiredSharedFile.jpg"
PhotosTabRobot
.clickFilesTab()
.scrollToItemWithName(fileName)
.clickMoreOnItem(fileName)
.clickManageAccess()
.clickSettings()
.clickExpirationDateTextField()
.verify { robotDisplayed() }
.selectDate(tomorrow)
.clickOk()
.clickSave()
.clickUpdateSuccessfulGrowler()
.clickBack(ManageAccessRobot)
.clickBack(FilesTabRobot)
.verify { robotDisplayed() }
.scrollToItemWithName(fileName)
.clickMoreOnItem(fileName)
.clickManageAccess()
.clickSettings()
.verify {
robotDisplayed()
expirationDateToggleIsOn()
expirationDateIsShown(tomorrow)
}
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2023-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.test.flow.share.link
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.robot.ConfirmStopSharingRobot
import me.proton.android.drive.ui.robot.FilesTabRobot
import me.proton.android.drive.ui.robot.ManageAccessRobot
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.core.test.rule.annotation.PrepareUser
import org.junit.Test
import org.junit.runner.RunWith
import me.proton.core.drive.i18n.R as I18N
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class DeleteSharedLinkManageAccessFlowTest : BaseTest() {
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 4)
fun stopSharingActiveLinkViaManageAccess() {
val file = "shared.jpg"
PhotosTabRobot
.clickFilesTab()
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickManageAccess()
.clickAllowToAnyone(ConfirmStopSharingRobot)
.confirmStopSharing(ManageAccessRobot)
.verify {
assertLinkIsNotShareWithAnyonePublic()
nodeWithTextDisplayed(I18N.string.description_files_stop_sharing_action_success)
robotDisplayed()
}
.clickBack(FilesTabRobot)
.verify { itemIsDisplayed(file, isSharedByLink = false) }
}
}
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2023-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.test.flow.share.link
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.robot.FilesTabRobot
import me.proton.android.drive.ui.robot.ManageAccessRobot
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.robot.ShareRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.core.test.rule.annotation.PrepareUser
import org.junit.Test
@HiltAndroidTest
class SetCustomPasswordManageAccessFlowTest : BaseTest() {
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 4)
fun deleteCustomPassword() {
val file = FOLDER_SHARED_WITH_PASSWORD
PhotosTabRobot
.clickFilesTab()
.verify { robotDisplayed() }
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickManageAccess()
.clickSettings()
.verify { robotDisplayed() }
.clickPasswordToggle()
.clickSave()
.clickUpdateSuccessfulGrowler()
.clickBack(ManageAccessRobot)
.clickBack(FilesTabRobot)
.verify { robotDisplayed() }
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickManageAccess()
.clickSettings()
.verify {
robotDisplayed()
passwordToggleIsOff()
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 4)
fun setCustomPassword() {
val file = FILE_SHARED
PhotosTabRobot
.clickFilesTab()
.verify { robotDisplayed() }
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickManageAccess()
.clickSettings()
.verify { robotDisplayed() }
.typePassword(FILE_SHARE_PASSWORD)
.clickPasswordToggle()
.clickSave()
.clickUpdateSuccessfulGrowler()
.clickBack(ManageAccessRobot)
.clickBack(FilesTabRobot)
.verify { robotDisplayed() }
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickManageAccess()
.verify { assertLinkIsShareWithAnyonePasswordProtected() }
.clickSettings()
.verify {
robotDisplayed()
passwordToggleIsOn()
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 4)
fun discardPasswordChanges() {
val file = FILE
PhotosTabRobot
.clickFilesTab()
.verify { robotDisplayed() }
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickManageAccess()
.clickAllowToAnyone()
.clickSettings()
.verify { robotDisplayed() }
.typePassword(FILE_SHARE_PASSWORD)
.clickPasswordToggle()
.clickBack(ShareRobot.DiscardChanges)
.verify { robotDisplayed() }
.clickDiscard(ManageAccessRobot)
.clickBack(FilesTabRobot)
.verify { robotDisplayed() }
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickManageAccess()
.clickSettings()
.verify {
robotDisplayed()
passwordToggleIsOff()
}
}
companion object {
private const val FILE_SHARED = "shared.jpg"
private const val FOLDER_SHARED_WITH_PASSWORD = "sharedFolderWithPassword"
private const val FILE = "image.jpg"
private const val FILE_SHARE_PASSWORD = "1234"
}
}
@@ -0,0 +1,111 @@
/*
* 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.share.link
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.FeatureFlag
import me.proton.android.drive.ui.annotation.FeatureFlags
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.core.drive.base.domain.entity.Permissions
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_DYNAMIC_ENTITLEMENT_CONFIGURATION
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_PUBLIC_SHARE_EDIT_MODE
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_PUBLIC_SHARE_EDIT_MODE_DISABLED
import me.proton.core.test.quark.data.Plan
import me.proton.core.test.rule.annotation.PrepareUser
import me.proton.core.test.rule.annotation.payments.TestSubscriptionData
import org.junit.Test
@HiltAndroidTest
class SharingManageAccessFlowTest : BaseTest() {
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2)
fun shareFile() {
val file = "image.jpg"
PhotosTabRobot
.clickFilesTab()
.clickMoreOnItem(file)
.clickManageAccess()
.clickAllowToAnyone()
.verify { assertLinkIsShareWithAnyonePublic() }
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2, isPhotos = true)
fun sharePhoto() {
val image = ImageName.Main
PhotosTabRobot.waitUntilLoaded()
PhotosTabRobot
.longClickOnPhoto(image.fileName)
.clickOptions()
.clickManageAccess()
.clickAllowToAnyone()
.verify { assertLinkIsShareWithAnyonePublic() }
}
@Test
@FeatureFlags(
[
FeatureFlag(DRIVE_PUBLIC_SHARE_EDIT_MODE, ENABLED),
FeatureFlag(DRIVE_DYNAMIC_ENTITLEMENT_CONFIGURATION, ENABLED)
]
)
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2)
fun shareFolderAsEditor() {
val folder = "folder1"
PhotosTabRobot
.clickFilesTab()
.clickMoreOnItem(folder)
.clickManageAccess()
.clickAllowToAnyone()
.verify { assertLinkIsShareWithAnyonePublic(Permissions.viewer) }
.clickViewerPermissions()
.clickEditor()
.verify { assertLinkIsShareWithAnyonePublic(Permissions.editor) }
}
@Test
@FeatureFlags(
[
FeatureFlag(DRIVE_PUBLIC_SHARE_EDIT_MODE, ENABLED),
FeatureFlag(DRIVE_PUBLIC_SHARE_EDIT_MODE_DISABLED, ENABLED)
]
)
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2)
fun shareFolderAsEditorKillSwitch() {
val folder = "folder1"
PhotosTabRobot
.clickFilesTab()
.clickMoreOnItem(folder)
.clickManageAccess()
.clickAllowToAnyone()
.verify {
assertLinkIsShareWithAnyonePublic(Permissions.viewer)
assertViewerPermissionsIsNotClickable()
}
}
}
@@ -0,0 +1,218 @@
/*
* Copyright (c) 2024-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.test.flow.share.list
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.robot.ConfirmStopSharingRobot
import me.proton.android.drive.ui.robot.FilesTabRobot
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.robot.PreviewRobot
import me.proton.android.drive.ui.robot.SharedByMeRobot
import me.proton.android.drive.ui.robot.SharedTabRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.android.drive.ui.test.flow.details.DetailsFlowTest.LinkDetails
import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.core.drive.base.domain.extension.bytes
import me.proton.core.drive.base.presentation.extension.asHumanReadableString
import me.proton.core.drive.files.presentation.extension.SemanticsDownloadState
import me.proton.core.test.rule.annotation.PrepareUser
import me.proton.test.fusion.FusionConfig
import org.junit.Test
import me.proton.core.drive.i18n.R as I18N
@HiltAndroidTest
class SharedByMeTest : BaseTest() {
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2)
fun emptySharedByMe() {
PhotosTabRobot
.navigateToSharedByMeTab()
.verify {
assertEmpty()
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 6)
fun previewTextFile() {
val file = "newShare.txt"
PhotosTabRobot
.navigateToSharedByMeTab()
.scrollToItemWithName(file)
.clickOnFile(file)
.verify {
PreviewRobot.nodeWithTextDisplayed("Hello World!")
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 6)
fun renameFile() {
val itemToBeRenamed = "newShare.txt"
val newItemName = "renamedShare.txt"
PhotosTabRobot
.navigateToSharedByMeTab()
.scrollToItemWithName(itemToBeRenamed)
.clickMoreOnItem(itemToBeRenamed)
.clickRename()
.clearName()
.typeName(newItemName)
.clickRename()
.scrollToItemWithName(newItemName)
.verify {
itemIsDisplayed(newItemName)
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 6)
fun moveFileToTrashAndRestoreIt() {
val file = "newShare.txt"
PhotosTabRobot
.navigateToSharedByMeTab()
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickMoveToTrash()
.dismissMoveToTrashSuccessGrowler(1, FilesTabRobot)
.verify {
//itemIsNotDisplayed(file)//TODO: uncomment this once fixed on BE
}
.openSidebarBySwipe()
.clickTrash()
.verify {
itemIsDisplayed(file)
}
.clickMoreOnItem(file)
.clickRestoreTrash()
.clickBack(SharedTabRobot)
.clickSharedByMeTab()
.verify {
itemIsDisplayed(file)
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 6)
fun makeAvailableOffline() {
val file = "newShare.txt"
PhotosTabRobot
.navigateToSharedByMeTab()
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickMakeAvailableOffline()
.verify {
itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded)
}
.openSidebarBySwipe()
.clickOffline()
.verify {
itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded)
}
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 6)
fun fileDetails() {
PhotosTabRobot
.navigateToSharedByMeTab()
.scrollToItemWithName(newShareText.name)
.clickMoreOnItem(newShareText.name)
.clickFileDetails()
.verify {
robotDisplayed()
hasHeaderTitle(newShareText.name)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_name_entry),
value = newShareText.name,
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_uploaded_by_entry),
value = newShareText.uploadedBy,
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_location_entry),
value = requireNotNull(newShareText.location),
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_last_modified_entry),
value = newShareText.modified,
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_mime_type_entry),
value = requireNotNull(newShareText.mimeType),
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_size_entry),
value = requireNotNull(newShareText.size),
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_shared_entry),
value = newShareText.isShared,
)
}
.clickBack(SharedTabRobot)
.verify { robotDisplayed() }
}
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 6)
fun stopSharing() {
val file = "newShare.txt"
PhotosTabRobot
.navigateToSharedByMeTab()
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickManageAccess()
.clickStopSharing(ConfirmStopSharingRobot)
.confirmStopSharing(SharedTabRobot)
.pullToRefresh(SharedTabRobot)
.verify {
itemIsNotDisplayed(file)
}
}
private val newShareText
get() = LinkDetails(
name = "newShare.txt",
uploadedBy = "${protonRule.testDataRule.mainTestUser!!.name}@${envConfig.host}",
location = "/" + FusionConfig.targetContext.getString(I18N.string.title_my_files),
modified = TimestampS().asHumanReadableString(),
isShared = FusionConfig.targetContext.getString(I18N.string.common_yes),
mimeType = "text/plain",
size = 63.bytes.asHumanReadableString(FusionConfig.targetContext),
)
private fun PhotosTabRobot.navigateToSharedByMeTab(): SharedByMeRobot =
this
.verify {
robotDisplayed()
}
.clickSharedTab()
.clickSharedByMeTab()
}
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2024-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.test.flow.share.list
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.robot.SharedTabRobot
import me.proton.android.drive.ui.robot.SharedWithMeRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.android.drive.ui.test.flow.details.DetailsFlowTest.LinkDetails
import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.core.drive.base.domain.extension.bytes
import me.proton.core.drive.base.presentation.extension.asHumanReadableString
import me.proton.core.drive.files.presentation.extension.SemanticsDownloadState
import me.proton.core.test.rule.annotation.PrepareUser
import me.proton.test.fusion.FusionConfig
import org.junit.Test
import me.proton.core.drive.i18n.R as I18N
@HiltAndroidTest
class SharedWithMeTest : BaseTest() {
@Test
@PrepareUser(loginBefore = true)
@Scenario(forTag = "main", value = 2)
fun emptyList() {
PhotosTabRobot
.navigateToSharedWithMeTab()
.verify {
assertEmpty()
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
fun browseSharedFolder() {
val folder = "ReadWriteFolder"
val fileInFolder = "EditableFile.txt"
PhotosTabRobot
.navigateToSharedWithMeTab()
.scrollToItemWithName(folder)
.clickOnFolder(folder)
.verify {
nodeWithTextDisplayed(fileInFolder)
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
fun previewTextFile() {
val file = "newShareInsideLegacy.txt"
PhotosTabRobot
.navigateToSharedWithMeTab()
.scrollToItemWithName(file)
.clickOnFile(file)
.verify {
nodeWithTextDisplayed("Hello World!")
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
fun makeAvailableOffline() {
val file = "newShareInsideLegacy.txt"
PhotosTabRobot
.navigateToSharedWithMeTab()
.scrollToItemWithName(file)
.clickMoreOnItem(file)
.clickMakeAvailableOffline()
.verify {
itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded)
}
.openSidebarBySwipe()
.clickOffline()
.verify {
itemIsDisplayed(file, downloadState = SemanticsDownloadState.Downloaded)
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
fun fileDetails() {
val sharingUser = protonRule.testDataRule.preparedUsers["sharingUser"]!!
PhotosTabRobot
.navigateToSharedWithMeTab()
.scrollToItemWithName(newShareInsideLegacy(sharingUser.name).name)
.clickMoreOnItem(newShareInsideLegacy(sharingUser.name).name)
.clickFileDetails()
.verify {
robotDisplayed()
hasHeaderTitle(newShareInsideLegacy(sharingUser.name).name)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_name_entry),
value = newShareInsideLegacy(sharingUser.name).name,
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_uploaded_by_entry),
value = newShareInsideLegacy(sharingUser.name).uploadedBy,
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_last_modified_entry),
value = newShareInsideLegacy(sharingUser.name).modified,
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_size_entry),
value = requireNotNull(newShareInsideLegacy(sharingUser.name).size),
)
hasInfoItem(
name = FusionConfig.targetContext.getString(I18N.string.file_info_shared_entry),
value = newShareInsideLegacy(sharingUser.name).isShared,
)
}
.clickBack(SharedTabRobot)
.verify { robotDisplayed() }
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
fun leaveShare() {
val newShare = "newShareInsideLegacy.txt"
PhotosTabRobot
.navigateToSharedWithMeTab()
.scrollToItemWithName(newShare)
.clickMoreOnItem(newShare)
.clickRemoveMe(SharedWithMeRobot)
.verify {
itemIsNotDisplayed(newShare)
}
}
private fun newShareInsideLegacy(name: String) = LinkDetails(
name = "newShareInsideLegacy.txt",
uploadedBy = "$name@${envConfig.host}",
modified = TimestampS().asHumanReadableString(),
isShared = FusionConfig.targetContext.getString(I18N.string.common_yes),
mimeType = "text/plain",
size = 63.bytes.asHumanReadableString(FusionConfig.targetContext),
)
private fun PhotosTabRobot.navigateToSharedWithMeTab(): SharedWithMeRobot =
this
.verify {
robotDisplayed()
}
.clickSharedTab()
.clickSharedWithMeTab()
}
@@ -61,7 +61,6 @@ class SharingUserFlowTest : BaseTest() {
.typeEmail(email)
.clickOnEditorPermission()
.clickOnViewerPermission()
.clickOnSendMessageAndName()
.typeMessage("Hello! You can view this file")
.clickSend()
.verify {
@@ -212,7 +211,6 @@ class SharingUserFlowTest : BaseTest() {
assertShareFile(image)
}
.typeEmail(email)
.clickOnSendMessageAndName()
.typeMessage("Hello! I hope you will receive this invite")
.clickSend()
.verify {
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2024 Proton AG.
* This file is part of Proton Drive.
*
* Proton Drive is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Drive is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Drive. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.android.drive.ui.test.flow.share.user
import androidx.test.core.app.ActivityScenario
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.android.drive.ui.MainActivity
import me.proton.android.drive.ui.annotation.FeatureFlag
import me.proton.android.drive.ui.annotation.Scenario
import me.proton.android.drive.ui.robot.PhotosTabRobot
import me.proton.android.drive.ui.robot.SharedWithMeRobot
import me.proton.android.drive.ui.test.BaseTest
import me.proton.android.drive.utils.getRandomString
import me.proton.android.drive.utils.replaceEmailPrefix
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_MOBILE_SHARING_INVITATIONS_ACCEPT_REJECT
import me.proton.core.test.quark.v2.command.populate
import me.proton.core.test.quark.v2.command.userCreateAddress
import me.proton.core.test.quark.v2.command.volumeCreate
import me.proton.core.test.rule.annotation.PrepareUser
import me.proton.core.test.rule.annotation.mapToUser
import org.junit.Test
@HiltAndroidTest
class UserInvitationIdFlowTest : BaseTest() {
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
@FeatureFlag(DRIVE_MOBILE_SHARING_INVITATIONS_ACCEPT_REJECT, ENABLED)
fun acceptUserInvitationFileAndPreview() {
val name = "newShare.txt"
PhotosTabRobot
.navigateToSharedWithMeTab()
.clickUserInvitation(2)
.clickAccept(name)
.verify {
assertAcceptSucceed()
}
.clickBack(SharedWithMeRobot)
.clickOnFile(name)
.verify {
nodeWithTextDisplayed("Hello World!")
}
}
@Test
@PrepareUser(withTag = "main")
@PrepareUser(withTag = "sharingUser")
@FeatureFlag(DRIVE_MOBILE_SHARING_INVITATIONS_ACCEPT_REJECT, ENABLED)
fun acceptUserInvitationWithMultipleAddress() {
val user = protonRule.testDataRule.mainTestUser!!
val primaryName = getRandomString(10)
val primaryEmail = user.email.replaceEmailPrefix(primaryName)
// Create another address before login
protonRule.testDataRule.quarkCommand.userCreateAddress(
decryptedUserId = user.decryptedUserId,
password = user.password,
email = primaryEmail,
isPrimary = true
)
val sharingUser = requireNotNull(protonRule.testDataRule.preparedUsers["sharingUser"])
quarkRule.quarkCommands.populate(sharingUser.mapToUser(), scenario = 6, sharingUser = user.mapToUser())
loginTestHelper.login(user.name, user.password)
ActivityScenario.launch(MainActivity::class.java)
val name = "newShare.txt"
PhotosTabRobot
.navigateToSharedWithMeTab()
.clickUserInvitation(2)
.clickAccept(name)
.verify {
assertAcceptSucceed()
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
@FeatureFlag(DRIVE_MOBILE_SHARING_INVITATIONS_ACCEPT_REJECT, ENABLED)
fun acceptUserInvitationFolderAndOpen() {
val name = "newShare"
PhotosTabRobot
.navigateToSharedWithMeTab()
.clickUserInvitation(2)
.clickAccept(name)
.verify {
assertAcceptSucceed()
}
.clickBack(SharedWithMeRobot)
.clickOnFolder(name)
.verify {
itemIsDisplayed("legacyShareInsideNew.txt")
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
@FeatureFlag(DRIVE_MOBILE_SHARING_INVITATIONS_ACCEPT_REJECT, ENABLED)
fun declineUserInvitation() {
PhotosTabRobot
.navigateToSharedWithMeTab()
.clickUserInvitation(2)
.clickDecline("newShare")
.verify {
assertDeclineSucceed()
}
.clickBack(SharedWithMeRobot)
.verify {
itemIsNotDisplayed("newShare")
}
}
@Test
@PrepareUser(withTag = "main", loginBefore = true)
@PrepareUser(withTag = "sharingUser")
@Scenario(forTag = "sharingUser", value = 6, sharedWithUserTag = "main")
@FeatureFlag(DRIVE_MOBILE_SHARING_INVITATIONS_ACCEPT_REJECT, ENABLED)
fun emptyUserInvitation() {
PhotosTabRobot
.navigateToSharedWithMeTab()
.clickUserInvitation(2)
.clickDecline("newShare")
.verify {
assertDeclineSucceed()
}
.clickDecline("newShare.txt")
.verify {
assertDeclineSucceed()
assertEmpty()
}
}
private fun PhotosTabRobot.navigateToSharedWithMeTab(): SharedWithMeRobot =
this
.verify {
waitUntilLoaded()
robotDisplayed()
}
.clickSharedTab()
.clickSharedWithMeTab()
}
+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.14.0"
const val versionName = "2.15.0"
const val archivesBaseName = "ProtonDrive-$versionName"
val supportedResourceConfigurations = listOf(
"b+es+419",
@@ -90,6 +90,7 @@ sealed class Event {
UNCOMPLETED,
PAUSE_BACKGROUND_RESTRICTIONS,
PREPARING,
FAILED_DUE_PHOTO_SHARE_MIGRATION,
}
}
@@ -118,6 +119,11 @@ sealed class Event {
override val occurredAt: TimestampMs = TimestampMs()
}
data class BackupSync(val folderId: FolderId, val bucketId: Int) : Event() {
override val id: String = "$EVENT_ID_PREFIX${this.javaClass.simpleName.uppercase()}_1"
override val occurredAt: TimestampMs = TimestampMs()
}
@Serializable
data class Download(val downloadId: String, val downloadedFiles: Int, val totalFiles: Int) :
Event() {
@@ -22,19 +22,21 @@ import android.system.OsConstants
import me.proton.core.drive.backup.domain.entity.BackupError
import me.proton.core.drive.base.domain.api.ProtonApiCode.EXCEEDED_QUOTA
import me.proton.core.drive.base.data.extension.isErrno
import me.proton.core.drive.base.domain.api.ProtonApiCode.PHOTO_MIGRATION
import me.proton.core.network.domain.ApiException
import me.proton.core.network.domain.hasProtonErrorCode
fun Throwable.toBackupError(): BackupError = when (this) {
fun Throwable.toBackupError(retryable: Boolean = true): BackupError = when (this) {
is SecurityException -> BackupError.Permissions()
is ApiException -> when {
hasProtonErrorCode(EXCEEDED_QUOTA) -> BackupError.DriveStorage()
else -> BackupError.Other()
hasProtonErrorCode(PHOTO_MIGRATION) -> BackupError.Migration()
else -> BackupError.Other(retryable)
}
else -> if (isErrno(OsConstants.ENOSPC)) {
BackupError.LocalStorage()
} else {
BackupError.Other()
BackupError.Other(retryable)
}
}
@@ -73,6 +73,7 @@ class BackupUploadErrorHandler @Inject constructor(
BackupErrorType.LOCAL_STORAGE,
BackupErrorType.DRIVE_STORAGE,
BackupErrorType.PHOTOS_UPLOAD_NOT_ALLOWED,
BackupErrorType.MIGRATION,
-> {
BackupStopException("Backup must stop: ${backupError.type}", throwable)
.log(BACKUP, "Stopping backup")
@@ -38,6 +38,7 @@ import me.proton.core.drive.backup.data.worker.WorkerKeys.KEY_USER_ID
import me.proton.core.drive.backup.domain.entity.BackupFolder
import me.proton.core.drive.backup.domain.usecase.AddBackupError
import me.proton.core.drive.backup.domain.usecase.CheckDuplicates
import me.proton.core.drive.backup.domain.usecase.HandleBackupError
import me.proton.core.drive.base.data.extension.log
import me.proton.core.drive.base.data.workmanager.addTags
import me.proton.core.drive.base.domain.log.LogTag
@@ -51,7 +52,7 @@ class BackupCheckDuplicatesWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
private val checkDuplicates: CheckDuplicates,
private val addBackupError: AddBackupError,
private val handleBackupError: HandleBackupError,
) : CoroutineWorker(context, workerParams) {
@@ -66,7 +67,7 @@ class BackupCheckDuplicatesWorker @AssistedInject constructor(
.onSuccess { folderId ->
checkDuplicates(BackupFolder(bucketId, folderId)).onFailure { error ->
error.log(LogTag.BACKUP, "Cannot check duplicates")
addBackupError(folderId, error.toBackupError())
handleBackupError(folderId, error.toBackupError())
return Result.failure()
}
}
@@ -29,13 +29,14 @@ import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.backup.data.extension.toBackupError
import me.proton.core.drive.backup.data.manager.BackupManagerImpl
import me.proton.core.drive.backup.data.worker.WorkerKeys.KEY_FOLDER_ID
import me.proton.core.drive.backup.data.worker.WorkerKeys.KEY_SHARE_ID
import me.proton.core.drive.backup.data.worker.WorkerKeys.KEY_USER_ID
import me.proton.core.drive.backup.domain.entity.BackupError
import me.proton.core.drive.backup.domain.usecase.AddBackupError
import me.proton.core.drive.backup.domain.usecase.CleanRevisions
import me.proton.core.drive.backup.domain.usecase.HandleBackupError
import me.proton.core.drive.base.data.entity.LoggerLevel.WARNING
import me.proton.core.drive.base.data.extension.isRetryable
import me.proton.core.drive.base.data.extension.log
@@ -52,7 +53,7 @@ class BackupCleanRevisionsWorker @AssistedInject constructor(
@Assisted workerParams: WorkerParameters,
private val cleanRevisions: CleanRevisions,
private val configurationProvider: ConfigurationProvider,
private val addBackupError: AddBackupError,
private val handleBackupError: HandleBackupError,
) : CoroutineWorker(context, workerParams) {
private val userId = UserId(requireNotNull(inputData.getString(KEY_USER_ID)))
@@ -76,7 +77,7 @@ class BackupCleanRevisionsWorker @AssistedInject constructor(
tag = BACKUP,
message = "Cannot clean revisions for: ${folderId.id} retryable $retryable, max retries reached $canRetry"
)
addBackupError(folderId, BackupError.Other(retryable))
handleBackupError(folderId, error.toBackupError(retryable))
Result.failure()
}
}
@@ -29,14 +29,14 @@ import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.backup.data.extension.toBackupError
import me.proton.core.drive.backup.data.manager.BackupManagerImpl
import me.proton.core.drive.backup.data.worker.WorkerKeys.KEY_FOLDER_ID
import me.proton.core.drive.backup.data.worker.WorkerKeys.KEY_SHARE_ID
import me.proton.core.drive.backup.data.worker.WorkerKeys.KEY_USER_ID
import me.proton.core.drive.backup.domain.entity.BackupError
import me.proton.core.drive.backup.domain.entity.BackupFolder
import me.proton.core.drive.backup.domain.usecase.AddBackupError
import me.proton.core.drive.backup.domain.usecase.FindDuplicates
import me.proton.core.drive.backup.domain.usecase.HandleBackupError
import me.proton.core.drive.base.data.entity.LoggerLevel.WARNING
import me.proton.core.drive.base.data.extension.isRetryable
import me.proton.core.drive.base.data.extension.log
@@ -54,7 +54,7 @@ class BackupFindDuplicatesWorker @AssistedInject constructor(
@Assisted workerParams: WorkerParameters,
private val findDuplicates: FindDuplicates,
private val configurationProvider: ConfigurationProvider,
private val addBackupError: AddBackupError,
private val handleBackupError: HandleBackupError,
) : CoroutineWorker(context, workerParams) {
private val userId = UserId(requireNotNull(inputData.getString(KEY_USER_ID)))
@@ -85,7 +85,7 @@ class BackupFindDuplicatesWorker @AssistedInject constructor(
tag = BACKUP,
message = "Cannot find duplicates for: ${folderId.id} retryable $retryable, max retries reached $canRetry"
)
addBackupError(folderId, BackupError.Other(retryable))
handleBackupError(folderId, error.toBackupError(retryable))
Result.failure()
}
}
@@ -32,9 +32,9 @@ import me.proton.core.drive.backup.domain.entity.BackupFileState
import me.proton.core.drive.backup.domain.entity.BackupFolder
import me.proton.core.drive.backup.domain.repository.BackupDuplicateRepository
import me.proton.core.drive.backup.domain.repository.BackupFileRepository
import me.proton.core.drive.backup.domain.usecase.AddBackupError
import me.proton.core.drive.backup.domain.usecase.AddFolder
import me.proton.core.drive.backup.domain.usecase.CheckDuplicates
import me.proton.core.drive.backup.domain.usecase.HandleBackupError
import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.core.drive.base.domain.extension.bytes
import me.proton.core.drive.db.test.myFiles
@@ -64,7 +64,7 @@ class BackupCheckDuplicatesWorkerTest {
lateinit var checkDuplicates: CheckDuplicates
@Inject
lateinit var addBackupError: AddBackupError
lateinit var handleBackupError: HandleBackupError
@Inject
lateinit var addFolder: AddFolder
@@ -130,7 +130,7 @@ class BackupCheckDuplicatesWorkerTest {
context = appContext,
workerParams = workerParameters,
checkDuplicates = checkDuplicates,
addBackupError = addBackupError,
handleBackupError = handleBackupError,
)
})
@@ -31,8 +31,8 @@ import me.proton.core.drive.backup.domain.entity.BackupDuplicate
import me.proton.core.drive.backup.domain.entity.BackupError
import me.proton.core.drive.backup.domain.repository.BackupDuplicateRepository
import me.proton.core.drive.backup.domain.repository.BackupErrorRepository
import me.proton.core.drive.backup.domain.usecase.AddBackupError
import me.proton.core.drive.backup.domain.usecase.CleanRevisions
import me.proton.core.drive.backup.domain.usecase.HandleBackupError
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
import me.proton.core.drive.db.test.myFiles
import me.proton.core.drive.folder.domain.repository.FolderRepository
@@ -65,7 +65,7 @@ class BackupCleanRevisionsWorkerTest {
lateinit var configurationProvider: ConfigurationProvider
@Inject
lateinit var addBackupError: AddBackupError
lateinit var handleBackupError: HandleBackupError
@Inject
lateinit var backupDuplicateRepository: BackupDuplicateRepository
@@ -167,7 +167,7 @@ class BackupCleanRevisionsWorkerTest {
workerParams = workerParameters,
cleanRevisions = cleanRevisions,
configurationProvider = configurationProvider,
addBackupError = addBackupError,
handleBackupError = handleBackupError,
)
})
.setInputData(
@@ -34,8 +34,8 @@ import me.proton.core.drive.backup.domain.entity.BackupFolder
import me.proton.core.drive.backup.domain.repository.BackupErrorRepository
import me.proton.core.drive.backup.domain.repository.BackupFileRepository
import me.proton.core.drive.backup.domain.repository.BackupFolderRepository
import me.proton.core.drive.backup.domain.usecase.AddBackupError
import me.proton.core.drive.backup.domain.usecase.FindDuplicates
import me.proton.core.drive.backup.domain.usecase.HandleBackupError
import me.proton.core.drive.base.domain.api.ProtonApiCode
import me.proton.core.drive.base.domain.entity.TimestampS
import me.proton.core.drive.base.domain.extension.bytes
@@ -81,7 +81,7 @@ class BackupFindDuplicatesWorkerTest {
lateinit var configurationProvider: ConfigurationProvider
@Inject
lateinit var addBackupError: AddBackupError
lateinit var handleBackupError: HandleBackupError
private lateinit var backupFolder: BackupFolder
private lateinit var backupFiles: List<BackupFile>
@@ -187,7 +187,7 @@ class BackupFindDuplicatesWorkerTest {
workerParams = workerParameters,
findDuplicates = findDuplicates,
configurationProvider = configurationProvider,
addBackupError = addBackupError,
handleBackupError = handleBackupError,
)
})
@@ -63,5 +63,10 @@ data class BackupError(
type = BackupErrorType.BACKGROUND_RESTRICTIONS,
retryable = true,
)
fun Migration() = BackupError(
type = BackupErrorType.MIGRATION,
retryable = false,
)
}
}
@@ -27,4 +27,5 @@ enum class BackupErrorType {
DRIVE_STORAGE,
PHOTOS_UPLOAD_NOT_ALLOWED,
BACKGROUND_RESTRICTIONS,
MIGRATION,
}
@@ -32,4 +32,5 @@ fun BackupErrorType.toEventBackupState() = when (this) {
-> Event.Backup.BackupState.FAILED_PHOTOS_UPLOAD_NOT_ALLOWED
BackupErrorType.BACKGROUND_RESTRICTIONS -> Event.Backup.BackupState.PAUSE_BACKGROUND_RESTRICTIONS
BackupErrorType.MIGRATION -> Event.Backup.BackupState.FAILED_DUE_PHOTO_SHARE_MIGRATION
}
@@ -19,7 +19,6 @@
package me.proton.core.drive.backup.domain.usecase
import me.proton.core.drive.backup.domain.entity.BackupFolder
import me.proton.core.drive.backup.domain.manager.BackupManager
import me.proton.core.drive.base.domain.log.LogTag
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.linkupload.domain.entity.UploadFileLink
@@ -28,14 +27,14 @@ import javax.inject.Inject
class EnableBackupForFolder @Inject constructor(
private val addFolder: AddFolder,
private val backupManager: BackupManager,
private val syncFolders: SyncFolders,
) {
suspend operator fun invoke(backupFolder: BackupFolder) = coRunCatching {
CoreLogger.d(LogTag.BACKUP, "Adding folder: ${backupFolder.bucketId}")
addFolder(backupFolder).getOrThrow()
backupManager.sync(
syncFolders(
backupFolder = backupFolder,
uploadPriority = UploadFileLink.BACKUP_PRIORITY
)
).getOrThrow()
}
}
@@ -0,0 +1,50 @@
/*
* 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.backup.domain.usecase
import me.proton.core.drive.backup.domain.entity.BackupError
import me.proton.core.drive.backup.domain.entity.BackupErrorType
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.BACKUP
import me.proton.core.drive.base.domain.log.logId
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.folder.domain.usecase.DeleteLocalContent
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.share.domain.usecase.GetShare
import javax.inject.Inject
class HandleBackupError @Inject constructor(
private val stopBackup: StopBackup,
private val addBackupError: AddBackupError,
private val getShare: GetShare,
private val deleteLocalContent: DeleteLocalContent,
) {
suspend operator fun invoke(folderId: FolderId, backupError: BackupError) = coRunCatching {
if (backupError.type == BackupErrorType.MIGRATION) {
stopBackup(folderId, backupError).getOrThrow()
deleteLocalContent(
volumeId = getShare(folderId.shareId).toResult().getOrThrow().volumeId,
linkId = folderId,
).getOrNull(BACKUP, "Cannot delete local content: ${folderId.id.logId()}")
} else {
addBackupError(folderId, backupError).getOrThrow()
}
}
}
@@ -19,27 +19,39 @@
package me.proton.core.drive.backup.domain.usecase
import me.proton.core.domain.entity.UserId
import me.proton.core.drive.announce.event.domain.entity.Event
import me.proton.core.drive.announce.event.domain.usecase.AnnounceEvent
import me.proton.core.drive.backup.domain.entity.BackupFolder
import me.proton.core.drive.backup.domain.entity.BucketUpdate
import me.proton.core.drive.backup.domain.manager.BackupManager
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.link.domain.entity.FolderId
import me.proton.core.drive.link.domain.extension.userId
import me.proton.core.util.kotlin.filterNotNullValues
import me.proton.core.util.kotlin.filterNullValues
import javax.inject.Inject
class SyncFolders @Inject constructor(
private val getAllFolders: GetAllFolders,
private val backupManager: BackupManager,
private val announceEvent: AnnounceEvent,
) {
suspend operator fun invoke(backupFolder: BackupFolder, uploadPriority: Long) = coRunCatching {
backupManager.sync(backupFolder, uploadPriority)
announceEvent(backupFolder.folderId.userId, Event.BackupSync(
folderId = backupFolder.folderId,
bucketId = backupFolder.bucketId,
))
}
suspend operator fun invoke(folderId: FolderId, uploadPriority: Long) = coRunCatching {
getAllFolders(folderId).getOrThrow().onEach { backupFolder ->
backupManager.sync(backupFolder, uploadPriority)
invoke(backupFolder, uploadPriority)
}
}
suspend operator fun invoke(userId: UserId, uploadPriority: Long) = coRunCatching {
getAllFolders(userId).getOrThrow().onEach { backupFolder ->
backupManager.sync(backupFolder, uploadPriority)
invoke(backupFolder, uploadPriority)
}
}
@@ -71,7 +83,7 @@ class SyncFolders @Inject constructor(
backupFolder
}
}.onEach { backupFolder ->
backupManager.sync(backupFolder, uploadPriority)
invoke(backupFolder, uploadPriority)
}
}
}
@@ -30,7 +30,7 @@ import kotlin.time.Duration.Companion.seconds
class SyncStaleFolders(
private val getAllFolders: GetAllFolders,
private val backupManager: BackupManager,
private val syncFolders: SyncFolders,
private val configurationProvider: ConfigurationProvider,
private val clock: () -> TimestampS,
) {
@@ -38,9 +38,9 @@ class SyncStaleFolders(
@Inject
constructor(
getAllFolders: GetAllFolders,
backupManager: BackupManager,
syncFolders: SyncFolders,
configurationProvider: ConfigurationProvider,
) : this(getAllFolders, backupManager, configurationProvider, ::TimestampS)
) : this(getAllFolders, syncFolders, configurationProvider, ::TimestampS)
private val stale = { backupFolder: BackupFolder ->
backupFolder.syncTime?.let { syncTime ->
@@ -57,9 +57,9 @@ class SyncStaleFolders(
getAllFolders(userId).sync(uploadPriority)
}
private fun Result<List<BackupFolder>>.sync(
private suspend fun Result<List<BackupFolder>>.sync(
uploadPriority: Long,
) = getOrThrow().filter(stale).onEach { backupFolder ->
backupManager.sync(backupFolder, uploadPriority)
syncFolders(backupFolder, uploadPriority)
}
}
@@ -24,6 +24,10 @@ object Dto {
const val ACTIVE_REVISION = "ActiveRevision"
const val ACTIVE_URLS = "ActiveUrls"
const val ADDRESS_ID = "AddressID"
const val ADDRESS_KEY_ID = "AddressKeyID"
const val ALBUM = "Album"
const val ALBUM_PROPERTIES = "AlbumProperties"
const val ALBUMS = "Albums"
const val ANCHOR_ID = "AnchorID"
const val ATTRIBUTES = "Attributes"
const val AVAILABLE_HASHES = "AvailableHashes"
@@ -39,18 +43,18 @@ object Dto {
const val CONTENT_KEY_PACKET = "ContentKeyPacket"
const val CONTENT_KEY_PACKET_SIGNATURE = "ContentKeyPacketSignature"
const val CONTEXT_SHARE_ID = "ContextShareID"
const val COVER_LINK_ID = "CoverLinkID"
const val CREATION_TIME = "CreationTime"
const val CREATE_TIME = "CreateTime"
const val CREATOR = "Creator"
const val CREATOR_EMAIL = "CreatorEmail"
const val DATA = "Data"
const val DELETED_URL_ID = "DeletedURLID"
const val DOCUMENT = "Document"
const val EXTERNAL_INVITATION_ID = "ExternalInvitationID"
const val EXTERNAL_INVITATION_SIGNUP = "ExternalInvitationSignup"
const val DEVICE = "Device"
const val DEVICES = "Devices"
const val DEVICE_ID = "DeviceID"
const val DOCUMENT = "Document"
const val DOWNLOADED_BYTES = "DownloadedBytes"
const val DUPLICATE_HASHES = "DuplicateHashes"
const val EMAIL = "Email"
const val EMAIL_DETAILS = "EmailDetails"
@@ -63,10 +67,12 @@ object Dto {
const val EXIF = "Exif"
const val EXPIRATION_DURATION = "ExpirationDuration"
const val EXPIRATION_TIME = "ExpirationTime"
const val EXTERNAL_INVITATION_IDS = "ExternalInvitationIDs"
const val EXTERNAL_INVITATION = "ExternalInvitation"
const val EXTERNAL_INVITATION_ID = "ExternalInvitationID"
const val EXTERNAL_INVITATION_IDS = "ExternalInvitationIDs"
const val EXTERNAL_INVITATIONS = "ExternalInvitations"
const val EXTERNAL_INVITATION_SIGNATURE = "ExternalInvitationSignature"
const val EXTERNAL_INVITATION_SIGNUP = "ExternalInvitationSignup"
const val FILE = "File"
const val FILE_PROPERTIES = "FileProperties"
const val FLAGS = "Flags"
@@ -94,6 +100,7 @@ object Dto {
const val KEY_PACKET = "KeyPacket"
const val KEY_PACKET_SIGNATURE = "KeyPacketSignature"
const val LAST_ACCESS_TIME = "LastAccessTime"
const val LAST_ACTIVITY_TIME = "LastActivityTime"
const val LAST_ANCHOR_ID = "LastAnchorID"
const val LAST_SYNC_TIME = "LastSyncTime"
const val LINK = "Link"
@@ -138,11 +145,13 @@ object Dto {
const val PERMISSIONS = "Permissions"
const val PHOTO = "Photo"
const val PHOTOS = "Photos"
const val PHOTO_COUNT = "PhotoCount"
const val PUBLIC_URL = "PublicUrl"
const val REVISION = "Revision"
const val REVISION_ID = "RevisionID"
const val RESPONSE = "Response"
const val RESPONSES = "Responses"
const val RESTORE_STATUS = "RestoreStatus"
const val ROOT_LINK_ID = "RootLinkID"
const val SESSION_KEY_SIGNATURE = "SessionKeySignature"
const val SHARE = "Share"
@@ -184,8 +193,9 @@ object Dto {
const val TRASH = "Trash"
const val TRASHED = "Trashed"
const val TYPE = "Type"
const val UPLOAD_LINKS = "UploadLinks"
const val UNREADABLE_SHARE_IDS = "UnreadableShareIDs"
const val UPLOAD_LINKS = "UploadLinks"
const val UPLOADED_BYTES = "UploadedBytes"
const val URL = "URL"
const val URL_PASSWORD_SALT = "UrlPasswordSalt"
const val URLS_EXPIRED = "UrlsExpired"
@@ -33,9 +33,11 @@ object Column {
const val CONTENT_KEY_PACKET = "content_key_packet"
const val CONTENT_KEY_PACKET_SIGNATURE = "content_key_packet_signature"
const val COUNT = "count"
const val COVER_LINK_ID = "cover_link_id"
const val CREATE_TIME = "create_time"
const val CREATION_TIME = "creation_time"
const val CREATOR_EMAIL = "creator_email"
const val DETAILS = "details"
const val DIGESTS = "digests"
const val DURATION = "duration"
const val ENCRYPTED = "encrypted"
@@ -59,6 +61,7 @@ object Column {
const val KEY_PACKET = "key_packet"
const val KEY_PACKET_SIGNATURE = "key_packet_signature"
const val LAST_ACCESS_TIME = "last_access_time"
const val LAST_ACTIVITY_TIME = "last_activity_time"
const val LAST_FETCH_CHILDREN_TIMESTAMP = "last_fetch_children_timestamp"
const val LAST_FETCH_TIMESTAMP = "last_fetch_timestamp"
const val LAST_FETCH_TRASH_TIMESTAMP = "last_fetch_trash_timestamp"
@@ -114,6 +117,7 @@ object Column {
const val SIZE = "size"
const val SHARE_ID = "share_id"
const val SHARE_INVITATION_COUNT = "share_invitation_count"
const val SHARE_KEY = "share_key"
const val SHARE_PASSPHRASE_KEY_PACKET = "share_passphrase_key_packet"
const val SHARE_PASSWORD_SALT = "share_password_salt"
const val SHARE_MEMBER_COUNT = "share_member_count"
@@ -58,7 +58,10 @@ fun Throwable.getDefaultMessage(
is UnsupportedOperationException -> getDefaultMessage(context)
is IOException -> getDefaultMessage(context)
is SecurityException -> getDefaultMessage(context)
is RuntimeException -> getDefaultMessage(context)
is RuntimeException -> {
cause?.getDefaultMessage(context, useExceptionMessage)
?: getDefaultMessage(context)
}
else -> unhandled
}
}
@@ -32,6 +32,7 @@ object ProtonApiCode {
const val INSUFFICIENT_QUOTA = 200001
const val EXCEEDED_QUOTA = 200002
const val TOO_MANY_CHILDREN = 200300
const val PHOTO_MIGRATION = 201100
const val ENCRYPTION_VERIFICATION_FAILED = 200501
const val KEY_GET_INPUT_INVALID = 33101;
const val KEY_GET_ADDRESS_MISSING = 33102
@@ -109,7 +109,7 @@ interface ConfigurationProvider {
val minimumOrganizationFetchInterval: Duration get() = 1.days
val observeWorkManagerInterval: Duration get() = 1.minutes
val cacheInternalStorageLimit: Bytes get() = 512.MiB
val drivePublicShareEditMode: Boolean get() = false // implement entitlements PublicCollaboration
val albumsFeatureFlag: Boolean get() = false
data class Thumbnail(
val maxWidth: Int,
@@ -18,6 +18,7 @@
package me.proton.core.drive.base.presentation.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
import androidx.compose.material.Divider
@@ -30,11 +31,13 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
data class NavigationTab(
val iconResId: Int,
val titleResId: Int,
val notificationDotVisible: Boolean = false
)
@Composable
@@ -53,10 +56,16 @@ fun BottomNavigation(
val isSelected = selectedTab == tab
BottomNavigationItem(
icon = {
Icon(
painter = painterResource(id = tab.iconResId),
contentDescription = stringResource(id = tab.titleResId)
)
BoxWithNotificationDot(
modifier = Modifier.padding(horizontal = ProtonDimens.SmallSpacing),
notificationDotVisible = tab.notificationDotVisible,
horizontalOffset = ProtonDimens.SmallSpacing,
) {
Icon(
painter = painterResource(id = tab.iconResId),
contentDescription = stringResource(id = tab.titleResId)
)
}
},
label = {
Text(
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
@@ -34,6 +35,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.presentation.R as CorePresentation
@@ -42,6 +44,8 @@ import me.proton.core.presentation.R as CorePresentation
fun BoxWithNotificationDot(
modifier: Modifier = Modifier,
notificationDotVisible: Boolean = false,
horizontalOffset: Dp = 0.dp,
verticalOffset: Dp = 0.dp,
content: @Composable (Modifier) -> Unit
) {
Box(modifier = modifier) {
@@ -50,6 +54,7 @@ fun BoxWithNotificationDot(
NotificationDot(
Modifier
.align(Alignment.TopEnd)
.offset(x = horizontalOffset, y = verticalOffset)
)
}
}
@@ -0,0 +1,82 @@
/*
* 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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import me.proton.core.compose.theme.ProtonDimens.DefaultButtonMinHeight
import me.proton.core.compose.theme.ProtonDimens.DefaultCornerRadius
import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing
import me.proton.core.compose.theme.ProtonDimens.MediumSpacing
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.captionNorm
import me.proton.core.drive.base.domain.entity.FileTypeCategory
import me.proton.core.drive.base.domain.extension.firstCodePointAsStringOrNull
import me.proton.core.drive.base.presentation.extension.iconResId
@Composable
fun BoxScope.LetterBadge(
text: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.offset(x = ExtraSmallSpacing)
.align(Alignment.BottomEnd)
.size(MediumSpacing)
.clip(CircleShape)
.background(ProtonTheme.colors.shade40),
contentAlignment = Alignment.Center
) {
Text(
text = text.firstCodePointAsStringOrNull?.uppercase() ?: "?",
style = ProtonTheme.typography.captionNorm,
)
}
}
@Preview
@Composable
fun LetterBadge() {
ProtonTheme {
Box {
Image(
modifier = Modifier
.size(DefaultButtonMinHeight)
.clip(RoundedCornerShape(DefaultCornerRadius)),
painter = painterResource(FileTypeCategory.Unknown.iconResId),
contentDescription = null,
)
LetterBadge("Name")
}
}
}
@@ -41,5 +41,8 @@ sealed class ListContentState {
val message: String,
@StringRes val actionResId: Int? = null,
override val isRefreshing: Boolean = false,
@StringRes val titleId: Int? = null,
@StringRes val descriptionResId: Int? = null,
@DrawableRes val imageResId: Int? = null,
) : ListContentState()
}
@@ -49,6 +49,10 @@ fun onLoadState(
flow
//.debounce(500L)
.onEach { (loadState, itemCount) ->
val append = loadState.append
if (append is LoadState.Error) {
append.error.getDefaultMessage(appContext, useExceptionMessage).let(onError)
}
listContentState.processRefreshState(
appContext = appContext,
useExceptionMessage = useExceptionMessage,
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,37 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="163dp"
android:height="159dp"
android:viewportWidth="163"
android:viewportHeight="159">
<path
android:pathData="M33,0.95L126,0.95A3,3 0,0 1,129 3.95L129,155.95A3,3 0,0 1,126 158.95L33,158.95A3,3 0,0 1,30 155.95L30,3.95A3,3 0,0 1,33 0.95z"
android:fillColor="#45495D"/>
<path
android:pathData="M39,5.95L120,5.95A3,3 0,0 1,123 8.95L123,149.95A3,3 0,0 1,120 152.95L39,152.95A3,3 0,0 1,36 149.95L36,8.95A3,3 0,0 1,39 5.95z"
android:fillColor="#2D3142"/>
<path
android:pathData="M70.89,56.24H86.25V76.72H98.64L78.57,96.79L58.5,76.72H70.89V56.24Z"
android:fillColor="#7A85B3"/>
<path
android:pathData="M55.5,103h45v4h-45z"
android:fillColor="#7A85B3"/>
<path
android:pathData="M61,12.95m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0"
android:fillColor="#EC3E7C"
android:fillAlpha="0.2"/>
<path
android:pathData="M61,12.95m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"
android:fillColor="#EC3E7C"/>
<path
android:pathData="M70,9.95L99,9.95A3,3 0,0 1,102 12.95L102,12.95A3,3 0,0 1,99 15.95L70,15.95A3,3 0,0 1,67 12.95L67,12.95A3,3 0,0 1,70 9.95z"
android:fillColor="#60698E"/>
<path
android:pathData="M116.47,15.95L150.54,15.95A12.47,12.47 0,0 1,163.01 28.42L163.01,62.48A12.47,12.47 0,0 1,150.54 74.95L116.47,74.95A12.47,12.47 0,0 1,104 62.48L104,28.42A12.47,12.47 0,0 1,116.47 15.95z"
android:fillColor="#45495D"/>
<path
android:pathData="M112.45,58.24C110.73,58.24 109.73,56.34 110.74,54.98L122.57,39.08C123.08,38.4 124.11,38.4 124.62,39.08L133.22,50.64L138.01,44.21C138.51,43.53 139.55,43.53 140.05,44.21L148.06,54.98C149.07,56.34 148.08,58.24 146.36,58.24H112.45Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M131.31,34.63C131.31,36.81 133.02,38.57 135.12,38.57C137.23,38.57 138.93,36.81 138.93,34.63C138.93,32.46 137.23,30.7 135.12,30.7C133.02,30.7 131.31,32.46 131.31,34.63Z"
android:fillColor="#ffffff"/>
</vector>
@@ -0,0 +1,37 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="163dp"
android:height="159dp"
android:viewportWidth="163"
android:viewportHeight="159">
<path
android:pathData="M33,0.95L126,0.95A3,3 0,0 1,129 3.95L129,155.95A3,3 0,0 1,126 158.95L33,158.95A3,3 0,0 1,30 155.95L30,3.95A3,3 0,0 1,33 0.95z"
android:fillColor="#EBE7FF"/>
<path
android:pathData="M39,5.95L120,5.95A3,3 0,0 1,123 8.95L123,149.95A3,3 0,0 1,120 152.95L39,152.95A3,3 0,0 1,36 149.95L36,8.95A3,3 0,0 1,39 5.95z"
android:fillColor="#E0D9FF"/>
<path
android:pathData="M70.89,59.24H86.25V79.72H98.64L78.57,99.79L58.5,79.72H70.89V59.24Z"
android:fillColor="#645B8A"/>
<path
android:pathData="M55.5,106h45v4h-45z"
android:fillColor="#645B8A"/>
<path
android:pathData="M60,12.95m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0"
android:fillColor="#EC3E7C"
android:fillAlpha="0.2"/>
<path
android:pathData="M60,12.95m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"
android:fillColor="#EC3E7C"/>
<path
android:pathData="M69,9.95L98,9.95A3,3 0,0 1,101 12.95L101,12.95A3,3 0,0 1,98 15.95L69,15.95A3,3 0,0 1,66 12.95L66,12.95A3,3 0,0 1,69 9.95z"
android:fillColor="#EBE7FF"/>
<path
android:pathData="M116.47,15.95L150.54,15.95A12.47,12.47 0,0 1,163.01 28.42L163.01,62.48A12.47,12.47 0,0 1,150.54 74.95L116.47,74.95A12.47,12.47 0,0 1,104 62.48L104,28.42A12.47,12.47 0,0 1,116.47 15.95z"
android:fillColor="#EBE7FF"/>
<path
android:pathData="M112.45,58.24C110.73,58.24 109.73,56.34 110.74,54.98L122.57,39.08C123.08,38.4 124.11,38.4 124.62,39.08L133.22,50.64L138.01,44.21C138.51,43.53 139.55,43.53 140.05,44.21L148.06,54.98C149.07,56.34 148.08,58.24 146.36,58.24H112.45Z"
android:fillColor="#6D4AFF"/>
<path
android:pathData="M131.31,34.63C131.31,36.81 133.02,38.57 135.12,38.57C137.23,38.57 138.93,36.81 138.93,34.63C138.93,32.46 137.23,30.7 135.12,30.7C133.02,30.7 131.31,32.46 131.31,34.63Z"
android:fillColor="#6D4AFF"/>
</vector>
@@ -24,5 +24,6 @@
<item name="empty_trash_daynight" type="drawable" format="reference">@drawable/empty_trash_dark</item>
<item name="empty_offline_daynight" type="drawable" format="reference">@drawable/empty_offline_dark</item>
<item name="img_upsell_drive_daynight" type="drawable" format="reference">@drawable/img_upsell_drive_dark</item>
<item name="img_update_required_daynight" type="drawable" format="reference">@drawable/img_update_required_dark</item>
</resources>
@@ -24,5 +24,6 @@
<item name="empty_trash_daynight" type="drawable" format="reference">@drawable/empty_trash_light</item>
<item name="empty_offline_daynight" type="drawable" format="reference">@drawable/empty_offline_light</item>
<item name="img_upsell_drive_daynight" type="drawable" format="reference">@drawable/img_upsell_drive_light</item>
<item name="img_update_required_daynight" type="drawable" format="reference">@drawable/img_update_required_light</item>
</resources>
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2022-2023 Proton AG.
* This file is part of Proton Core.
*
* Proton Core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.cryptobase.domain.usecase
import me.proton.core.crypto.common.context.CryptoContext
import me.proton.core.crypto.common.pgp.SessionKey
import me.proton.core.crypto.common.pgp.Unarmored
import me.proton.core.drive.base.domain.util.coRunCatching
import me.proton.core.drive.cryptobase.domain.CryptoScope
import me.proton.core.key.domain.decryptSessionKey
import me.proton.core.key.domain.entity.keyholder.KeyHolder
import me.proton.core.key.domain.useKeys
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class GetSessionKey @Inject constructor(
private val cryptoContext: CryptoContext,
) {
suspend operator fun invoke(
decryptKey: KeyHolder,
message: Unarmored,
coroutineContext: CoroutineContext = CryptoScope.EncryptAndDecrypt.coroutineContext,
): Result<SessionKey> = coRunCatching(coroutineContext) {
decryptKey.useKeys(cryptoContext) {
decryptSessionKey(message)
}
}
}
@@ -29,4 +29,8 @@ object SignatureContexts {
value = "drive.share-member.external-invitation",
isCritical = true
)
val DRIVE_SHARE_MEMBER_MEMBER = SignatureContext(
value = "drive.share-member.member",
isCritical = true
)
}
@@ -51,6 +51,7 @@ class DecryptAncestorsName @Inject constructor(
when (link) {
is Link.Folder -> link.copy(name = decryptedName.text)
is Link.File -> link.copy(name = decryptedName.text)
is Link.Album -> link.copy(name = decryptedName.text)
}
} else {
link
@@ -62,7 +62,7 @@ class DecryptLinkName @Inject constructor(
coroutineContext: CoroutineContext = CryptoScope.EncryptAndDecrypt.coroutineContext,
): Result<DecryptedText> = coRunCatching(coroutineContext) {
val link = getLink(linkId).toResult().getOrThrow()
val email = link.nameSignatureEmail ?: link.signatureAddress
val email = link.nameSignatureEmail ?: link.signatureEmail
val verificationKeys = getVerificationKeys(
link = link,
email = email,
@@ -25,6 +25,7 @@ import me.proton.core.drive.cryptobase.domain.usecase.UnlockKey
import me.proton.core.drive.key.domain.extension.keyHolder
import me.proton.core.drive.key.domain.usecase.GetNodeKey
import me.proton.core.drive.key.domain.usecase.GetVerificationKeys
import me.proton.core.drive.link.domain.entity.Album
import me.proton.core.drive.link.domain.entity.BaseLink
import me.proton.core.drive.link.domain.entity.File
import me.proton.core.drive.link.domain.entity.Folder
@@ -40,11 +41,14 @@ class DecryptLinkXAttr @Inject constructor(
private val getPublicKeyRing: GetPublicKeyRing,
private val unlockKey: UnlockKey,
) {
suspend operator fun invoke(link: BaseLink): Result<DecryptedText> = getLinkKey(link.id).mapCatching { decryptKey ->
suspend operator fun invoke(
link: BaseLink,
): Result<DecryptedText> = getLinkKey(link.id).mapCatching { decryptKey ->
val signatureAddress = when (link) {
is File -> link.uploadedBy
is Folder -> link.signatureAddress
else -> throw IllegalStateException("Link must be either file or folder and it was not")
is Folder -> link.signatureEmail
is Album -> link.signatureEmail
else -> error("Link must be either file, folder or album and it was not")
}
val verificationKeys = getVerificationKeys(link.id, signatureAddress).getOrThrow().keyHolder
unlockKey(decryptKey.keyHolder) { unlockedKey ->
@@ -81,13 +81,13 @@ class CreateMoveInfo @Inject constructor(
hash = hmacSha256(newParentFolderHashKey, decryptedLinkName).getOrThrow(),// calculate with new parent,
previousHash = link.hash,
parentLinkId = newParentFolder.id.id,
signatureEmail = if (link.signatureAddress.isEmpty()) {
signatureEmail = if (link.signatureEmail.isEmpty()) {
signatureAddress
} else {
null
},
nodePassphrase = newLinkKey.nodePassphrase,
nodePassphraseSignature = if (link.signatureAddress.isEmpty() || link.nameSignatureEmail.isNullOrEmpty()) {
nodePassphraseSignature = if (link.signatureEmail.isEmpty() || link.nameSignatureEmail.isNullOrEmpty()) {
newLinkKey.nodePassphraseSignature
} else {
null
@@ -49,8 +49,13 @@ class CreateRenameInfo @Inject constructor(
link: Link,
name: String,
mimeType: String,
shouldValidateName: Boolean = true,
): Result<RenameInfo> = coRunCatching {
val linkName = validateLinkName(name).getOrThrow()
val linkName = if (shouldValidateName) {
validateLinkName(name).getOrThrow()
} else {
name
}
val parentFolderKey = getNodeKey(parentFolder).getOrThrow()
val parentFolderHashKey = getNodeHashKey(parentFolder, parentFolderKey).getOrThrow()
val signatureAddress = getSignatureAddress(link.shareId).getOrThrow()
@@ -28,8 +28,11 @@ import me.proton.core.drive.key.domain.extension.nodePassphrase
import me.proton.core.drive.key.domain.extension.nodePassphraseSignature
import me.proton.core.drive.key.domain.usecase.GenerateNodeKey
import me.proton.core.drive.key.domain.usecase.GenerateShareKey
import me.proton.core.drive.key.domain.usecase.GetAddressKeyId
import me.proton.core.drive.key.domain.usecase.GetAddressKeys
import me.proton.core.drive.volume.domain.entity.Volume
import me.proton.core.drive.volume.domain.entity.VolumeInfo
import me.proton.core.drive.volume.domain.entity.VolumeInfo.Companion.DEFAULT_PHOTOS_ROOT_FOLDER_NAME
import me.proton.core.drive.volume.domain.entity.VolumeInfo.Companion.DEFAULT_ROOT_FOLDER_NAME
import javax.inject.Inject
@@ -40,25 +43,50 @@ class CreateVolumeInfo @Inject constructor(
private val generateHashKey: GenerateHashKey,
private val encryptText: EncryptText,
private val getAddressKeys: GetAddressKeys,
private val getAddressKeyId: GetAddressKeyId,
) {
suspend operator fun invoke(userId: UserId, signatureAddress: String): Result<VolumeInfo> =
suspend operator fun invoke(
userId: UserId,
signatureAddress: String,
type: Volume.Type,
): Result<VolumeInfo> =
generateShareAndFolderKey(userId, signatureAddress).mapCatching { (shareKey, folderKey) ->
val addressId = getAddressId(userId)
VolumeInfo(
addressId = addressId,
shareKey = shareKey.nodeKey,
sharePassphrase = shareKey.nodePassphrase,
sharePassphraseSignature = shareKey.nodePassphraseSignature,
folderName = encryptText(
encryptKey = shareKey.keyHolder,
text = DEFAULT_ROOT_FOLDER_NAME,
signKey = getAddressKeys(userId, addressId).keyHolder
).getOrThrow(),
folderKey = folderKey.nodeKey,
folderPassphrase = folderKey.nodePassphrase,
folderPassphraseSignature = folderKey.nodePassphraseSignature,
folderHashKey = generateHashKey(folderKey.keyHolder).getOrThrow()
)
val addressKeyId = getAddressKeyId(userId, addressId).getOrThrow().id
when (type) {
Volume.Type.PHOTO -> VolumeInfo.Photo(
addressId = addressId,
shareKey = shareKey.nodeKey,
sharePassphrase = shareKey.nodePassphrase,
sharePassphraseSignature = shareKey.nodePassphraseSignature,
folderName = encryptText(
encryptKey = shareKey.keyHolder,
text = DEFAULT_PHOTOS_ROOT_FOLDER_NAME,
signKey = getAddressKeys(userId, addressId).keyHolder
).getOrThrow(),
folderKey = folderKey.nodeKey,
folderPassphrase = folderKey.nodePassphrase,
folderPassphraseSignature = folderKey.nodePassphraseSignature,
folderHashKey = generateHashKey(folderKey.keyHolder).getOrThrow(),
addressKeyId = addressKeyId,
)
else -> VolumeInfo.Regular(
addressId = addressId,
shareKey = shareKey.nodeKey,
sharePassphrase = shareKey.nodePassphrase,
sharePassphraseSignature = shareKey.nodePassphraseSignature,
folderName = encryptText(
encryptKey = shareKey.keyHolder,
text = DEFAULT_ROOT_FOLDER_NAME,
signKey = getAddressKeys(userId, addressId).keyHolder
).getOrThrow(),
folderKey = folderKey.nodeKey,
folderPassphrase = folderKey.nodePassphrase,
folderPassphraseSignature = folderKey.nodePassphraseSignature,
folderHashKey = generateHashKey(folderKey.keyHolder).getOrThrow(),
addressKeyId = addressKeyId,
)
}
}
private suspend fun generateShareAndFolderKey(userId: UserId, signatureAddress: String): Result<Pair<Key.Node, Key.Node>> =
@@ -0,0 +1,27 @@
/*
* 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.db.test
import me.proton.core.drive.photo.data.db.entity.AlbumListingEntity
suspend fun FolderContext.albumListings(
vararg albumListings: AlbumListingEntity,
) {
db.albumListingDao.insertOrUpdate(*albumListings)
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2024 Proton AG.
* This file is part of Proton Core.
*
* Proton Core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Proton Core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Proton Core. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.drive.db.test
import me.proton.core.drive.base.domain.entity.Permissions
import me.proton.core.drive.share.user.data.db.entity.UserInvitationDetailsEntity
import me.proton.core.drive.share.user.data.db.entity.UserInvitationIdEntity
suspend fun ShareContext.userInvitation(id: String) {
userInvitationId(NullableUserInvitationIdEntity(id))
userInvitationDetails(NullableUserInvitationDetailsEntity(id))
}
suspend fun ShareContext.userInvitationId(userInvitationIdEntity: UserInvitationIdEntity) {
db.userInvitationIdDao.insertOrIgnore(userInvitationIdEntity)
}
suspend fun ShareContext.userInvitationDetails(userInvitationDetailsEntity: UserInvitationDetailsEntity) {
db.userInvitationDetailsDao.insertOrIgnore(userInvitationDetailsEntity)
}
@Suppress("FunctionName")
fun ShareContext.NullableUserInvitationIdEntity(
id: String,
) = UserInvitationIdEntity(
id = id,
userId = user.userId,
volumeId = volume.id,
shareId = share.id,
)
@Suppress("FunctionName")
fun ShareContext.NullableUserInvitationDetailsEntity(
id: String,
) = UserInvitationDetailsEntity(
id = id,
userId = user.userId,
volumeId = volume.id,
shareId = share.id,
inviterEmail = "inviterEmail",
inviteeEmail = "inviteeEmail",
permissions = Permissions.viewer.value,
keyPacket = "user-invitation-key-packet",
keyPacketSignature = "user-invitation-key-packet-signature",
createTime = 0L,
passphrase = "passphrase",
shareKey = "shareKey",
creatorEmail = "creatorEmail",
type = 0L,
linkId = "linkId",
name = "name",
mimeType = null,
)
@@ -22,6 +22,7 @@ package me.proton.core.drive.db.test
import me.proton.android.drive.db.DriveDatabase
import me.proton.core.account.data.entity.AccountEntity
import me.proton.core.drive.share.domain.entity.ShareId
import me.proton.core.drive.volume.data.api.entity.VolumeDto
import me.proton.core.drive.volume.data.db.VolumeEntity
import me.proton.core.drive.volume.domain.entity.VolumeId
import me.proton.core.user.data.entity.UserEntity
@@ -34,6 +35,7 @@ data class VolumeContext(
) : BaseContext()
val volumeId = VolumeId("volume-id")
val photoVolumeId = VolumeId("photo-volume-id")
val mainShareId = ShareId(userId, "main-share-id")
suspend fun UserContext.volume(
@@ -52,14 +54,46 @@ suspend fun <T> UserContext.volume(
return VolumeContext(db, user, account, volume).block()
}
suspend fun <T> UserContext.photoVolume(
volume: VolumeEntity = NullablePhotoVolumeEntity(
id = photoVolumeId.id,
),
block: suspend VolumeContext.() -> T,
): T {
db.volumeDao.insertOrUpdate(volume)
return VolumeContext(db, user, account, volume).block()
}
@Suppress("FunctionName")
fun NullableVolumeEntity(id: String = volumeId.id, state: Long = 1, creationTime: Long = 0) =
fun NullableVolumeEntity(
id: String = volumeId.id,
state: Long = 1,
createTime: Long = 0,
) =
VolumeEntity(
id = id,
userId = userId,
shareId = mainShareId.id,
creationTime = creationTime,
maxSpace = 0,
linkId = mainRootId.id,
createTime = createTime,
usedSpace = 0,
state = state,
type = VolumeDto.TYPE_REGULAR,
)
@Suppress("FunctionName")
fun NullablePhotoVolumeEntity(
id: String = photoVolumeId.id,
state: Long = 1,
createTime: Long = 0,
) =
VolumeEntity(
id = id,
userId = userId,
shareId = photoShareId.id,
linkId = photoRootId.id,
createTime = createTime,
usedSpace = 0,
state = state,
type = VolumeDto.TYPE_PHOTO,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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