From 3b74d7aa6cd280c42b12ae99301ba4e1a7e67bf5 Mon Sep 17 00:00:00 2001 From: mena Date: Thu, 15 Jan 2026 13:24:01 +0200 Subject: [PATCH 01/16] feat(core): implement automatic logout after 5 minutes of inactivity - Add SessionManager to track user inactivity - Add SessionHandler to detect touch events - Integrate logout trigger in ComposeApp - Stop session timer on Auth screens to prevent loops --- .../kotlin/cmp/navigation/ComposeApp.kt | 41 +++++++----- .../cmp/navigation/ComposeAppViewModel.kt | 10 +++ .../org/mifos/mobile/core/common/Constants.kt | 2 + .../mobile/core/common/SessionManager.kt | 66 +++++++++++++++++++ .../core/common/di/DispatchersModule.kt | 2 + core/ui/build.gradle.kts | 1 + .../mobile/core/ui/utils/SessionHandler.kt | 50 ++++++++++++++ .../feature/auth/login/LoginViewModel.kt | 3 + .../OtpAuthenticationViewModel.kt | 3 + .../RecoverPasswordViewModel.kt | 9 ++- .../registration/RegistrationViewModel.kt | 5 ++ .../setNewPassword/SetPasswordViewModel.kt | 9 ++- .../auth/uploadId/UploadIdViewmodel.kt | 9 ++- .../mobile/feature/home/HomeViewModel.kt | 3 + 14 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt create mode 100644 core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt index 4c3bc2876..7b3355b85 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt @@ -23,11 +23,14 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import cmp.navigation.rootnav.RootNavScreen +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme import org.mifos.mobile.core.model.MifosThemeConfig import org.mifos.mobile.core.ui.utils.EventsEffect import org.mifos.mobile.core.ui.utils.NetworkBanner +import org.mifos.mobile.core.ui.utils.SessionHandler @Composable fun ComposeApp( @@ -35,6 +38,7 @@ fun ComposeApp( handleAppLocale: (locale: String?) -> Unit, onSplashScreenRemoved: () -> Unit, modifier: Modifier = Modifier, + sessionManager: SessionManager = koinInject(), viewModel: ComposeAppViewModel = koinViewModel(), ) { val uiState by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -59,25 +63,32 @@ fun ComposeApp( androidTheme = uiState.isAndroidTheme, shouldDisplayDynamicTheming = uiState.isDynamicColorsEnabled, ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), + SessionHandler( + onLogout = { + viewModel.trySendAction(AppAction.Logout) + }, + sessionManager = sessionManager, ) { - Column( - modifier = modifier + Box( + modifier = Modifier .fillMaxSize() - .statusBarsPadding(), + .background(MaterialTheme.colorScheme.surface), ) { - NetworkBanner( - bannerState = uiState.networkBanner, - modifier = Modifier.fillMaxWidth(), - ) + Column( + modifier = modifier + .fillMaxSize() + .statusBarsPadding(), + ) { + NetworkBanner( + bannerState = uiState.networkBanner, + modifier = Modifier.fillMaxWidth(), + ) - RootNavScreen( - modifier = Modifier, - onSplashScreenRemoved = onSplashScreenRemoved, - ) + RootNavScreen( + modifier = Modifier, + onSplashScreenRemoved = onSplashScreenRemoved, + ) + } } } } diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt index 10e8fb819..c5c3ea4bc 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt @@ -146,6 +146,14 @@ class ComposeAppViewModel( is AppAction.Internal.SystemThemeUpdate -> handleSystemThemeUpdate(action) is AppAction.Internal.TimeBasedThemeUpdate -> handleTimeBasedThemeUpdate(action) + + is AppAction.Logout -> handleUserInactivityLogout() + } + } + + private fun handleUserInactivityLogout() { + viewModelScope.launch { + userPreferencesRepository.logOut() } } @@ -261,4 +269,6 @@ sealed interface AppAction { val timeBasedTheme: TimeBasedTheme, ) : Internal() } + + data object Logout : AppAction } diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt index 493178e1e..e97db1127 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt @@ -95,6 +95,8 @@ object Constants { const val MAKE_PAYMENT = "make_payments" const val LOAN_SUMMARY = "loan_summary" + const val TIMEOUT_SESSION_MS = 5 * 60 * 1000L + // Settings constants const val PROFILE = "profile" const val PASSWORD = "password" diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt new file mode 100644 index 000000000..351070ccc --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.mobile.core.common + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +class SessionManager { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var lastInteractionTime = 0L + private var isMonitoring = false + private val timeoutMs = Constants.TIMEOUT_SESSION_MS + + private val _logoutEvent = MutableSharedFlow() + val logoutEvent = _logoutEvent.asSharedFlow() + + @OptIn(ExperimentalTime::class) + fun startSession() { + if (isMonitoring) return + isMonitoring = true + lastInteractionTime = Clock.System.now().toEpochMilliseconds() + startHeartbeat() + } + + @OptIn(ExperimentalTime::class) + fun userInteracted() { + if (!isMonitoring) return + lastInteractionTime = Clock.System.now().toEpochMilliseconds() + } + + fun stopSession() { + isMonitoring = false + } + + @OptIn(ExperimentalTime::class) + private fun startHeartbeat() { + scope.launch { + while (isMonitoring) { + val currentTime = Clock.System.now().toEpochMilliseconds() + if (currentTime - lastInteractionTime >= timeoutMs) { + withContext(Dispatchers.Main) { + _logoutEvent.emit(Unit) + } + stopSession() + break + } + delay(30_000) + } + } + } +} diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt index fad6bab48..a3243ac90 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt @@ -17,6 +17,7 @@ import org.koin.core.module.Module import org.koin.core.qualifier.named import org.koin.dsl.module import org.mifos.mobile.core.common.MifosDispatchers +import org.mifos.mobile.core.common.SessionManager val DispatchersModule = module { includes(ioDispatcherModule) @@ -25,6 +26,7 @@ val DispatchersModule = module { single(named("ApplicationScope")) { CoroutineScope(SupervisorJob() + Dispatchers.Default) } + single { SessionManager() } } expect val ioDispatcherModule: Module diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index f189c14c0..2d7127732 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -32,6 +32,7 @@ kotlin{ commonMain.dependencies { api(projects.core.designsystem) implementation(projects.core.model) + implementation(projects.core.common) api(libs.kotlinx.datetime) implementation(libs.jb.lifecycle.compose) implementation(libs.jb.composeViewmodel) diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt new file mode 100644 index 000000000..875e04163 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.mobile.core.ui.utils + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.pointerInput +import kotlinx.coroutines.flow.collectLatest +import org.mifos.mobile.core.common.SessionManager + +@Composable +fun SessionHandler( + sessionManager: SessionManager, + onLogout: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + LaunchedEffect(Unit) { + sessionManager.logoutEvent.collectLatest { + onLogout() + } + } + + Box( + modifier = modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + + if (event.changes.any { it.changedToDown() }) { + sessionManager.userInteracted() + } + } + } + }, + ) { + content() + } +} diff --git a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/login/LoginViewModel.kt b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/login/LoginViewModel.kt index c4279802c..e5b13e209 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/login/LoginViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/login/LoginViewModel.kt @@ -20,6 +20,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_sign_in_password_er import mifos_mobile.feature.auth.generated.resources.feature_sign_in_username_error import org.jetbrains.compose.resources.StringResource import org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.data.repository.UserAuthRepository import org.mifos.mobile.core.datastore.UserPreferencesRepository import org.mifos.mobile.core.datastore.model.UserData @@ -30,6 +31,7 @@ import org.mifos.mobile.core.ui.utils.ScreenUiState class LoginViewModel( private val userAuthRepositoryImpl: UserAuthRepository, private val userPreferencesRepositoryImpl: UserPreferencesRepository, + sessionManager: SessionManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = LoginState(uiState = ScreenUiState.Success), @@ -38,6 +40,7 @@ class LoginViewModel( private var loginJob: Job? = null init { + sessionManager.stopSession() savedStateHandle.get("username")?.let { trySendAction(LoginAction.UsernameChanged(it)) } diff --git a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/otpAuthentication/OtpAuthenticationViewModel.kt b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/otpAuthentication/OtpAuthenticationViewModel.kt index c05bf3a61..fe8060c3e 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/otpAuthentication/OtpAuthenticationViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/otpAuthentication/OtpAuthenticationViewModel.kt @@ -31,6 +31,7 @@ import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.mifos.mobile.core.common.Constants import org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.data.repository.UserAuthRepository import org.mifos.mobile.core.model.EventType import org.mifos.mobile.core.ui.utils.BaseViewModel @@ -39,11 +40,13 @@ import org.mifos.mobile.feature.auth.login.LoginRoute internal class OtpAuthenticationViewModel( private val userAuthRepositoryImpl: UserAuthRepository, + sessionManager: SessionManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = OtpAuthState(dialogState = null), ) { init { + sessionManager.stopSession() val nextRoute = savedStateHandle.toRoute() mutableStateFlow.update { diff --git a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/recoverPassword/RecoverPasswordViewModel.kt b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/recoverPassword/RecoverPasswordViewModel.kt index ab9ad79d8..a83bd95b2 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/recoverPassword/RecoverPasswordViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/recoverPassword/RecoverPasswordViewModel.kt @@ -24,6 +24,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_recover_now_invalid import mifos_mobile.feature.auth.generated.resources.feature_recover_now_phone_number_error import mifos_mobile.feature.auth.generated.resources.feature_recover_now_phone_number_required import org.jetbrains.compose.resources.StringResource +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.ui.utils.BaseViewModel import org.mifos.mobile.core.ui.utils.ScreenUiState import org.mifos.mobile.core.ui.utils.ValidationHelper @@ -31,11 +32,17 @@ import org.mifos.mobile.feature.auth.setNewPassword.SetPasswordRoute const val PHONE_NUMBER_LENGTH = 10 -internal class RecoverPasswordViewModel : +internal class RecoverPasswordViewModel( + sessionManager: SessionManager, +) : BaseViewModel( initialState = RecoverPasswordState(), ) { + init { + sessionManager.stopSession() + } + private var validationJob: Job? = null override fun handleAction(action: RecoverPasswordAction) { diff --git a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/registration/RegistrationViewModel.kt b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/registration/RegistrationViewModel.kt index 30e9f9ca4..29ce05c0e 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/registration/RegistrationViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/registration/RegistrationViewModel.kt @@ -27,6 +27,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_signup_error_passwo import mifos_mobile.feature.auth.generated.resources.feature_signup_error_password_short import org.jetbrains.compose.resources.StringResource import org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.data.repository.UserAuthRepository import org.mifos.mobile.core.model.entity.register.RegisterPayload import org.mifos.mobile.core.ui.PasswordStrengthState @@ -48,10 +49,14 @@ import org.mifos.mobile.core.ui.utils.ValidationHelper @Suppress("TooManyFunctions") class RegistrationViewModel( private val userAuthRepositoryImpl: UserAuthRepository, + sessionManager: SessionManager, ) : BaseViewModel( initialState = SignUpState(), ) { + init { + sessionManager.stopSession() + } private var validationJob: Job? = null private var passwordStrengthJob: Job = Job() diff --git a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/setNewPassword/SetPasswordViewModel.kt b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/setNewPassword/SetPasswordViewModel.kt index a226c27b8..e7153e486 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/setNewPassword/SetPasswordViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/setNewPassword/SetPasswordViewModel.kt @@ -25,6 +25,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_signup_error_passwo import mifos_mobile.feature.auth.generated.resources.feature_signup_error_password_required_error import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.model.EventType import org.mifos.mobile.core.ui.PasswordStrengthState import org.mifos.mobile.core.ui.utils.BaseViewModel @@ -34,10 +35,16 @@ import org.mifos.mobile.core.ui.utils.PasswordStrengthResult import org.mifos.mobile.core.ui.utils.ScreenUiState import org.mifos.mobile.feature.auth.login.LoginRoute -internal class SetPasswordViewModel : BaseViewModel( +internal class SetPasswordViewModel( + sessionManager: SessionManager, +) : BaseViewModel( initialState = SetPasswordState(dialogState = null), ) { + init { + sessionManager.stopSession() + } + private var validationJob: Job? = null private var passwordStrengthJob: Job = Job() diff --git a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/uploadId/UploadIdViewmodel.kt b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/uploadId/UploadIdViewmodel.kt index f585e6df7..98c46cebf 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/uploadId/UploadIdViewmodel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/uploadId/UploadIdViewmodel.kt @@ -31,15 +31,22 @@ import mifos_mobile.feature.auth.generated.resources.feature_upload_id_error_mob import mifos_mobile.feature.auth.generated.resources.feature_upload_id_error_photo_required import mifos_mobile.feature.auth.generated.resources.feature_upload_id_upload_failed import org.jetbrains.compose.resources.StringResource +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.common.toBase64DataUri import org.mifos.mobile.core.ui.utils.BaseViewModel import org.mifos.mobile.core.ui.utils.ValidationHelper import org.mifos.mobile.feature.auth.recoverPassword.PHONE_NUMBER_LENGTH -internal class UploadIdViewModel : +internal class UploadIdViewModel( + sessionManager: SessionManager, +) : BaseViewModel( initialState = UploadIdUiState(dialogState = null), ) { + + init { + sessionManager.stopSession() + } override fun handleAction(action: UploadIdAction) { when (action) { UploadIdAction.OnBackClick -> sendEvent(UploadIdEvent.BackClick) diff --git a/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeViewModel.kt index 379b56090..e0e282bf7 100644 --- a/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeViewModel.kt @@ -21,6 +21,7 @@ import mifos_mobile.feature.home.generated.resources.feature_server_error import org.jetbrains.compose.resources.StringResource import org.mifos.mobile.core.common.CurrencyFormatter import org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.data.repository.HomeRepository import org.mifos.mobile.core.data.util.NetworkMonitor import org.mifos.mobile.core.datastore.UserPreferencesRepository @@ -45,6 +46,7 @@ import org.mifos.mobile.core.ui.utils.BaseViewModel internal class HomeViewModel( private val homeRepositoryImpl: HomeRepository, private val networkMonitor: NetworkMonitor, + private val sessionManager: SessionManager, userPreferencesRepositoryImpl: UserPreferencesRepository, ) : BaseViewModel( initialState = HomeState( @@ -58,6 +60,7 @@ internal class HomeViewModel( private var isHandlingNetworkChange = false init { + sessionManager.startSession() observeNetworkStatus() } From 5a64e1d40e75d0d835d3cc587f828f723c10643f Mon Sep 17 00:00:00 2001 From: mena Date: Thu, 15 Jan 2026 14:21:17 +0200 Subject: [PATCH 02/16] refactor(core): implement atomic state management in `SessionManager` and track key events in `SessionHandler` --- .../mobile/core/common/SessionManager.kt | 37 ++++++++++++------- .../mobile/core/ui/utils/SessionHandler.kt | 8 ++++ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index 351070ccc..29f570e7f 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -17,42 +17,51 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.Clock import kotlin.time.ExperimentalTime class SessionManager { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private var lastInteractionTime = 0L - private var isMonitoring = false + + @OptIn(ExperimentalAtomicApi::class) + private val lastInteractionTime = AtomicLong(0L) + + @OptIn(ExperimentalAtomicApi::class) + private val isMonitoring = AtomicBoolean(false) private val timeoutMs = Constants.TIMEOUT_SESSION_MS private val _logoutEvent = MutableSharedFlow() val logoutEvent = _logoutEvent.asSharedFlow() - @OptIn(ExperimentalTime::class) + @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) fun startSession() { - if (isMonitoring) return - isMonitoring = true - lastInteractionTime = Clock.System.now().toEpochMilliseconds() - startHeartbeat() + if (isMonitoring.compareAndSet(expectedValue = false, newValue = true)) { + lastInteractionTime.store(Clock.System.now().toEpochMilliseconds()) + startHeartbeat() + } } - @OptIn(ExperimentalTime::class) + @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) fun userInteracted() { - if (!isMonitoring) return - lastInteractionTime = Clock.System.now().toEpochMilliseconds() + if (isMonitoring.load()) { + lastInteractionTime.store(Clock.System.now().toEpochMilliseconds()) + } } + @OptIn(ExperimentalAtomicApi::class) fun stopSession() { - isMonitoring = false + isMonitoring.store(false) } - @OptIn(ExperimentalTime::class) + @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) private fun startHeartbeat() { scope.launch { - while (isMonitoring) { + while (isMonitoring.load()) { val currentTime = Clock.System.now().toEpochMilliseconds() - if (currentTime - lastInteractionTime >= timeoutMs) { + if (currentTime - lastInteractionTime.load() >= timeoutMs) { withContext(Dispatchers.Main) { _logoutEvent.emit(Unit) } diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt index 875e04163..eecb6f4f8 100644 --- a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.pointerInput @@ -43,6 +46,11 @@ fun SessionHandler( } } } + }.onKeyEvent { event -> + if (event.type == KeyEventType.KeyUp) { + sessionManager.userInteracted() + } + false }, ) { content() From c285dd852f5b553da247459b6f6d33d59ba48055 Mon Sep 17 00:00:00 2001 From: mena Date: Thu, 15 Jan 2026 16:14:04 +0200 Subject: [PATCH 03/16] refactor(core): improve session heartbeat management in `SessionManager` --- .../org/mifos/mobile/core/common/SessionManager.kt | 10 +++++++--- .../feature/auth/registration/RegistrationViewModel.kt | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index 29f570e7f..1a7d0a8ad 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -11,6 +11,7 @@ package org.mifos.mobile.core.common import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -26,6 +27,7 @@ import kotlin.time.ExperimentalTime class SessionManager { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var heartbeatJob: Job? = null @OptIn(ExperimentalAtomicApi::class) private val lastInteractionTime = AtomicLong(0L) @@ -40,7 +42,7 @@ class SessionManager { fun startSession() { if (isMonitoring.compareAndSet(expectedValue = false, newValue = true)) { lastInteractionTime.store(Clock.System.now().toEpochMilliseconds()) - startHeartbeat() + heartbeatJob = startHeartbeat() } } @@ -54,11 +56,13 @@ class SessionManager { @OptIn(ExperimentalAtomicApi::class) fun stopSession() { isMonitoring.store(false) + heartbeatJob?.cancel() + heartbeatJob = null } @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) - private fun startHeartbeat() { - scope.launch { + private fun startHeartbeat(): Job { + return scope.launch { while (isMonitoring.load()) { val currentTime = Clock.System.now().toEpochMilliseconds() if (currentTime - lastInteractionTime.load() >= timeoutMs) { diff --git a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/registration/RegistrationViewModel.kt b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/registration/RegistrationViewModel.kt index 29ce05c0e..185eb49fa 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/registration/RegistrationViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifos/mobile/feature/auth/registration/RegistrationViewModel.kt @@ -57,6 +57,7 @@ class RegistrationViewModel( init { sessionManager.stopSession() } + private var validationJob: Job? = null private var passwordStrengthJob: Job = Job() From 8076c171dcb75fc3c7af04891501c0fdf84745ff Mon Sep 17 00:00:00 2001 From: mena Date: Thu, 15 Jan 2026 16:44:31 +0200 Subject: [PATCH 04/16] chore: add empty line for better readability in `SessionManager` --- .../kotlin/org/mifos/mobile/core/common/SessionManager.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index 1a7d0a8ad..8bc9bba8a 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -28,6 +28,7 @@ class SessionManager { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var heartbeatJob: Job? = null + @OptIn(ExperimentalAtomicApi::class) private val lastInteractionTime = AtomicLong(0L) From 9ab91e9c9ea51a63ccb3e225fbe1bda9ad8e614e Mon Sep 17 00:00:00 2001 From: mena Date: Fri, 16 Jan 2026 20:13:51 +0200 Subject: [PATCH 05/16] feat: implement session expired dialog and improve interaction handling - Added `session_expired_title` and `session_expired_message` strings in English and Spanish. - Refactored `SessionManager` to use `isExpired` `StateFlow` instead of a logout event flow. - Added `MifosBasicDialog` in `ComposeApp` to notify users when their session has expired. - Updated `SessionHandler` to intercept and consume pointer and key events when the session is expired. - Simplified `SessionHandler` by removing the explicit `onLogout` callback in favor of managing logout via the expiration dialog. --- .../composeResources/values-es/strings.xml | 2 ++ .../composeResources/values/strings.xml | 2 ++ .../kotlin/cmp/navigation/ComposeApp.kt | 28 +++++++++++++++++-- .../mobile/core/common/SessionManager.kt | 17 ++++++----- .../mobile/core/ui/utils/SessionHandler.kt | 27 ++++++++++-------- 5 files changed, 52 insertions(+), 24 deletions(-) diff --git a/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml b/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml index d7af1aef4..586902374 100644 --- a/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml +++ b/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml @@ -14,4 +14,6 @@ Transferir Perfil ⚠️ No estás conectado a Internet + Sesión Expirada + Por su seguridad, se ha cerrado la sesión debido a inactividad. \ No newline at end of file diff --git a/cmp-navigation/src/commonMain/composeResources/values/strings.xml b/cmp-navigation/src/commonMain/composeResources/values/strings.xml index 34e4eb93a..bbbcdd7b1 100644 --- a/cmp-navigation/src/commonMain/composeResources/values/strings.xml +++ b/cmp-navigation/src/commonMain/composeResources/values/strings.xml @@ -14,4 +14,6 @@ Transfer Profile ⚠️ You aren’t connected to the internet + Session Expired + For your security, you have been logged out due to inactivity. \ No newline at end of file diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt index 7b3355b85..f7e23bc2d 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt @@ -23,14 +23,20 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import cmp.navigation.rootnav.RootNavScreen +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.mifos.mobile.core.common.SessionManager +import org.mifos.mobile.core.designsystem.component.BasicDialogState +import org.mifos.mobile.core.designsystem.component.MifosBasicDialog import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme import org.mifos.mobile.core.model.MifosThemeConfig import org.mifos.mobile.core.ui.utils.EventsEffect import org.mifos.mobile.core.ui.utils.NetworkBanner import org.mifos.mobile.core.ui.utils.SessionHandler +import org.mifos.mobile.navigation.generated.resources.Res +import org.mifos.mobile.navigation.generated.resources.session_expired_message +import org.mifos.mobile.navigation.generated.resources.session_expired_title @Composable fun ComposeApp( @@ -43,6 +49,7 @@ fun ComposeApp( ) { val uiState by viewModel.stateFlow.collectAsStateWithLifecycle() + val isSessionExpired by sessionManager.isExpired.collectAsStateWithLifecycle() EventsEffect(eventFlow = viewModel.eventFlow) { event -> when (event) { is AppEvent.ShowToast -> {} @@ -63,10 +70,25 @@ fun ComposeApp( androidTheme = uiState.isAndroidTheme, shouldDisplayDynamicTheming = uiState.isDynamicColorsEnabled, ) { + val dialogState = if (isSessionExpired) { + BasicDialogState.Shown( + title = stringResource(Res.string.session_expired_title), + message = stringResource(Res.string.session_expired_message), + ) + } else { + BasicDialogState.Hidden + } + + if (isSessionExpired) { + MifosBasicDialog( + visibilityState = dialogState, + onDismissRequest = { + viewModel.trySendAction(AppAction.Logout) + }, + ) + } + SessionHandler( - onLogout = { - viewModel.trySendAction(AppAction.Logout) - }, sessionManager = sessionManager, ) { Box( diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index 8bc9bba8a..813217ba8 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -14,10 +14,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicLong import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -36,12 +35,13 @@ class SessionManager { private val isMonitoring = AtomicBoolean(false) private val timeoutMs = Constants.TIMEOUT_SESSION_MS - private val _logoutEvent = MutableSharedFlow() - val logoutEvent = _logoutEvent.asSharedFlow() + private val _isExpired = MutableStateFlow(false) + val isExpired = _isExpired.asStateFlow() @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) fun startSession() { if (isMonitoring.compareAndSet(expectedValue = false, newValue = true)) { + _isExpired.value = false lastInteractionTime.store(Clock.System.now().toEpochMilliseconds()) heartbeatJob = startHeartbeat() } @@ -49,6 +49,7 @@ class SessionManager { @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) fun userInteracted() { + if (_isExpired.value) return if (isMonitoring.load()) { lastInteractionTime.store(Clock.System.now().toEpochMilliseconds()) } @@ -57,6 +58,7 @@ class SessionManager { @OptIn(ExperimentalAtomicApi::class) fun stopSession() { isMonitoring.store(false) + _isExpired.value = false heartbeatJob?.cancel() heartbeatJob = null } @@ -67,10 +69,7 @@ class SessionManager { while (isMonitoring.load()) { val currentTime = Clock.System.now().toEpochMilliseconds() if (currentTime - lastInteractionTime.load() >= timeoutMs) { - withContext(Dispatchers.Main) { - _logoutEvent.emit(Unit) - } - stopSession() + _isExpired.value = true break } delay(30_000) diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt index eecb6f4f8..8db12b020 100644 --- a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -11,29 +11,24 @@ package org.mifos.mobile.core.ui.utils import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.pointerInput -import kotlinx.coroutines.flow.collectLatest +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.mifos.mobile.core.common.SessionManager @Composable fun SessionHandler( sessionManager: SessionManager, - onLogout: () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - LaunchedEffect(Unit) { - sessionManager.logoutEvent.collectLatest { - onLogout() - } - } + val isExpired by sessionManager.isExpired.collectAsStateWithLifecycle() Box( modifier = modifier.pointerInput(Unit) { @@ -42,13 +37,21 @@ fun SessionHandler( val event = awaitPointerEvent(pass = PointerEventPass.Initial) if (event.changes.any { it.changedToDown() }) { - sessionManager.userInteracted() + if (isExpired) { + event.changes.forEach { it.consume() } + } else { + sessionManager.userInteracted() + } } } } - }.onKeyEvent { event -> + }.onPreviewKeyEvent { event -> if (event.type == KeyEventType.KeyUp) { - sessionManager.userInteracted() + if (isExpired) { + return@onPreviewKeyEvent true + } else { + sessionManager.userInteracted() + } } false }, From b25312fdb4145ec4fbcb81a768de7287a3ae9f59 Mon Sep 17 00:00:00 2001 From: mena Date: Fri, 16 Jan 2026 20:47:26 +0200 Subject: [PATCH 06/16] fix(ui): update `pointerInput` key to `isExpired` in `SessionHandler` --- .../kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt index 8db12b020..df57fa3f5 100644 --- a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -31,7 +31,7 @@ fun SessionHandler( val isExpired by sessionManager.isExpired.collectAsStateWithLifecycle() Box( - modifier = modifier.pointerInput(Unit) { + modifier = modifier.pointerInput(isExpired) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent(pass = PointerEventPass.Initial) From d6619d96789781b86ddff22d871bb6ef279635fd Mon Sep 17 00:00:00 2001 From: mena Date: Tue, 20 Jan 2026 16:27:48 +0200 Subject: [PATCH 07/16] refactor: replace `MaterialTheme` with `KptTheme` and add blur effect to `SessionHandler` when expired --- .../kotlin/cmp/navigation/ComposeApp.kt | 4 +- .../mobile/core/ui/utils/SessionHandler.kt | 44 +++++++++++-------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt index f7e23bc2d..eb1167497 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,6 +36,7 @@ import org.mifos.mobile.core.ui.utils.SessionHandler import org.mifos.mobile.navigation.generated.resources.Res import org.mifos.mobile.navigation.generated.resources.session_expired_message import org.mifos.mobile.navigation.generated.resources.session_expired_title +import template.core.base.designsystem.theme.KptTheme @Composable fun ComposeApp( @@ -94,7 +94,7 @@ fun ComposeApp( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), + .background(KptTheme.colorScheme.surface), ) { Column( modifier = modifier diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt index df57fa3f5..f2bddd908 100644 --- a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -10,9 +10,11 @@ package org.mifos.mobile.core.ui.utils import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type @@ -21,6 +23,7 @@ import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.pointerInput import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.mifos.mobile.core.common.SessionManager +import template.core.base.designsystem.theme.KptTheme @Composable fun SessionHandler( @@ -31,30 +34,33 @@ fun SessionHandler( val isExpired by sessionManager.isExpired.collectAsStateWithLifecycle() Box( - modifier = modifier.pointerInput(isExpired) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(pass = PointerEventPass.Initial) + modifier = modifier + .fillMaxSize() + .then(if (isExpired) Modifier.blur(KptTheme.spacing.md) else Modifier) + .pointerInput(isExpired) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) - if (event.changes.any { it.changedToDown() }) { - if (isExpired) { - event.changes.forEach { it.consume() } - } else { - sessionManager.userInteracted() + if (event.changes.any { it.changedToDown() }) { + if (isExpired) { + event.changes.forEach { it.consume() } + } else { + sessionManager.userInteracted() + } } } } - } - }.onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyUp) { - if (isExpired) { - return@onPreviewKeyEvent true - } else { - sessionManager.userInteracted() + }.onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp) { + if (isExpired) { + return@onPreviewKeyEvent true + } else { + sessionManager.userInteracted() + } } - } - false - }, + false + }, ) { content() } From 8ce38dc83361ade0d99973f18dde8b7cff5b71b6 Mon Sep 17 00:00:00 2001 From: mena Date: Wed, 21 Jan 2026 18:06:33 +0200 Subject: [PATCH 08/16] feat: implement persistent session expiration handling - Added `SessionStorage` interface and `DatastoreSessionStorage` implementation to persist last interaction time. - Updated `SessionManager` to track session across app restarts (cold starts) using `SessionStorage`. - Introduced `shouldShowDialog` to `SessionManager` to control whether to show an expiration dialog or logout silently based on the session state. - Added a throttled disk-save mechanism for session time to minimize I/O overhead. - Enhanced `ComposeApp` with a lifecycle observer to trigger manual session expiration checks on app start. - Refactored `SessionHandler` to consistently consume pointer events when the session is expired. - Updated `UserPreferencesDataSource` and repository to support storing and retrieving the last session timestamp. --- .../kotlin/cmp/navigation/ComposeApp.kt | 28 ++++- .../org/mifos/mobile/core/common/Constants.kt | 1 + .../mobile/core/common/SessionManager.kt | 101 ++++++++++++++++-- .../core/common/di/DispatchersModule.kt | 2 +- .../core/datastore/DatastoreSessionStorage.kt | 26 +++++ .../datastore/UserPreferencesDataSource.kt | 20 ++++ .../datastore/UserPreferencesRepository.kt | 3 + .../UserPreferencesRepositoryImpl.kt | 7 ++ .../core/datastore/di/PreferenceModule.kt | 4 + .../mobile/core/ui/utils/SessionHandler.kt | 10 +- 10 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/DatastoreSessionStorage.kt diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt index eb1167497..780bbdd41 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt @@ -17,9 +17,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import cmp.navigation.rootnav.RootNavScreen import org.jetbrains.compose.resources.stringResource @@ -49,7 +53,22 @@ fun ComposeApp( ) { val uiState by viewModel.stateFlow.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + sessionManager.checkExpirationNow() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + val isSessionExpired by sessionManager.isExpired.collectAsStateWithLifecycle() + val showDialog by sessionManager.shouldShowDialog.collectAsStateWithLifecycle() EventsEffect(eventFlow = viewModel.eventFlow) { event -> when (event) { is AppEvent.ShowToast -> {} @@ -70,7 +89,7 @@ fun ComposeApp( androidTheme = uiState.isAndroidTheme, shouldDisplayDynamicTheming = uiState.isDynamicColorsEnabled, ) { - val dialogState = if (isSessionExpired) { + val dialogState = if (isSessionExpired && showDialog) { BasicDialogState.Shown( title = stringResource(Res.string.session_expired_title), message = stringResource(Res.string.session_expired_message), @@ -79,7 +98,7 @@ fun ComposeApp( BasicDialogState.Hidden } - if (isSessionExpired) { + if (dialogState is BasicDialogState.Shown) { MifosBasicDialog( visibilityState = dialogState, onDismissRequest = { @@ -88,6 +107,11 @@ fun ComposeApp( ) } + LaunchedEffect(isSessionExpired, showDialog) { + if (isSessionExpired && !showDialog) { + viewModel.trySendAction(AppAction.Logout) + } + } SessionHandler( sessionManager = sessionManager, ) { diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt index e97db1127..1d08d9276 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt @@ -96,6 +96,7 @@ object Constants { const val LOAN_SUMMARY = "loan_summary" const val TIMEOUT_SESSION_MS = 5 * 60 * 1000L + const val THROTTLE_DISK_SAVE_MS = 2000L // Settings constants const val PROFILE = "profile" diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index 813217ba8..2c6414c36 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -14,23 +14,38 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicLong import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.Clock import kotlin.time.ExperimentalTime -class SessionManager { +interface SessionStorage { + suspend fun saveSessionTime(time: Long) + fun getSessionTime(): Flow +} + +class SessionManager( + private val sessionStorage: SessionStorage, +) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val checkLock = Mutex() private var heartbeatJob: Job? = null @OptIn(ExperimentalAtomicApi::class) private val lastInteractionTime = AtomicLong(0L) + @OptIn(ExperimentalAtomicApi::class) + private val lastDiskSaveTime = AtomicLong(0L) + @OptIn(ExperimentalAtomicApi::class) private val isMonitoring = AtomicBoolean(false) private val timeoutMs = Constants.TIMEOUT_SESSION_MS @@ -38,12 +53,29 @@ class SessionManager { private val _isExpired = MutableStateFlow(false) val isExpired = _isExpired.asStateFlow() + @OptIn(ExperimentalAtomicApi::class) + private val isColdStart = AtomicBoolean(true) + + private val _shouldShowDialog = MutableStateFlow(false) + val shouldShowDialog = _shouldShowDialog.asStateFlow() + @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) fun startSession() { if (isMonitoring.compareAndSet(expectedValue = false, newValue = true)) { _isExpired.value = false - lastInteractionTime.store(Clock.System.now().toEpochMilliseconds()) - heartbeatJob = startHeartbeat() + scope.launch { + val savedTime = sessionStorage.getSessionTime().first() + val now = Clock.System.now().toEpochMilliseconds() + + val effectiveTime = if (savedTime == 0L) now else savedTime + + lastInteractionTime.store(effectiveTime) + checkExpirationInternal() + + if (!_isExpired.value) { + heartbeatJob = startHeartbeat() + } + } } } @@ -51,7 +83,18 @@ class SessionManager { fun userInteracted() { if (_isExpired.value) return if (isMonitoring.load()) { - lastInteractionTime.store(Clock.System.now().toEpochMilliseconds()) + isColdStart.store(false) + + val now = Clock.System.now().toEpochMilliseconds() + lastInteractionTime.store(now) + + val lastSave = lastDiskSaveTime.load() + if (now - lastSave > Constants.THROTTLE_DISK_SAVE_MS) { + lastDiskSaveTime.store(now) + scope.launch { + sessionStorage.saveSessionTime(now) + } + } } } @@ -59,19 +102,59 @@ class SessionManager { fun stopSession() { isMonitoring.store(false) _isExpired.value = false + _shouldShowDialog.value = false heartbeatJob?.cancel() heartbeatJob = null + isColdStart.store(true) + lastInteractionTime.store(0L) + + scope.launch { + sessionStorage.saveSessionTime(0L) + } + } + + fun checkExpirationNow() { + scope.launch { + checkExpirationInternal() + } + } + + @OptIn(ExperimentalAtomicApi::class, ExperimentalTime::class) + private suspend fun checkExpirationInternal() { + checkLock.withLock { + if (_isExpired.value) return + + val ramTime = lastInteractionTime.load() + + val effectiveTime = if (ramTime == 0L) { + sessionStorage.getSessionTime().first() + } else { + ramTime + } + + if (effectiveTime == 0L) return + + val currentTime = Clock.System.now().toEpochMilliseconds() + + if (currentTime - effectiveTime >= timeoutMs) { + if (isColdStart.compareAndSet(expectedValue = true, newValue = false)) { + _shouldShowDialog.value = false + } else { + _shouldShowDialog.value = true + } + _isExpired.value = true + isMonitoring.store(true) + } else { + isColdStart.store(false) + } + } } @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) private fun startHeartbeat(): Job { return scope.launch { while (isMonitoring.load()) { - val currentTime = Clock.System.now().toEpochMilliseconds() - if (currentTime - lastInteractionTime.load() >= timeoutMs) { - _isExpired.value = true - break - } + checkExpirationInternal() delay(30_000) } } diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt index a3243ac90..6e404f1cd 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt @@ -26,7 +26,7 @@ val DispatchersModule = module { single(named("ApplicationScope")) { CoroutineScope(SupervisorJob() + Dispatchers.Default) } - single { SessionManager() } + single { SessionManager(get()) } } expect val ioDispatcherModule: Module diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/DatastoreSessionStorage.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/DatastoreSessionStorage.kt new file mode 100644 index 000000000..e91d84b49 --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/DatastoreSessionStorage.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.mobile.core.datastore + +import kotlinx.coroutines.flow.Flow +import org.mifos.mobile.core.common.SessionStorage + +class DatastoreSessionStorage( + private val dataSource: UserPreferencesDataSource, +) : SessionStorage { + + override suspend fun saveSessionTime(time: Long) { + dataSource.setLastSessionTime(time) + } + + override fun getSessionTime(): Flow { + return dataSource.observeLastSessionTime + } +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt index 5732a2491..b49c4f104 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt @@ -32,6 +32,9 @@ import org.mifos.mobile.core.model.MifosThemeConfig private const val USER_DATA = "userData" private const val APP_SETTINGS = "appSettings" +private const val LAST_SESSION_TIME = "last_session_time" + +@Suppress("TooManyFunctions") class UserPreferencesDataSource( private val settings: Settings, private val dispatcher: CoroutineDispatcher, @@ -59,6 +62,10 @@ class UserPreferencesDataSource( ), ) + private val lastSessionTime = MutableStateFlow( + settings.getLong(LAST_SESSION_TIME, 0L), + ) + val token = _userInfo.map { it.base64EncodedAuthenticationKey } @@ -87,6 +94,16 @@ class UserPreferencesDataSource( val observeTimeBasedThemeConfig: Flow get() = _settingsInfo.map { it.timeBasedTheme } + val observeLastSessionTime: Flow + get() = lastSessionTime + + suspend fun setLastSessionTime(time: Long) { + withContext(dispatcher) { + settings.putLong(LAST_SESSION_TIME, time) + lastSessionTime.value = time + } + } + suspend fun updateSettingsInfo(appSettings: AppSettings) { withContext(dispatcher) { settings.putSettingsPreference(appSettings) @@ -165,6 +182,9 @@ class UserPreferencesDataSource( ) settings.putSettingsPreference(cleared) _settingsInfo.value = cleared + + settings.putLong(LAST_SESSION_TIME, 0L) + lastSessionTime.value = 0L } } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt index dd65b2611..2f4efbab9 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt @@ -45,6 +45,8 @@ interface UserPreferencesRepository { val passcode: Flow + val observeLastSessionTime: Flow + suspend fun updateToken(password: String): DataState suspend fun updateTheme(theme: MifosThemeConfig): DataState @@ -75,5 +77,6 @@ interface UserPreferencesRepository { suspend fun setLanguage(language: LanguageConfig) + suspend fun setLastSessionTime(time: Long) suspend fun logOut(): Unit } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt index 2434b008b..ba860942b 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt @@ -79,6 +79,9 @@ class UserPreferencesRepositoryImpl( override val observeDynamicColorPreference: Flow get() = preferenceManager.observeDynamicColorPreference + override val observeLastSessionTime: Flow + get() = preferenceManager.observeLastSessionTime + override val passcode: Flow get() = preferenceManager.passcode @@ -187,6 +190,10 @@ class UserPreferencesRepositoryImpl( preferenceManager.setPasscode(passcode) } + override suspend fun setLastSessionTime(time: Long) { + preferenceManager.setLastSessionTime(time) + } + override suspend fun logOut() { preferenceManager.clearInfo() } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/di/PreferenceModule.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/di/PreferenceModule.kt index bff3f4b8b..dca6dec4a 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/di/PreferenceModule.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/di/PreferenceModule.kt @@ -13,6 +13,8 @@ import com.russhwolf.settings.Settings import org.koin.core.qualifier.named import org.koin.dsl.module import org.mifos.mobile.core.common.MifosDispatchers +import org.mifos.mobile.core.common.SessionStorage +import org.mifos.mobile.core.datastore.DatastoreSessionStorage import org.mifos.mobile.core.datastore.UserPreferencesDataSource import org.mifos.mobile.core.datastore.UserPreferencesRepository import org.mifos.mobile.core.datastore.UserPreferencesRepositoryImpl @@ -34,4 +36,6 @@ val PreferencesModule = module { unconfinedDispatcher = get(named(MifosDispatchers.Unconfined.name)), ) } + + single { DatastoreSessionStorage(get()) } } diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt index f2bddd908..099eb1ad1 100644 --- a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -42,12 +42,10 @@ fun SessionHandler( while (true) { val event = awaitPointerEvent(pass = PointerEventPass.Initial) - if (event.changes.any { it.changedToDown() }) { - if (isExpired) { - event.changes.forEach { it.consume() } - } else { - sessionManager.userInteracted() - } + if (isExpired) { + event.changes.forEach { it.consume() } + } else if (event.changes.any { it.changedToDown() }) { + sessionManager.userInteracted() } } } From 959eb700f1fb4b8d433df2f1acf977d664ad3e15 Mon Sep 17 00:00:00 2001 From: mena Date: Sun, 25 Jan 2026 17:46:53 +0200 Subject: [PATCH 09/16] summarize: implement app locking and passcode verification for session management - Refactored `ComposeAppViewModel` to replace `Logout` with `LockApp` and `SessionExpired` actions, updating `userPreferencesRepository` to manage unlock state instead of full logout. - Added background detection in `ComposeApp` to trigger `LockApp` when the app is backgrounded. - Updated `SessionManager` to simplify expiration logic, removing disk-based time tracking and cold start dialog suppression. - Enhanced `PasscodeViewModel` with a `Verify` mode to authenticate users using their stored passcode and update the application's lock status. - Introduced `passcodeNavGraph` to manage passcode verification flows and added success strings for the unlock process. - Modified `RootNavScreen` to handle `UserLocked` and `UserUnlocked` states, ensuring the app navigates to the passcode screen when locked. --- .../kotlin/cmp/navigation/ComposeApp.kt | 29 ++++++--- .../cmp/navigation/ComposeAppViewModel.kt | 15 ++++- .../navigation/navigation/PasscodeNavGraph.kt | 32 +++------- .../cmp/navigation/rootnav/RootNavScreen.kt | 37 ++++++++--- .../navigation/rootnav/RootNavViewModel.kt | 7 +++ .../mobile/core/common/SessionManager.kt | 61 ++----------------- .../core/common/di/DispatchersModule.kt | 2 +- .../composeResources/values-es/strings.xml | 2 + .../composeResources/values/strings.xml | 3 + .../mobile/feature/passcode/PasscodeScreen.kt | 2 + .../feature/passcode/PasscodeViewModel.kt | 54 ++++++++++++++++ 11 files changed, 144 insertions(+), 100 deletions(-) diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt index 780bbdd41..92b23be19 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt @@ -20,11 +20,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.rememberNavController import cmp.navigation.rootnav.RootNavScreen import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject @@ -51,24 +55,33 @@ fun ComposeApp( sessionManager: SessionManager = koinInject(), viewModel: ComposeAppViewModel = koinViewModel(), ) { + val navController = rememberNavController() val uiState by viewModel.stateFlow.collectAsStateWithLifecycle() + var wasBackgrounded by remember { mutableStateOf(false) } + val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_START) { - sessionManager.checkExpirationNow() + if (event == Lifecycle.Event.ON_STOP) { + wasBackgrounded = true + viewModel.trySendAction(AppAction.LockApp) + } else if (event == Lifecycle.Event.ON_START) { + if (wasBackgrounded) { + viewModel.trySendAction(AppAction.LockApp) + wasBackgrounded = false + } } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { + sessionManager.stopSession() lifecycleOwner.lifecycle.removeObserver(observer) } } val isSessionExpired by sessionManager.isExpired.collectAsStateWithLifecycle() - val showDialog by sessionManager.shouldShowDialog.collectAsStateWithLifecycle() EventsEffect(eventFlow = viewModel.eventFlow) { event -> when (event) { is AppEvent.ShowToast -> {} @@ -89,7 +102,7 @@ fun ComposeApp( androidTheme = uiState.isAndroidTheme, shouldDisplayDynamicTheming = uiState.isDynamicColorsEnabled, ) { - val dialogState = if (isSessionExpired && showDialog) { + val dialogState = if (isSessionExpired) { BasicDialogState.Shown( title = stringResource(Res.string.session_expired_title), message = stringResource(Res.string.session_expired_message), @@ -102,16 +115,11 @@ fun ComposeApp( MifosBasicDialog( visibilityState = dialogState, onDismissRequest = { - viewModel.trySendAction(AppAction.Logout) + viewModel.trySendAction(AppAction.SessionExpired) }, ) } - LaunchedEffect(isSessionExpired, showDialog) { - if (isSessionExpired && !showDialog) { - viewModel.trySendAction(AppAction.Logout) - } - } SessionHandler( sessionManager = sessionManager, ) { @@ -131,6 +139,7 @@ fun ComposeApp( ) RootNavScreen( + navController = navController, modifier = Modifier, onSplashScreenRemoved = onSplashScreenRemoved, ) diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt index c5c3ea4bc..30ca2da04 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt @@ -147,13 +147,21 @@ class ComposeAppViewModel( is AppAction.Internal.TimeBasedThemeUpdate -> handleTimeBasedThemeUpdate(action) - is AppAction.Logout -> handleUserInactivityLogout() + is AppAction.SessionExpired -> handleUserInactivityLogout() + + is AppAction.LockApp -> handleLockApp() + } + } + + private fun handleLockApp() { + viewModelScope.launch { + userPreferencesRepository.setIsUnlocked(false) } } private fun handleUserInactivityLogout() { viewModelScope.launch { - userPreferencesRepository.logOut() + userPreferencesRepository.setIsUnlocked(false) } } @@ -270,5 +278,6 @@ sealed interface AppAction { ) : Internal() } - data object Logout : AppAction + data object LockApp : AppAction + data object SessionExpired : AppAction } diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/navigation/PasscodeNavGraph.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/navigation/PasscodeNavGraph.kt index b2610661e..620fca72d 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/navigation/PasscodeNavGraph.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/navigation/PasscodeNavGraph.kt @@ -10,33 +10,21 @@ package cmp.navigation.navigation import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController import androidx.navigation.navigation -import cmp.navigation.authenticated.AuthenticatedGraphRoute -import org.mifos.library.passcode.PASSCODE_SCREEN -import org.mifos.library.passcode.passcodeRoute +import cmp.navigation.utils.toObjectNavigationRoute +import org.mifos.mobile.feature.passcode.navigation.PasscodeRoute +import org.mifos.mobile.feature.passcode.navigation.passcodeDestination -internal fun NavGraphBuilder.passcodeNavGraph(navController: NavHostController) { +internal fun NavGraphBuilder.passcodeNavGraph( + onPasscodeVerified: () -> Unit, +) { navigation( route = NavGraphRoute.PASSCODE_GRAPH, - startDestination = PASSCODE_SCREEN, + startDestination = PasscodeRoute.Standard.toObjectNavigationRoute(), ) { - passcodeRoute( - onForgotButton = { - navController.popBackStack() - navController.navigate(AuthenticatedGraphRoute) - }, - onSkipButton = { - navController.popBackStack() - navController.navigate(AuthenticatedGraphRoute) - }, - onPasscodeConfirm = { - navController.popBackStack() - navController.navigate(AuthenticatedGraphRoute) - }, - onPasscodeRejected = { - navController.popBackStack() - navController.navigate(AuthenticatedGraphRoute) + passcodeDestination( + onPasscodeConfirm = { _, _, _, _, _ -> + onPasscodeVerified() }, ) } diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavScreen.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavScreen.kt index 1a728ce4c..81c1d546a 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavScreen.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavScreen.kt @@ -24,15 +24,16 @@ import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import cmp.navigation.authenticated.AuthenticatedGraphRoute import cmp.navigation.authenticated.authenticatedGraph -import cmp.navigation.authenticated.navigateToAuthenticatedGraph import cmp.navigation.authenticated.navigateToStatusScreenLoginFlow -import cmp.navigation.authenticated.navigateToStatusScreenPasscodeFlow +import cmp.navigation.navigation.passcodeNavGraph import cmp.navigation.splash.SplashRoute import cmp.navigation.splash.navigateToSplash import cmp.navigation.splash.splashDestination import cmp.navigation.ui.rememberMifosNavController import cmp.navigation.utils.toObjectNavigationRoute +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.ui.NonNullEnterTransitionProvider import org.mifos.mobile.core.ui.NonNullExitTransitionProvider import org.mifos.mobile.core.ui.RootTransitionProviders @@ -44,13 +45,13 @@ import org.mifos.mobile.feature.onboarding.language.navigation.navigateToOnboard import org.mifos.mobile.feature.onboarding.language.navigation.onBoardingLanguageDestination import org.mifos.mobile.feature.passcode.navigation.PasscodeRoute import org.mifos.mobile.feature.passcode.navigation.navigateToPasscodeScreen -import org.mifos.mobile.feature.passcode.navigation.passcodeDestination @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun RootNavScreen( modifier: Modifier = Modifier, viewModel: RootNavViewModel = koinViewModel(), + sessionManager: SessionManager = koinInject(), navController: NavHostController = rememberMifosNavController(name = "RootNavScreen"), onSplashScreenRemoved: () -> Unit = {}, ) { @@ -79,7 +80,19 @@ fun RootNavScreen( navController::navigateToStatusScreenLoginFlow, ) authenticatedGraph(navController) - passcodeDestination(navController::navigateToStatusScreenPasscodeFlow) + passcodeNavGraph( + onPasscodeVerified = { + sessionManager.startSession() + val previousEntry = navController.previousBackStackEntry + if (previousEntry != null) { + navController.popBackStack() + } else { + navController.navigate(AuthenticatedGraphRoute) { + popUpTo(0) { inclusive = true } + } + } + }, + ) } val targetRoute = when (state) { @@ -122,10 +135,20 @@ fun RootNavScreen( RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) RootNavState.SetLanguage -> navController.navigateToOnboardingLanguage(rootNavOptions) - RootNavState.UserLocked -> navController.navigateToPasscodeScreen(rootNavOptions) - is RootNavState.UserUnlocked -> navController.navigateToAuthenticatedGraph( - navOptions = rootNavOptions, + RootNavState.UserLocked -> navController.navigateToPasscodeScreen( + navOptions { + popUpTo(navController.graph.id) { + inclusive = true + } + launchSingleTop = true + }, ) + is RootNavState.UserUnlocked -> { + navController.navigate(AuthenticatedGraphRoute) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } } } } diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavViewModel.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavViewModel.kt index eb90a1ba5..9297eb871 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavViewModel.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavViewModel.kt @@ -17,17 +17,24 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.mifos.mobile.core.data.repository.UserDataRepository +import org.mifos.mobile.core.datastore.UserPreferencesRepository import org.mifos.mobile.core.datastore.model.AppSettings import org.mifos.mobile.core.model.AuthState import org.mifos.mobile.core.ui.utils.BaseViewModel class RootNavViewModel( userDataRepository: UserDataRepository, + private val userPreferencesRepository: UserPreferencesRepository, ) : BaseViewModel( initialState = RootNavState.Splash, ) { init { + + viewModelScope.launch { + userPreferencesRepository.setIsUnlocked(false) + } + viewModelScope.launch { userDataRepository.authState .collect { authState -> diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index 2c6414c36..74504b843 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -32,9 +31,7 @@ interface SessionStorage { fun getSessionTime(): Flow } -class SessionManager( - private val sessionStorage: SessionStorage, -) { +class SessionManager { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val checkLock = Mutex() @@ -43,9 +40,6 @@ class SessionManager( @OptIn(ExperimentalAtomicApi::class) private val lastInteractionTime = AtomicLong(0L) - @OptIn(ExperimentalAtomicApi::class) - private val lastDiskSaveTime = AtomicLong(0L) - @OptIn(ExperimentalAtomicApi::class) private val isMonitoring = AtomicBoolean(false) private val timeoutMs = Constants.TIMEOUT_SESSION_MS @@ -53,25 +47,13 @@ class SessionManager( private val _isExpired = MutableStateFlow(false) val isExpired = _isExpired.asStateFlow() - @OptIn(ExperimentalAtomicApi::class) - private val isColdStart = AtomicBoolean(true) - - private val _shouldShowDialog = MutableStateFlow(false) - val shouldShowDialog = _shouldShowDialog.asStateFlow() - @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) fun startSession() { if (isMonitoring.compareAndSet(expectedValue = false, newValue = true)) { _isExpired.value = false scope.launch { - val savedTime = sessionStorage.getSessionTime().first() val now = Clock.System.now().toEpochMilliseconds() - - val effectiveTime = if (savedTime == 0L) now else savedTime - - lastInteractionTime.store(effectiveTime) - checkExpirationInternal() - + lastInteractionTime.store(now) if (!_isExpired.value) { heartbeatJob = startHeartbeat() } @@ -83,18 +65,8 @@ class SessionManager( fun userInteracted() { if (_isExpired.value) return if (isMonitoring.load()) { - isColdStart.store(false) - val now = Clock.System.now().toEpochMilliseconds() lastInteractionTime.store(now) - - val lastSave = lastDiskSaveTime.load() - if (now - lastSave > Constants.THROTTLE_DISK_SAVE_MS) { - lastDiskSaveTime.store(now) - scope.launch { - sessionStorage.saveSessionTime(now) - } - } } } @@ -102,21 +74,9 @@ class SessionManager( fun stopSession() { isMonitoring.store(false) _isExpired.value = false - _shouldShowDialog.value = false heartbeatJob?.cancel() heartbeatJob = null - isColdStart.store(true) lastInteractionTime.store(0L) - - scope.launch { - sessionStorage.saveSessionTime(0L) - } - } - - fun checkExpirationNow() { - scope.launch { - checkExpirationInternal() - } } @OptIn(ExperimentalAtomicApi::class, ExperimentalTime::class) @@ -126,26 +86,13 @@ class SessionManager( val ramTime = lastInteractionTime.load() - val effectiveTime = if (ramTime == 0L) { - sessionStorage.getSessionTime().first() - } else { - ramTime - } - - if (effectiveTime == 0L) return + if (ramTime == 0L) return val currentTime = Clock.System.now().toEpochMilliseconds() - if (currentTime - effectiveTime >= timeoutMs) { - if (isColdStart.compareAndSet(expectedValue = true, newValue = false)) { - _shouldShowDialog.value = false - } else { - _shouldShowDialog.value = true - } + if (currentTime - ramTime >= timeoutMs) { _isExpired.value = true isMonitoring.store(true) - } else { - isColdStart.store(false) } } } diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt index 6e404f1cd..a3243ac90 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/di/DispatchersModule.kt @@ -26,7 +26,7 @@ val DispatchersModule = module { single(named("ApplicationScope")) { CoroutineScope(SupervisorJob() + Dispatchers.Default) } - single { SessionManager(get()) } + single { SessionManager() } } expect val ioDispatcherModule: Module diff --git a/feature/passcode/src/commonMain/composeResources/values-es/strings.xml b/feature/passcode/src/commonMain/composeResources/values-es/strings.xml index b8e0561d9..2331f0913 100644 --- a/feature/passcode/src/commonMain/composeResources/values-es/strings.xml +++ b/feature/passcode/src/commonMain/composeResources/values-es/strings.xml @@ -23,4 +23,6 @@ Ingresa el PIN de autenticación Confirma que eres tú ingresando tu PIN de autenticación de 4 dígitos para completar tu acción + ¡Desbloqueado! + Has verificado tu identidad correctamente. diff --git a/feature/passcode/src/commonMain/composeResources/values/strings.xml b/feature/passcode/src/commonMain/composeResources/values/strings.xml index 7512cea64..feb3cd66d 100644 --- a/feature/passcode/src/commonMain/composeResources/values/strings.xml +++ b/feature/passcode/src/commonMain/composeResources/values/strings.xml @@ -23,4 +23,7 @@ Enter Authentication Code Confirm it’s you by entering your 4-digit authentication code in order to complete your action + + Unlocked! + You have successfully verified your identity. diff --git a/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeScreen.kt b/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeScreen.kt index a29a70ae7..01d778e36 100644 --- a/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeScreen.kt +++ b/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle import mifos_mobile.feature.passcode.generated.resources.Res +import mifos_mobile.feature.passcode.generated.resources.feature_passcode_authenticate import mifos_mobile.feature.passcode.generated.resources.feature_passcode_confirm import mifos_mobile.feature.passcode.generated.resources.feature_passcode_setup import mifos_mobile.feature.passcode.generated.resources.feature_passcode_tip @@ -121,6 +122,7 @@ private fun PasscodeScreenContent( text = when (state.mode) { PasscodeMode.Set -> stringResource(Res.string.feature_passcode_setup) PasscodeMode.Confirm -> stringResource(Res.string.feature_passcode_confirm) + PasscodeMode.Verify -> stringResource(Res.string.feature_passcode_authenticate) }, style = MifosTypography.titleMedium, color = KptTheme.colorScheme.onBackground, diff --git a/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeViewModel.kt b/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeViewModel.kt index 1257e6aa7..31d76bdbc 100644 --- a/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeViewModel.kt +++ b/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeViewModel.kt @@ -10,24 +10,45 @@ package org.mifos.mobile.feature.passcode import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import mifos_mobile.feature.passcode.generated.resources.Res import mifos_mobile.feature.passcode.generated.resources.feature_passcode_common_continue import mifos_mobile.feature.passcode.generated.resources.feature_passcode_setup_successful import mifos_mobile.feature.passcode.generated.resources.feature_passcode_setup_successful_msg +import mifos_mobile.feature.passcode.generated.resources.feature_passcode_unlock_success +import mifos_mobile.feature.passcode.generated.resources.feature_passcode_unlock_success_msg import org.jetbrains.compose.resources.getString import org.mifos.mobile.core.common.Constants +import org.mifos.mobile.core.common.SessionManager import org.mifos.mobile.core.datastore.UserPreferencesRepository import org.mifos.mobile.core.model.EventType import org.mifos.mobile.core.ui.utils.BaseViewModel internal class PasscodeViewModel( private val userPreferencesRepository: UserPreferencesRepository, + sessionManager: SessionManager, ) : BaseViewModel( initialState = PasscodeState(), ) { + init { + sessionManager.stopSession() + + viewModelScope.launch { + val storedPasscode = userPreferencesRepository.passcode.firstOrNull() + if (!storedPasscode.isNullOrEmpty()) { + mutableStateFlow.update { + it.copy( + mode = PasscodeMode.Verify, + storedPasscode = storedPasscode, + ) + } + } + } + } + private var passcodeBuilder: StringBuilder = StringBuilder() override fun handleAction(action: PasscodeAction) { @@ -100,10 +121,42 @@ internal class PasscodeViewModel( } } } + + PasscodeMode.Verify -> viewModelScope.launch { + val storedPasscode = userPreferencesRepository.passcode.firstOrNull() + val entered = passcodeBuilder.toString() + if (entered == storedPasscode) { + userPreferencesRepository.setIsUnlocked(true) + sendEvent( + PasscodeEvent.OnPasscodeConfirm( + eventType = EventType.SUCCESS.name, + eventDestination = "UNLOCK_ACTION", + title = getString(Res.string.feature_passcode_unlock_success), + subtitle = getString(Res.string.feature_passcode_unlock_success_msg), + buttonText = getString(Res.string.feature_passcode_common_continue), + ), + ) + } else { + handleWrongPasscode() + } + } } } } + private fun handleWrongPasscode() { + passcodeBuilder.clear() + mutableStateFlow.update { + it.copy( + firstPasscode = if (it.mode == PasscodeMode.Confirm) "" else it.firstPasscode, + mode = if (it.mode == PasscodeMode.Confirm) PasscodeMode.Set else it.mode, + passcode = "", + filledDots = 0, + passcodeError = true, + ) + } + } + private fun updatePasscodeState() { mutableStateFlow.update { it.copy( @@ -144,4 +197,5 @@ internal sealed interface PasscodeAction { enum class PasscodeMode { Set, Confirm, + Verify, } From b37caee4701199a97071242fc75adfc5a45667ff Mon Sep 17 00:00:00 2001 From: mena Date: Tue, 27 Jan 2026 18:48:40 +0200 Subject: [PATCH 10/16] feat: update unlock status when confirming passcode in `PasscodeViewModel` --- .../org/mifos/mobile/feature/passcode/PasscodeViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeViewModel.kt b/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeViewModel.kt index 31d76bdbc..36d7b4e18 100644 --- a/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeViewModel.kt +++ b/feature/passcode/src/commonMain/kotlin/org/mifos/mobile/feature/passcode/PasscodeViewModel.kt @@ -98,6 +98,7 @@ internal class PasscodeViewModel( if (confirm == state.firstPasscode) { viewModelScope.launch { userPreferencesRepository.setPasscode(confirm) + userPreferencesRepository.setIsUnlocked(true) sendEvent( PasscodeEvent.OnPasscodeConfirm( eventType = EventType.SUCCESS.name, From 173b09e8575eb10204aae8120d57d89834e5eefa Mon Sep 17 00:00:00 2001 From: mena Date: Wed, 28 Jan 2026 15:50:15 +0200 Subject: [PATCH 11/16] refactor: simplify `SessionManager` and remove persistent session storage - Deleted `DatastoreSessionStorage` and `SessionStorage` interface, moving session management to in-memory state. - Removed `last_session_time` tracking from `UserPreferencesDataSource` and `UserPreferencesRepository`. - Updated `session_expired_message` in English and Spanish to reflect that users should verify their passcode instead of being logged out. - Adjusted `RootNavScreen` to always navigate to the authenticated graph upon passcode verification. - Removed explicit `sessionManager.stopSession()` call from `ComposeApp` lifecycle disposal. - Cleaned up `PreferenceModule` by removing unused `SessionStorage` binding. --- .../composeResources/values-es/strings.xml | 2 +- .../composeResources/values/strings.xml | 2 +- .../kotlin/cmp/navigation/ComposeApp.kt | 1 - .../cmp/navigation/rootnav/RootNavScreen.kt | 9 ++----- .../mobile/core/common/SessionManager.kt | 6 ----- .../core/datastore/DatastoreSessionStorage.kt | 26 ------------------- .../datastore/UserPreferencesDataSource.kt | 20 -------------- .../datastore/UserPreferencesRepository.kt | 3 --- .../UserPreferencesRepositoryImpl.kt | 7 ----- .../core/datastore/di/PreferenceModule.kt | 4 --- 10 files changed, 4 insertions(+), 76 deletions(-) delete mode 100644 core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/DatastoreSessionStorage.kt diff --git a/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml b/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml index 586902374..dca2d8a22 100644 --- a/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml +++ b/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml @@ -15,5 +15,5 @@ Perfil ⚠️ No estás conectado a Internet Sesión Expirada - Por su seguridad, se ha cerrado la sesión debido a inactividad. + Por su seguridad, la sesión se ha bloqueado debido a inactividad. Por favor, verifique su código de acceso para continuar. \ No newline at end of file diff --git a/cmp-navigation/src/commonMain/composeResources/values/strings.xml b/cmp-navigation/src/commonMain/composeResources/values/strings.xml index bbbcdd7b1..ba7110f80 100644 --- a/cmp-navigation/src/commonMain/composeResources/values/strings.xml +++ b/cmp-navigation/src/commonMain/composeResources/values/strings.xml @@ -15,5 +15,5 @@ Profile ⚠️ You aren’t connected to the internet Session Expired - For your security, you have been logged out due to inactivity. + For your security, your session has timed out due to inactivity. Please verify your passcode to continue. \ No newline at end of file diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt index 92b23be19..0b002fb4c 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt @@ -76,7 +76,6 @@ fun ComposeApp( lifecycleOwner.lifecycle.addObserver(observer) onDispose { - sessionManager.stopSession() lifecycleOwner.lifecycle.removeObserver(observer) } } diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavScreen.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavScreen.kt index 81c1d546a..d95cc59ca 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavScreen.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/rootnav/RootNavScreen.kt @@ -83,13 +83,8 @@ fun RootNavScreen( passcodeNavGraph( onPasscodeVerified = { sessionManager.startSession() - val previousEntry = navController.previousBackStackEntry - if (previousEntry != null) { - navController.popBackStack() - } else { - navController.navigate(AuthenticatedGraphRoute) { - popUpTo(0) { inclusive = true } - } + navController.navigate(AuthenticatedGraphRoute) { + popUpTo(0) { inclusive = true } } }, ) diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index 74504b843..e05a700ba 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -26,11 +25,6 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.Clock import kotlin.time.ExperimentalTime -interface SessionStorage { - suspend fun saveSessionTime(time: Long) - fun getSessionTime(): Flow -} - class SessionManager { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val checkLock = Mutex() diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/DatastoreSessionStorage.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/DatastoreSessionStorage.kt deleted file mode 100644 index e91d84b49..000000000 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/DatastoreSessionStorage.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2026 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md - */ -package org.mifos.mobile.core.datastore - -import kotlinx.coroutines.flow.Flow -import org.mifos.mobile.core.common.SessionStorage - -class DatastoreSessionStorage( - private val dataSource: UserPreferencesDataSource, -) : SessionStorage { - - override suspend fun saveSessionTime(time: Long) { - dataSource.setLastSessionTime(time) - } - - override fun getSessionTime(): Flow { - return dataSource.observeLastSessionTime - } -} diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt index b49c4f104..5732a2491 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt @@ -32,9 +32,6 @@ import org.mifos.mobile.core.model.MifosThemeConfig private const val USER_DATA = "userData" private const val APP_SETTINGS = "appSettings" -private const val LAST_SESSION_TIME = "last_session_time" - -@Suppress("TooManyFunctions") class UserPreferencesDataSource( private val settings: Settings, private val dispatcher: CoroutineDispatcher, @@ -62,10 +59,6 @@ class UserPreferencesDataSource( ), ) - private val lastSessionTime = MutableStateFlow( - settings.getLong(LAST_SESSION_TIME, 0L), - ) - val token = _userInfo.map { it.base64EncodedAuthenticationKey } @@ -94,16 +87,6 @@ class UserPreferencesDataSource( val observeTimeBasedThemeConfig: Flow get() = _settingsInfo.map { it.timeBasedTheme } - val observeLastSessionTime: Flow - get() = lastSessionTime - - suspend fun setLastSessionTime(time: Long) { - withContext(dispatcher) { - settings.putLong(LAST_SESSION_TIME, time) - lastSessionTime.value = time - } - } - suspend fun updateSettingsInfo(appSettings: AppSettings) { withContext(dispatcher) { settings.putSettingsPreference(appSettings) @@ -182,9 +165,6 @@ class UserPreferencesDataSource( ) settings.putSettingsPreference(cleared) _settingsInfo.value = cleared - - settings.putLong(LAST_SESSION_TIME, 0L) - lastSessionTime.value = 0L } } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt index 2f4efbab9..dd65b2611 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt @@ -45,8 +45,6 @@ interface UserPreferencesRepository { val passcode: Flow - val observeLastSessionTime: Flow - suspend fun updateToken(password: String): DataState suspend fun updateTheme(theme: MifosThemeConfig): DataState @@ -77,6 +75,5 @@ interface UserPreferencesRepository { suspend fun setLanguage(language: LanguageConfig) - suspend fun setLastSessionTime(time: Long) suspend fun logOut(): Unit } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt index ba860942b..2434b008b 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt @@ -79,9 +79,6 @@ class UserPreferencesRepositoryImpl( override val observeDynamicColorPreference: Flow get() = preferenceManager.observeDynamicColorPreference - override val observeLastSessionTime: Flow - get() = preferenceManager.observeLastSessionTime - override val passcode: Flow get() = preferenceManager.passcode @@ -190,10 +187,6 @@ class UserPreferencesRepositoryImpl( preferenceManager.setPasscode(passcode) } - override suspend fun setLastSessionTime(time: Long) { - preferenceManager.setLastSessionTime(time) - } - override suspend fun logOut() { preferenceManager.clearInfo() } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/di/PreferenceModule.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/di/PreferenceModule.kt index dca6dec4a..bff3f4b8b 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/di/PreferenceModule.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/di/PreferenceModule.kt @@ -13,8 +13,6 @@ import com.russhwolf.settings.Settings import org.koin.core.qualifier.named import org.koin.dsl.module import org.mifos.mobile.core.common.MifosDispatchers -import org.mifos.mobile.core.common.SessionStorage -import org.mifos.mobile.core.datastore.DatastoreSessionStorage import org.mifos.mobile.core.datastore.UserPreferencesDataSource import org.mifos.mobile.core.datastore.UserPreferencesRepository import org.mifos.mobile.core.datastore.UserPreferencesRepositoryImpl @@ -36,6 +34,4 @@ val PreferencesModule = module { unconfinedDispatcher = get(named(MifosDispatchers.Unconfined.name)), ) } - - single { DatastoreSessionStorage(get()) } } From c46d59309b39e597d9a14de4821b9012c8f0dc86 Mon Sep 17 00:00:00 2001 From: mena Date: Wed, 28 Jan 2026 16:36:39 +0200 Subject: [PATCH 12/16] fix(core): stop session monitoring upon expiration and cleanup constants - Update `SessionManager` to stop monitoring once the session has expired. - Remove unused `THROTTLE_DISK_SAVE_MS` constant from `Constants.kt`. - Fix minor formatting in `Constants.kt`. --- .../kotlin/org/mifos/mobile/core/common/Constants.kt | 3 +-- .../kotlin/org/mifos/mobile/core/common/SessionManager.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt index 1d08d9276..9ce3c8aee 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt @@ -96,9 +96,8 @@ object Constants { const val LOAN_SUMMARY = "loan_summary" const val TIMEOUT_SESSION_MS = 5 * 60 * 1000L - const val THROTTLE_DISK_SAVE_MS = 2000L -// Settings constants + // Settings constants const val PROFILE = "profile" const val PASSWORD = "password" const val AUTH_PASSCODE = "auth_passcode" diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index e05a700ba..dff816abf 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -86,7 +86,7 @@ class SessionManager { if (currentTime - ramTime >= timeoutMs) { _isExpired.value = true - isMonitoring.store(true) + isMonitoring.store(false) } } } From 7f616e6752e0a8b8d31408339a5117d4a6a1a5cd Mon Sep 17 00:00:00 2001 From: mena Date: Wed, 28 Jan 2026 18:41:08 +0200 Subject: [PATCH 13/16] fix(core): prevent expiration checks when monitoring is disabled in `SessionManager` --- .../kotlin/org/mifos/mobile/core/common/SessionManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt index dff816abf..888827534 100644 --- a/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -76,7 +76,7 @@ class SessionManager { @OptIn(ExperimentalAtomicApi::class, ExperimentalTime::class) private suspend fun checkExpirationInternal() { checkLock.withLock { - if (_isExpired.value) return + if (!isMonitoring.load() || _isExpired.value) return val ramTime = lastInteractionTime.load() From 2f64236ec1c78725c5b0c2013d22688e1eb0153e Mon Sep 17 00:00:00 2001 From: mena Date: Wed, 28 Jan 2026 19:29:22 +0200 Subject: [PATCH 14/16] refactor: simplify session expiration handling in `ComposeApp` and `ComposeAppViewModel` - Remove the `MifosBasicDialog` for session expiration and instead trigger the `SessionExpired` action via `LaunchedEffect`. - Consolidate session expiration and app locking logic in `ComposeAppViewModel` by replacing `handleUserInactivityLogout` with `handleLockApp`. --- .../composeResources/values-es/strings.xml | 2 -- .../composeResources/values/strings.xml | 2 -- .../kotlin/cmp/navigation/ComposeApp.kt | 26 +++---------------- .../cmp/navigation/ComposeAppViewModel.kt | 8 +----- 4 files changed, 5 insertions(+), 33 deletions(-) diff --git a/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml b/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml index dca2d8a22..d7af1aef4 100644 --- a/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml +++ b/cmp-navigation/src/commonMain/composeResources/values-es/strings.xml @@ -14,6 +14,4 @@ Transferir Perfil ⚠️ No estás conectado a Internet - Sesión Expirada - Por su seguridad, la sesión se ha bloqueado debido a inactividad. Por favor, verifique su código de acceso para continuar. \ No newline at end of file diff --git a/cmp-navigation/src/commonMain/composeResources/values/strings.xml b/cmp-navigation/src/commonMain/composeResources/values/strings.xml index ba7110f80..34e4eb93a 100644 --- a/cmp-navigation/src/commonMain/composeResources/values/strings.xml +++ b/cmp-navigation/src/commonMain/composeResources/values/strings.xml @@ -14,6 +14,4 @@ Transfer Profile ⚠️ You aren’t connected to the internet - Session Expired - For your security, your session has timed out due to inactivity. Please verify your passcode to continue. \ No newline at end of file diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt index 0b002fb4c..a81bc6549 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt @@ -30,20 +30,14 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController import cmp.navigation.rootnav.RootNavScreen -import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.mifos.mobile.core.common.SessionManager -import org.mifos.mobile.core.designsystem.component.BasicDialogState -import org.mifos.mobile.core.designsystem.component.MifosBasicDialog import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme import org.mifos.mobile.core.model.MifosThemeConfig import org.mifos.mobile.core.ui.utils.EventsEffect import org.mifos.mobile.core.ui.utils.NetworkBanner import org.mifos.mobile.core.ui.utils.SessionHandler -import org.mifos.mobile.navigation.generated.resources.Res -import org.mifos.mobile.navigation.generated.resources.session_expired_message -import org.mifos.mobile.navigation.generated.resources.session_expired_title import template.core.base.designsystem.theme.KptTheme @Composable @@ -101,22 +95,10 @@ fun ComposeApp( androidTheme = uiState.isAndroidTheme, shouldDisplayDynamicTheming = uiState.isDynamicColorsEnabled, ) { - val dialogState = if (isSessionExpired) { - BasicDialogState.Shown( - title = stringResource(Res.string.session_expired_title), - message = stringResource(Res.string.session_expired_message), - ) - } else { - BasicDialogState.Hidden - } - - if (dialogState is BasicDialogState.Shown) { - MifosBasicDialog( - visibilityState = dialogState, - onDismissRequest = { - viewModel.trySendAction(AppAction.SessionExpired) - }, - ) + LaunchedEffect(isSessionExpired) { + if (isSessionExpired) { + viewModel.trySendAction(AppAction.SessionExpired) + } } SessionHandler( diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt index 30ca2da04..2072fabed 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt @@ -147,7 +147,7 @@ class ComposeAppViewModel( is AppAction.Internal.TimeBasedThemeUpdate -> handleTimeBasedThemeUpdate(action) - is AppAction.SessionExpired -> handleUserInactivityLogout() + is AppAction.SessionExpired -> handleLockApp() is AppAction.LockApp -> handleLockApp() } @@ -159,12 +159,6 @@ class ComposeAppViewModel( } } - private fun handleUserInactivityLogout() { - viewModelScope.launch { - userPreferencesRepository.setIsUnlocked(false) - } - } - private fun handleTimeBasedThemeUpdate(action: AppAction.Internal.TimeBasedThemeUpdate) { val currentThemeConfig = mutableStateFlow.value.themeConfig From dac787b9d79b1f56ed050c351d6382b17b9d4aa5 Mon Sep 17 00:00:00 2001 From: mena Date: Fri, 30 Jan 2026 18:07:49 +0200 Subject: [PATCH 15/16] feat(core/ui): remove background blurring during session expiration - Removed the blur effect from `SessionHandler` when `isExpired` is true, keeping only the pointer input interception. --- .../kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt index 099eb1ad1..fc5f973c0 100644 --- a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -36,7 +36,6 @@ fun SessionHandler( Box( modifier = modifier .fillMaxSize() - .then(if (isExpired) Modifier.blur(KptTheme.spacing.md) else Modifier) .pointerInput(isExpired) { awaitPointerEventScope { while (true) { From 20d821e565877003b9901c21071114c1e3b8ad17 Mon Sep 17 00:00:00 2001 From: mena Date: Fri, 30 Jan 2026 18:19:09 +0200 Subject: [PATCH 16/16] refactor: remove unused imports from `SessionHandler.kt` - Clean up unused `Modifier.blur` and `KptTheme` imports in `SessionHandler.kt`. --- .../kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt index fc5f973c0..8f54069e2 100644 --- a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type @@ -23,7 +22,6 @@ import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.pointerInput import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.mifos.mobile.core.common.SessionManager -import template.core.base.designsystem.theme.KptTheme @Composable fun SessionHandler(