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
This commit is contained in:
Seren
2025-10-03 08:21:03 +00:00
committed by Seren Matthews
parent e90c42ea40
commit 1ae5e69f02
17 changed files with 430 additions and 19 deletions
@@ -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 = {}
)
}
}
@@ -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()
@@ -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<String>())
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<MailboxState>.observeUnreadFilterState() =
this.map { it.unreadFilterState as? UnreadFilterState.Data }
.mapNotNull { it?.isFilterEnabled }
@@ -77,6 +77,7 @@ sealed interface MailboxListState {
}
data object Loading : MailboxListState
data object CouldNotLoadUserSession : MailboxListState
companion object {
const val maxItemSelectionLimit = 100
@@ -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,
@@ -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
}
@@ -34,6 +34,7 @@
<string name="mailbox_default_sender">(No Sender)</string>
<string name="mailbox_error_message_offline">You are offline, unable to retrieve items</string>
<string name="mailbox_error_message_generic">Loading mailbox failed</string>
<string name="mailbox_error_message_user_session">Unable to load session. Please check your connection and try again.</string>
<string name="mailbox_error_message_unexpected">Something went unexpectedly wrong</string>
<string name="mailbox_is_empty_title">No messages</string>
<string name="trash_is_empty_title">Nothing in Trash</string>
@@ -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<ObservePrimaryUserId> {
private val observePrimaryUserId = mockk<ObservePrimaryUserIdWithValidSession> {
every { this@mockk.invoke() } returns flowOf(userId)
}
@@ -244,6 +245,9 @@ internal class MailboxViewModelTest {
private val deleteMessages = mockk<DeleteMessages>()
private val starMessages = mockk<StarMessages>()
private val starConversations = mockk<StarConversations>()
private val getUserHasValidSession = mockk<GetUserHasValidSession> {
coEvery { this@mockk.invoke() } returns true
}
private val unStarMessages = mockk<UnStarMessages>()
private val unStarConversations = mockk<UnStarConversations>()
private val getBottomSheetActions = mockk<GetBottomSheetActions>()
@@ -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<ActionUiModel>().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<ActionUiModel>().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 {
@@ -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
)
)
@@ -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<UserId, MailUserSessionWrapper>()
private val userSessionUpdatedTrigger = MutableSharedFlow<UserId>(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<UserId> = 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? {
@@ -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<MailUserSessionWrapper>()
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<MailSessionWrapper> {
coEvery { getAccount(any()) } returns DataError.Local.NoDataCached.left()
coEvery { getAccounts() } returns emptyList<StoredAccount>().right()
@@ -68,6 +68,8 @@ interface UserSessionRepository {
suspend fun setAutoLockPinCode(autoLockPin: AutoLockPin): Either<SetAutoLockPinError, Unit>
suspend fun setBiometricAppProtection(): Either<DataError, Unit>
fun observeUserSessionAvailable(userId: UserId): Flow<UserId>
}
fun UserSessionRepository.onAccountState(state: AccountState, initialState: Boolean = true): Flow<Account> =
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<UserId?> = observePrimaryUserId()
.filterNotNull()
.flatMapLatest { userId ->
userSessionRepository.observeUserSessionAvailable(userId)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<UserSessionRepository>()
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())
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<UserSessionRepository>()
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()
}
}
}
@@ -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<DataError, Unit> {
TODO("Not yet implemented")
}
override fun observeUserSessionAvailable(userId: UserId): Flow<UserId> = observePrimaryUserId().filterNotNull()
}