diff --git a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt index 4c3bc2876..a81bc6549 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeApp.kt @@ -16,18 +16,29 @@ 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.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.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 +import template.core.base.designsystem.theme.KptTheme @Composable fun ComposeApp( @@ -35,10 +46,35 @@ fun ComposeApp( handleAppLocale: (locale: String?) -> Unit, onSplashScreenRemoved: () -> Unit, modifier: Modifier = Modifier, + 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_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 { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val isSessionExpired by sessionManager.isExpired.collectAsStateWithLifecycle() EventsEffect(eventFlow = viewModel.eventFlow) { event -> when (event) { is AppEvent.ShowToast -> {} @@ -59,25 +95,36 @@ fun ComposeApp( androidTheme = uiState.isAndroidTheme, shouldDisplayDynamicTheming = uiState.isDynamicColorsEnabled, ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - ) { - Column( - modifier = modifier - .fillMaxSize() - .statusBarsPadding(), - ) { - NetworkBanner( - bannerState = uiState.networkBanner, - modifier = Modifier.fillMaxWidth(), - ) + LaunchedEffect(isSessionExpired) { + if (isSessionExpired) { + viewModel.trySendAction(AppAction.SessionExpired) + } + } - RootNavScreen( - modifier = Modifier, - onSplashScreenRemoved = onSplashScreenRemoved, - ) + SessionHandler( + sessionManager = sessionManager, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(KptTheme.colorScheme.surface), + ) { + Column( + modifier = modifier + .fillMaxSize() + .statusBarsPadding(), + ) { + NetworkBanner( + bannerState = uiState.networkBanner, + modifier = Modifier.fillMaxWidth(), + ) + + 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 10e8fb819..2072fabed 100644 --- a/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt +++ b/cmp-navigation/src/commonMain/kotlin/cmp/navigation/ComposeAppViewModel.kt @@ -146,6 +146,16 @@ class ComposeAppViewModel( is AppAction.Internal.SystemThemeUpdate -> handleSystemThemeUpdate(action) is AppAction.Internal.TimeBasedThemeUpdate -> handleTimeBasedThemeUpdate(action) + + is AppAction.SessionExpired -> handleLockApp() + + is AppAction.LockApp -> handleLockApp() + } + } + + private fun handleLockApp() { + viewModelScope.launch { + userPreferencesRepository.setIsUnlocked(false) } } @@ -261,4 +271,7 @@ sealed interface AppAction { val timeBasedTheme: TimeBasedTheme, ) : Internal() } + + 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..d95cc59ca 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,14 @@ fun RootNavScreen( navController::navigateToStatusScreenLoginFlow, ) authenticatedGraph(navController) - passcodeDestination(navController::navigateToStatusScreenPasscodeFlow) + passcodeNavGraph( + onPasscodeVerified = { + sessionManager.startSession() + navController.navigate(AuthenticatedGraphRoute) { + popUpTo(0) { inclusive = true } + } + }, + ) } val targetRoute = when (state) { @@ -122,10 +130,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/Constants.kt b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/Constants.kt index 493178e1e..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 @@ -95,7 +95,9 @@ object Constants { const val MAKE_PAYMENT = "make_payments" const val LOAN_SUMMARY = "loan_summary" -// Settings constants + const val TIMEOUT_SESSION_MS = 5 * 60 * 1000L + + // 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 new file mode 100644 index 000000000..888827534 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/mifos/mobile/core/common/SessionManager.kt @@ -0,0 +1,103 @@ +/* + * 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.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +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 { + 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 isMonitoring = AtomicBoolean(false) + private val timeoutMs = Constants.TIMEOUT_SESSION_MS + + 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 + scope.launch { + val now = Clock.System.now().toEpochMilliseconds() + lastInteractionTime.store(now) + if (!_isExpired.value) { + heartbeatJob = startHeartbeat() + } + } + } + } + + @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) + fun userInteracted() { + if (_isExpired.value) return + if (isMonitoring.load()) { + val now = Clock.System.now().toEpochMilliseconds() + lastInteractionTime.store(now) + } + } + + @OptIn(ExperimentalAtomicApi::class) + fun stopSession() { + isMonitoring.store(false) + _isExpired.value = false + heartbeatJob?.cancel() + heartbeatJob = null + lastInteractionTime.store(0L) + } + + @OptIn(ExperimentalAtomicApi::class, ExperimentalTime::class) + private suspend fun checkExpirationInternal() { + checkLock.withLock { + if (!isMonitoring.load() || _isExpired.value) return + + val ramTime = lastInteractionTime.load() + + if (ramTime == 0L) return + + val currentTime = Clock.System.now().toEpochMilliseconds() + + if (currentTime - ramTime >= timeoutMs) { + _isExpired.value = true + isMonitoring.store(false) + } + } + } + + @OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class) + private fun startHeartbeat(): Job { + return scope.launch { + while (isMonitoring.load()) { + 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 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..8f54069e2 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/SessionHandler.kt @@ -0,0 +1,62 @@ +/* + * 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.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.KeyEventType +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.mifos.mobile.core.common.SessionManager + +@Composable +fun SessionHandler( + sessionManager: SessionManager, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val isExpired by sessionManager.isExpired.collectAsStateWithLifecycle() + + Box( + modifier = modifier + .fillMaxSize() + .pointerInput(isExpired) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + + if (isExpired) { + event.changes.forEach { it.consume() } + } else if (event.changes.any { it.changedToDown() }) { + sessionManager.userInteracted() + } + } + } + }.onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp) { + if (isExpired) { + return@onPreviewKeyEvent true + } else { + sessionManager.userInteracted() + } + } + false + }, + ) { + 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..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 @@ -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,15 @@ 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() } 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..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 @@ -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) { @@ -77,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, @@ -100,10 +122,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 +198,5 @@ internal sealed interface PasscodeAction { enum class PasscodeMode { Set, Confirm, + Verify, }