From 1ae5e69f02b445e09da652b6b70eeec3bf4722fa Mon Sep 17 00:00:00 2001 From: Seren Date: Thu, 18 Sep 2025 16:15:13 +0200 Subject: [PATCH] Automatically recover mailbox if user session is null ET-3788 # Conflicts: # mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxViewModel.kt # mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/MailboxViewModelTest.kt --- .../presentation/mailbox/MailboxScreen.kt | 26 ++++++- .../mailbox/MailboxScreenState.kt | 1 + .../presentation/mailbox/MailboxViewModel.kt | 20 ++++- .../mailbox/model/MailboxListState.kt | 1 + .../mailbox/model/MailboxOperation.kt | 3 +- .../mailbox/reducer/MailboxListReducer.kt | 5 ++ .../src/main/res/values/strings.xml | 1 + .../presentation/MailboxViewModelTest.kt | 75 +++++++++++++++++- .../mailbox/reducer/MailboxListReducerTest.kt | 45 +++++++++++ .../repository/UserSessionRepositoryImpl.kt | 28 +++++-- .../UserSessionRepositoryImplTest.kt | 27 +++++++ .../repository/UserSessionRepository.kt | 2 + .../domain/usecase/GetUserHasValidSession.kt | 36 +++++++++ .../ObservePrimaryUserIdWithValidSession.kt | 38 +++++++++ .../usecase/GetUserHasValidSessionTest.kt | 60 ++++++++++++++ ...bservePrimaryUserIdWithValidSessionTest.kt | 78 +++++++++++++++++++ .../repository/FakeUserSessionRepository.kt | 3 + 17 files changed, 430 insertions(+), 19 deletions(-) create mode 100644 mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/usecase/GetUserHasValidSession.kt create mode 100644 mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/usecase/ObservePrimaryUserIdWithValidSession.kt create mode 100644 mail-session/domain/src/test/kotlin/ch/protonmail/android/mailsession/domain/usecase/GetUserHasValidSessionTest.kt create mode 100644 mail-session/domain/src/test/kotlin/ch/protonmail/android/mailsession/domain/usecase/ObservePrimaryUserIdWithValidSessionTest.kt diff --git a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxScreen.kt b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxScreen.kt index a90068afd0..6cd92ea55e 100644 --- a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxScreen.kt +++ b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxScreen.kt @@ -100,7 +100,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType @@ -276,9 +279,17 @@ fun MailboxScreen( onClearAll = { viewModel.submit(MailboxViewAction.ClearAll) }, onClearAllConfirmed = { viewModel.submit(MailboxViewAction.ClearAllConfirmed) }, onClearAllDismissed = { viewModel.submit(MailboxViewAction.ClearAllDismissed) }, - onSnooze = { viewModel.submit(MailboxViewAction.RequestSnoozeBottomSheet) } + onSnooze = { viewModel.submit(MailboxViewAction.RequestSnoozeBottomSheet) }, + validateUserSession = { viewModel.submit(MailboxViewAction.ValidateUserSession) } ) + val lifecycle = LocalLifecycleOwner.current + LaunchedEffect(key1 = Unit) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.submit(MailboxViewAction.ValidateUserSession) + } + } + mailboxState.bottomSheetState?.let { ConsumableLaunchedEffect(effect = it.bottomSheetVisibilityEffect) { bottomSheetEffect -> when (bottomSheetEffect) { @@ -703,6 +714,7 @@ private fun MailboxSwipeRefresh( val currentViewState = remember(items.loadState, state) { when { state is MailboxListState.Loading -> MailboxScreenState.Loading + state is MailboxListState.CouldNotLoadUserSession -> MailboxScreenState.CouldNotLoadUserSession searchMode.isInSearch() -> items.mapToUiStatesInSearch(searchMode, lastViewState) else -> items.mapToUiStates(refreshRequested) } @@ -809,6 +821,7 @@ private fun MailboxSwipeRefresh( modifier = Modifier.testTag(MailboxScreenTestTags.ListProgress) ) + is MailboxScreenState.CouldNotLoadUserSession, is MailboxScreenState.SearchLoadingWithData, is MailboxScreenState.SearchData, is MailboxScreenState.LoadingWithData, @@ -986,6 +999,11 @@ private fun MailboxItemsList( onClick = { items.retry() } ) + is MailboxScreenState.CouldNotLoadUserSession -> AppendError( + message = stringResource(id = R.string.mailbox_error_message_user_session), + onClick = { actions.validateUserSession() } + ) + else -> { Spacer(modifier = Modifier.padding(1.dp)) } @@ -1239,7 +1257,8 @@ object MailboxScreen { val onClearAllDismissed: () -> Unit, val onSnooze: () -> Unit, val onActionBarVisibilityChanged: (Boolean) -> Unit, - val onCustomizeToolbar: () -> Unit + val onCustomizeToolbar: () -> Unit, + val validateUserSession: () -> Unit ) { companion object { @@ -1301,7 +1320,8 @@ object MailboxScreen { onNavigateToUpselling = { _, _ -> }, onSnooze = {}, onCustomizeToolbar = {}, - onActionBarVisibilityChanged = {} + onActionBarVisibilityChanged = {}, + validateUserSession = {} ) } } diff --git a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxScreenState.kt b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxScreenState.kt index 4b3ec3eb65..4122e08f5c 100644 --- a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxScreenState.kt +++ b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxScreenState.kt @@ -22,6 +22,7 @@ import androidx.paging.compose.LazyPagingItems import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxItemUiModel sealed class MailboxScreenState { + object CouldNotLoadUserSession : MailboxScreenState() object Loading : MailboxScreenState() object Empty : MailboxScreenState() object UnexpectedError : MailboxScreenState() diff --git a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxViewModel.kt b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxViewModel.kt index 76ac6a8146..397887c251 100644 --- a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxViewModel.kt +++ b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/MailboxViewModel.kt @@ -112,7 +112,8 @@ import ch.protonmail.android.mailmessage.presentation.model.bottomsheet.MoveToBo import ch.protonmail.android.mailmessage.presentation.model.bottomsheet.SnoozeSheetState import ch.protonmail.android.mailpagination.domain.usecase.ObservePageInvalidationEvents import ch.protonmail.android.mailsession.domain.repository.EventLoopRepository -import ch.protonmail.android.mailsession.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailsession.domain.usecase.GetUserHasValidSession +import ch.protonmail.android.mailsession.domain.usecase.ObservePrimaryUserIdWithValidSession import ch.protonmail.android.mailsettings.domain.model.ToolbarActionsRefreshSignal import ch.protonmail.android.mailsettings.domain.usecase.ObserveFolderColorSettings import ch.protonmail.android.mailsettings.domain.usecase.ObserveSwipeActionsPreference @@ -143,7 +144,6 @@ import me.proton.core.domain.entity.UserId import me.proton.core.util.kotlin.DispatcherProvider import timber.log.Timber import javax.inject.Inject -import kotlin.collections.map @HiltViewModel @SuppressWarnings("LongParameterList", "TooManyFunctions", "LargeClass") @@ -151,8 +151,9 @@ class MailboxViewModel @Inject constructor( @AppScope private val appScope: CoroutineScope, private val mailboxPagerFactory: MailboxPagerFactory, private val getCurrentViewModeForLabel: GetCurrentViewModeForLabel, - observePrimaryUserId: ObservePrimaryUserId, + private val observePrimaryUserIdWithValidSession: ObservePrimaryUserIdWithValidSession, private val observeMailLabels: ObserveMailLabels, + private val getUserHasValidSession: GetUserHasValidSession, private val observeSwipeActionsPreference: ObserveSwipeActionsPreference, private val observeSelectedMailLabelId: ObserveSelectedMailLabelId, private val observeLoadedMailLabelId: ObserveLoadedMailLabelId, @@ -193,7 +194,7 @@ class MailboxViewModel @Inject constructor( private val eventLoopRepository: EventLoopRepository ) : ViewModel() { - private val primaryUserId = observePrimaryUserId().filterNotNull() + private val primaryUserId = observePrimaryUserIdWithValidSession().filterNotNull() private val mutableState = MutableStateFlow(initialState) private val itemIds = Collections.synchronizedList(mutableListOf()) private val folderColorSettings = primaryUserId.flatMapLatest { @@ -370,6 +371,7 @@ class MailboxViewModel @Inject constructor( is MailboxViewAction.RequestSnoozeBottomSheet -> requestSnoozeBottomSheet(viewAction) is MailboxViewAction.SignalMoveToCompleted -> handleMoveToCompleted(viewAction) is MailboxViewAction.SignalLabelAsCompleted -> handleLabelAsCompleted(viewAction) + is MailboxViewAction.ValidateUserSession -> handleValidateUserSession() } } } @@ -435,6 +437,7 @@ class MailboxViewModel @Inject constructor( when (state.value.mailboxListState) { is MailboxListState.Data.SelectionMode -> handleItemClickInSelectionMode(item) is MailboxListState.Data.ViewMode -> handleItemClickInViewMode(item) + is MailboxListState.CouldNotLoadUserSession, is MailboxListState.Loading -> { Timber.d("Loading state can't handle item clicks") } @@ -1152,6 +1155,15 @@ class MailboxViewModel @Inject constructor( mutableState.value = state } + // A user can be logged in but not have a valid user session, in this case the app can't function. User sessions + // are cached, but in rare cases such as migration RUST will not have even cached the session and therefore network + // connection is obligatory and we MUST load the session before the app can recover + private suspend fun handleValidateUserSession() { + if (state.value.mailboxListState is MailboxListState.Loading && !getUserHasValidSession()) { + emitNewStateFrom(MailboxEvent.CouldNotLoadUserSession) + } + } + private fun Flow.observeUnreadFilterState() = this.map { it.unreadFilterState as? UnreadFilterState.Data } .mapNotNull { it?.isFilterEnabled } diff --git a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/model/MailboxListState.kt b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/model/MailboxListState.kt index 9bda2a7111..85ea73815b 100644 --- a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/model/MailboxListState.kt +++ b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/model/MailboxListState.kt @@ -77,6 +77,7 @@ sealed interface MailboxListState { } data object Loading : MailboxListState + data object CouldNotLoadUserSession : MailboxListState companion object { const val maxItemSelectionLimit = 100 diff --git a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/model/MailboxOperation.kt b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/model/MailboxOperation.kt index 1aaf23bf59..e37d28474e 100644 --- a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/model/MailboxOperation.kt +++ b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/model/MailboxOperation.kt @@ -171,10 +171,11 @@ internal sealed interface MailboxViewAction : MailboxOperation { AffectingBottomSheet, AffectingTopAppBar, AffectingBottomAppBar + object ValidateUserSession : MailboxViewAction } internal sealed interface MailboxEvent : MailboxOperation { - + object CouldNotLoadUserSession : MailboxEvent, AffectingMailboxList data class LabelAsConfirmed( val viewMode: ViewMode, val itemCount: Int, diff --git a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/reducer/MailboxListReducer.kt b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/reducer/MailboxListReducer.kt index 28548b6403..8db585d225 100644 --- a/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/reducer/MailboxListReducer.kt +++ b/mail-mailbox/presentation/src/main/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/reducer/MailboxListReducer.kt @@ -89,6 +89,7 @@ class MailboxListReducer @Inject constructor( is MailboxEvent.AttachmentReadyEvent -> reduceAttachmentReady(operation, currentState) is MailboxEvent.AttachmentErrorEvent -> reduceAttachmentDownloadError(currentState) is MailboxEvent.PaginatorInvalidated -> reducePaginatorInvalidated(operation, currentState) + is MailboxEvent.CouldNotLoadUserSession -> reduceCouldNotLoadUserSession() } } @@ -243,6 +244,7 @@ class MailboxListReducer @Inject constructor( ): MailboxListState.Data { val currentMailLabel = operation.selectedLabel return when (currentState) { + is MailboxListState.CouldNotLoadUserSession, is MailboxListState.Loading -> MailboxListState.Data.ViewMode( currentMailLabel, openItemEffect = Effect.empty(), @@ -274,6 +276,7 @@ class MailboxListReducer @Inject constructor( ): MailboxListState.Data { val currentMailLabel = operation.selectedLabel return when (currentState) { + is MailboxListState.CouldNotLoadUserSession, is MailboxListState.Loading -> MailboxListState.Data.ViewMode( currentMailLabel, openItemEffect = Effect.empty(), @@ -557,4 +560,6 @@ class MailboxListReducer @Inject constructor( else -> currentState } + + private fun reduceCouldNotLoadUserSession(): MailboxListState = MailboxListState.CouldNotLoadUserSession } diff --git a/mail-mailbox/presentation/src/main/res/values/strings.xml b/mail-mailbox/presentation/src/main/res/values/strings.xml index 7d0f32948b..bc923347a3 100644 --- a/mail-mailbox/presentation/src/main/res/values/strings.xml +++ b/mail-mailbox/presentation/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ (No Sender) You are offline, unable to retrieve items Loading mailbox failed + Unable to load session. Please check your connection and try again. Something went unexpectedly wrong No messages Nothing in Trash diff --git a/mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/MailboxViewModelTest.kt b/mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/MailboxViewModelTest.kt index c18ecb4011..fe8556e065 100644 --- a/mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/MailboxViewModelTest.kt +++ b/mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/MailboxViewModelTest.kt @@ -110,7 +110,8 @@ import ch.protonmail.android.mailmessage.presentation.model.bottomsheet.SnoozeSh import ch.protonmail.android.mailpagination.domain.model.PageInvalidationEvent import ch.protonmail.android.mailpagination.domain.usecase.ObservePageInvalidationEvents import ch.protonmail.android.mailsession.domain.repository.EventLoopRepository -import ch.protonmail.android.mailsession.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailsession.domain.usecase.GetUserHasValidSession +import ch.protonmail.android.mailsession.domain.usecase.ObservePrimaryUserIdWithValidSession import ch.protonmail.android.mailsettings.domain.model.FolderColorSettings import ch.protonmail.android.mailsettings.domain.model.SwipeActionsPreference import ch.protonmail.android.mailsettings.domain.model.ToolbarActionsRefreshSignal @@ -175,7 +176,7 @@ internal class MailboxViewModelTest { private val actionUiModelMapper = ActionUiModelMapper() private val swipeActionsMapper = SwipeActionsMapper() - private val observePrimaryUserId = mockk { + private val observePrimaryUserId = mockk { every { this@mockk.invoke() } returns flowOf(userId) } @@ -244,6 +245,9 @@ internal class MailboxViewModelTest { private val deleteMessages = mockk() private val starMessages = mockk() private val starConversations = mockk() + private val getUserHasValidSession = mockk { + coEvery { this@mockk.invoke() } returns true + } private val unStarMessages = mockk() private val unStarConversations = mockk() private val getBottomSheetActions = mockk() @@ -302,7 +306,7 @@ internal class MailboxViewModelTest { appScope = scope, mailboxPagerFactory = pagerFactory, getCurrentViewModeForLabel = getCurrentViewModeForLabel, - observePrimaryUserId = observePrimaryUserId, + observePrimaryUserIdWithValidSession = observePrimaryUserId, observeMailLabels = observeMailLabels, observeSwipeActionsPreference = observeSwipeActionsPreference, observeSelectedMailLabelId = observeSelectedMailLabelId, @@ -341,7 +345,8 @@ internal class MailboxViewModelTest { observeViewModeChanged = observeViewModeChanged, toolbarRefreshSignal = toolbarRefreshSignal, terminateConversationPaginator = terminateConversationPaginator, - eventLoopRepository = eventLoopRepository + eventLoopRepository = eventLoopRepository, + getUserHasValidSession = getUserHasValidSession ) } @@ -922,6 +927,68 @@ internal class MailboxViewModelTest { } } + @Test + fun `when loading and validateUserSession is false then emit CouldNotLoadUserSession`() = runTest { + // Given + coEvery { getUserHasValidSession() } returns false + coEvery { observeUnreadCounters(userId = any()) } returns emptyFlow() + coEvery { observeMailLabels(userId = any()) } returns emptyFlow() + + // When + mailboxViewModel.state.test { + // Then + val actual = awaitItem() + val expected = MailboxState( + mailboxListState = MailboxListState.Loading, + topAppBarState = MailboxTopAppBarState.Loading, + unreadFilterState = UnreadFilterState.Loading, + bottomAppBarState = BottomBarState.Data.Hidden(emptyList().toImmutableList()), + deleteDialogState = DeleteDialogState.Hidden, + clearAllDialogState = DeleteDialogState.Hidden, + bottomSheetState = null, + actionResult = Effect.empty(), + error = Effect.empty() + ) + + // when + mailboxViewModel.submit(MailboxViewAction.ValidateUserSession) + assertEquals(expected, actual) + awaitItem() + verify { mailboxReducer.newStateFrom(actual, MailboxEvent.CouldNotLoadUserSession) } + } + } + + @Test + fun `when loading and validateUserSession is true then do not emit CouldNotLoadUserSession`() = runTest { + // Given + coEvery { getUserHasValidSession() } returns true + coEvery { observeUnreadCounters(userId = any()) } returns emptyFlow() + coEvery { observeMailLabels(userId = any()) } returns emptyFlow() + + // When + mailboxViewModel.state.test { + // Then + val actual = awaitItem() + val expected = MailboxState( + mailboxListState = MailboxListState.Loading, + topAppBarState = MailboxTopAppBarState.Loading, + unreadFilterState = UnreadFilterState.Loading, + bottomAppBarState = BottomBarState.Data.Hidden(emptyList().toImmutableList()), + deleteDialogState = DeleteDialogState.Hidden, + clearAllDialogState = DeleteDialogState.Hidden, + bottomSheetState = null, + actionResult = Effect.empty(), + error = Effect.empty() + ) + + // when + mailboxViewModel.submit(MailboxViewAction.ValidateUserSession) + assertEquals(expected, actual) + coVerify(exactly = 1) { getUserHasValidSession.invoke() } + verify(exactly = 0) { mailboxReducer.newStateFrom(actual, MailboxEvent.CouldNotLoadUserSession) } + } + } + @Test fun `when avatar click action is submitted to remove item from selection, new state is created and emitted`() = runTest { diff --git a/mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/reducer/MailboxListReducerTest.kt b/mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/reducer/MailboxListReducerTest.kt index c2cd123e8c..1951c8abdb 100644 --- a/mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/reducer/MailboxListReducerTest.kt +++ b/mail-mailbox/presentation/src/test/kotlin/ch/protonmail/android/mailmailbox/presentation/mailbox/reducer/MailboxListReducerTest.kt @@ -167,6 +167,21 @@ internal class MailboxListReducerTest( avatarImagesUiModel = AvatarImagesUiModel.Empty ) ), + TestInput( + currentState = MailboxListState.CouldNotLoadUserSession, + operation = MailboxEvent.SelectedLabelChanged(MailLabelTestData.spamSystemLabel), + expectedState = MailboxListState.Data.ViewMode( + currentMailLabel = MailLabelTestData.spamSystemLabel, + openItemEffect = Effect.empty(), + scrollToMailboxTop = Effect.empty(), + refreshErrorEffect = Effect.empty(), + refreshRequested = false, + swipeActions = null, + searchState = MailboxSearchStateSampleData.NotSearching, + shouldShowFab = true, + avatarImagesUiModel = AvatarImagesUiModel.Empty + ) + ), TestInput( currentState = MailboxListState.Loading, operation = MailboxEvent.NewLabelSelected(MailLabelTestData.customLabelOne, UNREAD_COUNT), @@ -182,6 +197,21 @@ internal class MailboxListReducerTest( avatarImagesUiModel = AvatarImagesUiModel.Empty ) ), + TestInput( + currentState = MailboxListState.CouldNotLoadUserSession, + operation = MailboxEvent.NewLabelSelected(MailLabelTestData.customLabelOne, UNREAD_COUNT), + expectedState = MailboxListState.Data.ViewMode( + currentMailLabel = MailLabelTestData.customLabelOne, + openItemEffect = Effect.empty(), + scrollToMailboxTop = Effect.empty(), + refreshErrorEffect = Effect.empty(), + refreshRequested = false, + swipeActions = null, + searchState = MailboxSearchStateSampleData.NotSearching, + shouldShowFab = true, + avatarImagesUiModel = AvatarImagesUiModel.Empty + ) + ), TestInput( currentState = MailboxListState.Loading, operation = MailboxEvent.NewLabelSelected(MailLabelTestData.spamSystemLabel, UNREAD_COUNT), @@ -1365,6 +1395,21 @@ internal class MailboxListReducerTest( avatarImagesUiModel = AvatarImagesUiModel.Empty, areAllItemsSelected = false ) + ), + TestInput( + currentState = MailboxListState.Data.ViewMode( + currentMailLabel = MailLabelTestData.customLabelOne, + openItemEffect = Effect.empty(), + scrollToMailboxTop = Effect.empty(), + refreshErrorEffect = Effect.empty(), + refreshRequested = true, + swipeActions = null, + searchState = MailboxSearchStateSampleData.NotSearching, + shouldShowFab = true, + avatarImagesUiModel = AvatarImagesUiModel.Empty + ), + operation = MailboxEvent.CouldNotLoadUserSession, + expectedState = MailboxListState.CouldNotLoadUserSession ) ) diff --git a/mail-session/data/src/main/kotlin/ch/protonmail/android/mailsession/data/repository/UserSessionRepositoryImpl.kt b/mail-session/data/src/main/kotlin/ch/protonmail/android/mailsession/data/repository/UserSessionRepositoryImpl.kt index 26a7198c11..8c3aa21431 100644 --- a/mail-session/data/src/main/kotlin/ch/protonmail/android/mailsession/data/repository/UserSessionRepositoryImpl.kt +++ b/mail-session/data/src/main/kotlin/ch/protonmail/android/mailsession/data/repository/UserSessionRepositoryImpl.kt @@ -40,8 +40,10 @@ import ch.protonmail.android.mailsession.domain.model.UserSettings import ch.protonmail.android.mailsession.domain.repository.UserSessionRepository import ch.protonmail.android.mailsession.domain.wrapper.MailUserSessionWrapper import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -64,6 +66,7 @@ class UserSessionRepositoryImpl @Inject constructor( // Cache to store MailUserSession per UserId private val userSessionCache = mutableMapOf() + private val userSessionUpdatedTrigger = MutableSharedFlow(replay = 1) private suspend fun getStoredAccount(userId: UserId) = mailSession.getAccount(userId.toLocalUserId()) @@ -123,19 +126,30 @@ class UserSessionRepositoryImpl @Inject constructor( userSessionCache.remove(userId) } + override fun observeUserSessionAvailable(userId: UserId): Flow = userSessionUpdatedTrigger.filter { + it == userId + }.distinctUntilChanged() + override suspend fun getUserSession(userId: UserId): MailUserSessionWrapper? { // Return cached session if it exists - userSessionCache[userId]?.let { return it } + userSessionCache[userId]?.let { + return it + } // Create and store session if not in cache - val session = getStoredAccount(userId).getOrNull()?.let { account -> - mailSession.getAccountSessions(account).getOrNull()?.firstOrNull() + val storedAccount = getStoredAccount(userId).getOrNull() + val session = storedAccount?.let { account -> + val accountSessions = mailSession.getAccountSessions(account).getOrNull() + accountSessions?.firstOrNull() } - val userContext = session?.let { mailSession.userContextFromSession(it) }?.getOrNull() - if (userContext != null) { - userSessionCache[userId] = userContext + // throws network error + val userContext = session?.let { mailSession.userContextFromSession(it) } + userContext?.getOrNull()?.let { + userSessionUpdatedTrigger.emit(userId) + userSessionCache[userId] = it + return it } - return userContext + return null } override suspend fun getUserSettings(userId: UserId): UserSettings? { diff --git a/mail-session/data/src/test/kotlin/ch/protonmail/android/mailsession/data/repository/UserSessionRepositoryImplTest.kt b/mail-session/data/src/test/kotlin/ch/protonmail/android/mailsession/data/repository/UserSessionRepositoryImplTest.kt index a338dc69dc..e4714a8707 100644 --- a/mail-session/data/src/test/kotlin/ch/protonmail/android/mailsession/data/repository/UserSessionRepositoryImplTest.kt +++ b/mail-session/data/src/test/kotlin/ch/protonmail/android/mailsession/data/repository/UserSessionRepositoryImplTest.kt @@ -209,6 +209,33 @@ class UserSessionRepositoryImplTest { coVerify(exactly = 1) { mailSession.userContextFromSession(any()) } } + @Test + fun `caching of a new user session will trigger observer`() = runTest { + // Given + val userId = UserIdTestData.userId + val userId1 = UserIdTestData.userId1 + val expectedMailUserSession = mockk() + val mailSession = mailSessionWithUserSessionStored(expectedMailUserSession) + coEvery { mailSessionRepository.getMailSession() } returns mailSession + + + // When + userSessionRepository.getUserSession(userId) + // Then + userSessionRepository.observeUserSessionAvailable(userId).test { + assertEquals(userId, awaitItem()) + } + + // When + userSessionRepository.getUserSession(userId1) + + // Then + userSessionRepository.observeUserSessionAvailable(userId1).test { + assertEquals(userId1, awaitItem()) + } + } + + private fun mailSessionWithNoUserSessionsStored() = mockk { coEvery { getAccount(any()) } returns DataError.Local.NoDataCached.left() coEvery { getAccounts() } returns emptyList().right() diff --git a/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/repository/UserSessionRepository.kt b/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/repository/UserSessionRepository.kt index 51308c4af3..fb4658711b 100644 --- a/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/repository/UserSessionRepository.kt +++ b/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/repository/UserSessionRepository.kt @@ -68,6 +68,8 @@ interface UserSessionRepository { suspend fun setAutoLockPinCode(autoLockPin: AutoLockPin): Either suspend fun setBiometricAppProtection(): Either + + fun observeUserSessionAvailable(userId: UserId): Flow } fun UserSessionRepository.onAccountState(state: AccountState, initialState: Boolean = true): Flow = diff --git a/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/usecase/GetUserHasValidSession.kt b/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/usecase/GetUserHasValidSession.kt new file mode 100644 index 0000000000..a7e291c18f --- /dev/null +++ b/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/usecase/GetUserHasValidSession.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail 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 Mail 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 Mail. If not, see . + */ + +package ch.protonmail.android.mailsession.domain.usecase + +import ch.protonmail.android.mailsession.domain.repository.UserSessionRepository +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class GetUserHasValidSession @Inject constructor( + private val observePrimaryUserId: ObservePrimaryUserId, + private val userSessionRepository: UserSessionRepository +) { + + suspend operator fun invoke(): Boolean { + val userId = observePrimaryUserId().filterNotNull().first() + val session = userSessionRepository.getUserSession(userId) + return session != null + } +} diff --git a/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/usecase/ObservePrimaryUserIdWithValidSession.kt b/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/usecase/ObservePrimaryUserIdWithValidSession.kt new file mode 100644 index 0000000000..25d6180f4f --- /dev/null +++ b/mail-session/domain/src/main/kotlin/ch/protonmail/android/mailsession/domain/usecase/ObservePrimaryUserIdWithValidSession.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail 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 Mail 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 Mail. If not, see . + */ + +package ch.protonmail.android.mailsession.domain.usecase + +import ch.protonmail.android.mailsession.domain.repository.UserSessionRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ObservePrimaryUserIdWithValidSession @Inject constructor( + private val observePrimaryUserId: ObservePrimaryUserId, + private val userSessionRepository: UserSessionRepository +) { + + operator fun invoke(): Flow = observePrimaryUserId() + .filterNotNull() + .flatMapLatest { userId -> + userSessionRepository.observeUserSessionAvailable(userId) + } +} diff --git a/mail-session/domain/src/test/kotlin/ch/protonmail/android/mailsession/domain/usecase/GetUserHasValidSessionTest.kt b/mail-session/domain/src/test/kotlin/ch/protonmail/android/mailsession/domain/usecase/GetUserHasValidSessionTest.kt new file mode 100644 index 0000000000..e22a4472ed --- /dev/null +++ b/mail-session/domain/src/test/kotlin/ch/protonmail/android/mailsession/domain/usecase/GetUserHasValidSessionTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail 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 Mail 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 Mail. If not, see . + */ + +package ch.protonmail.android.mailsession.domain.usecase + +import ch.protonmail.android.mailsession.domain.repository.UserSessionRepository +import ch.protonmail.android.mailsession.domain.wrapper.MailUserSessionWrapper +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class GetUserHasValidSessionTest { + + private val userSessionRepository = mockk() + + private val observePrimaryUserId = ObservePrimaryUserId(userSessionRepository) + private val getUserHasValidSession = GetUserHasValidSession(observePrimaryUserId, userSessionRepository) + + @Test + fun `given no user session then false`() = runTest { + // Given + every { userSessionRepository.observePrimaryUserId() } returns flowOf(UserIdTestData.userId) + coEvery { userSessionRepository.getUserSession(UserIdTestData.userId) } returns null + + // Then + assertEquals(false, getUserHasValidSession()) + } + + @Test + fun `given user session then true`() = runTest { + // Given + every { userSessionRepository.observePrimaryUserId() } returns flowOf(UserIdTestData.userId) + coEvery { userSessionRepository.getUserSession(UserIdTestData.userId) } returns MailUserSessionWrapper( + mockk() + ) + + // Then + assertEquals(true, getUserHasValidSession()) + } +} diff --git a/mail-session/domain/src/test/kotlin/ch/protonmail/android/mailsession/domain/usecase/ObservePrimaryUserIdWithValidSessionTest.kt b/mail-session/domain/src/test/kotlin/ch/protonmail/android/mailsession/domain/usecase/ObservePrimaryUserIdWithValidSessionTest.kt new file mode 100644 index 0000000000..8fc89e8152 --- /dev/null +++ b/mail-session/domain/src/test/kotlin/ch/protonmail/android/mailsession/domain/usecase/ObservePrimaryUserIdWithValidSessionTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail 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 Mail 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 Mail. If not, see . + */ + +package ch.protonmail.android.mailsession.domain.usecase + +import app.cash.turbine.test +import ch.protonmail.android.mailsession.domain.repository.UserSessionRepository +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class ObservePrimaryUserIdWithValidSessionTest { + + private val userSessionRepository = mockk() + + private val observePrimaryUserId = ObservePrimaryUserId(userSessionRepository) + + + @Test + fun `given userId and session available then return flow of UserId`() = runTest { + // Given + every { userSessionRepository.observePrimaryUserId() } returns flowOf(null, UserIdTestData.userId) + coEvery { userSessionRepository.observeUserSessionAvailable(UserIdTestData.userId) } returns + flowOf(UserIdTestData.userId) + + // Then + ObservePrimaryUserIdWithValidSession(observePrimaryUserId, userSessionRepository).invoke().test { + assertEquals(UserIdTestData.userId, awaitItem()) + awaitComplete() + } + } + + @Test + fun `given null userId then valid session is never returned for UserId`() = runTest { + // Given + every { userSessionRepository.observePrimaryUserId() } returns flowOf(null) + coEvery { userSessionRepository.observeUserSessionAvailable(UserIdTestData.userId) } returns + flowOf(UserIdTestData.userId) + + // Then + ObservePrimaryUserIdWithValidSession(observePrimaryUserId, userSessionRepository).invoke().test { + awaitComplete() + } + } + + @Test + fun `given valid session is never returned for UserId`() = runTest { + // Given + every { userSessionRepository.observePrimaryUserId() } returns flowOf(null, UserIdTestData.userId) + coEvery { userSessionRepository.observeUserSessionAvailable(UserIdTestData.userId) } returns + flowOf() + + // Then + ObservePrimaryUserIdWithValidSession(observePrimaryUserId, userSessionRepository).invoke().test { + awaitComplete() + } + } +} diff --git a/mail-settings/domain/src/test/kotlin/ch/protonmail/android/mailsettings/domain/repository/FakeUserSessionRepository.kt b/mail-settings/domain/src/test/kotlin/ch/protonmail/android/mailsettings/domain/repository/FakeUserSessionRepository.kt index fa0d0e82b9..039e0a33ae 100644 --- a/mail-settings/domain/src/test/kotlin/ch/protonmail/android/mailsettings/domain/repository/FakeUserSessionRepository.kt +++ b/mail-settings/domain/src/test/kotlin/ch/protonmail/android/mailsettings/domain/repository/FakeUserSessionRepository.kt @@ -30,6 +30,7 @@ import ch.protonmail.android.mailsession.domain.model.UserSettings import ch.protonmail.android.mailsession.domain.repository.UserSessionRepository import ch.protonmail.android.mailsession.domain.wrapper.MailUserSessionWrapper import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf import me.proton.core.domain.entity.UserId import me.proton.core.network.domain.session.SessionId @@ -95,4 +96,6 @@ internal class FakeUserSessionRepository( override suspend fun setBiometricAppProtection(): Either { TODO("Not yet implemented") } + + override fun observeUserSessionAvailable(userId: UserId): Flow = observePrimaryUserId().filterNotNull() }