diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index 2b3046c722..3b40204c9c 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -29,7 +29,7 @@ data class ApplicationConfiguration( val languages: List = listOf("en"), val useDarkTheme: Boolean = false, val syncInterval: Long = 15, - val syncStrategies: List = listOf(), + val syncStrategy: List = listOf(), val loginConfig: LoginConfig = LoginConfig(), val deviceToDeviceSync: DeviceToDeviceSyncConfig? = null, val snackBarTheme: SnackBarThemeConfig = SnackBarThemeConfig(), diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt index 2a1fe217a8..7be62552a4 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt @@ -27,6 +27,7 @@ import javax.inject.Singleton import kotlinx.serialization.SerializationException import org.smartregister.fhircore.engine.util.extension.decodeJson import org.smartregister.fhircore.engine.util.extension.encodeJson +import org.smartregister.model.practitioner.PractitionerDetails import timber.log.Timber @Singleton @@ -93,7 +94,14 @@ constructor(@ApplicationContext val context: Context, val gson: Gson) { /** Write any object by saving it as JSON */ inline fun write(key: String, value: T?, encodeWithGson: Boolean = true) { with(prefs.edit()) { - putString(key, if (encodeWithGson) gson.toJson(value) else value.encodeJson()) + putString( + key, + (if (encodeWithGson) gson.toJson(value) else value.encodeJson()).also { + if (value is PractitionerDetails) { + Timber.e(it) + } + }, + ) commit() } } diff --git a/android/quest/src/main/AndroidManifest.xml b/android/quest/src/main/AndroidManifest.xml index 10e9f4220c..6ef2dd11ff 100644 --- a/android/quest/src/main/AndroidManifest.xml +++ b/android/quest/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:screenOrientation="portrait" android:installLocation="internalOnly" android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/AppTheme.NoActionBar" android:windowSoftInputMode="adjustResize" tools:ignore="UnusedAttribute,LockedOrientationActivity" diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginErrorState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginErrorState.kt index 537ac530ab..6eca7427b7 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginErrorState.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginErrorState.kt @@ -22,4 +22,5 @@ enum class LoginErrorState { INVALID_OFFLINE_STATE, MULTI_USER_LOGIN_ATTEMPT, UNKNOWN_HOST, + ERROR_MATCHING_SYNC_STRATEGY } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginScreen.kt index 45ddb690c3..a691e5a322 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginScreen.kt @@ -159,9 +159,9 @@ fun LoginPage( Surface( modifier = - modifier - .fillMaxSize() - .scrollable(orientation = Orientation.Vertical, state = rememberScrollState()), + modifier + .fillMaxSize() + .scrollable(orientation = Orientation.Vertical, state = rememberScrollState()), color = Color.White, contentColor = contentColorFor(backgroundColor = Color.DarkGray), ) { @@ -173,7 +173,10 @@ fun LoginPage( } Column( modifier = - modifier.padding(horizontal = 16.dp).fillMaxHeight().verticalScroll(rememberScrollState()), + modifier + .padding(horizontal = 16.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.SpaceBetween, ) { Spacer(modifier = modifier.height(20.dp)) @@ -184,11 +187,11 @@ fun LoginPage( painter = painterResource(R.drawable.ic_app_logo), contentDescription = stringResource(id = R.string.app_logo), modifier = - modifier - .align(Alignment.CenterHorizontally) - .requiredHeight(120.dp) - .requiredWidth(140.dp) - .testTag(APP_LOGO_TAG), + modifier + .align(Alignment.CenterHorizontally) + .requiredHeight(120.dp) + .requiredWidth(140.dp) + .testTag(APP_LOGO_TAG), ) } Text( @@ -197,11 +200,11 @@ fun LoginPage( fontWeight = FontWeight.Bold, fontSize = 32.sp, modifier = - modifier - .wrapContentWidth() - .padding(vertical = 8.dp) - .align(Alignment.CenterHorizontally) - .testTag(APP_NAME_TEXT_TAG), + modifier + .wrapContentWidth() + .padding(vertical = 8.dp) + .align(Alignment.CenterHorizontally) + .testTag(APP_NAME_TEXT_TAG), ) Spacer(modifier = modifier.height(40.dp)) Text(text = stringResource(R.string.username), modifier = modifier.padding(vertical = 4.dp)) @@ -214,25 +217,30 @@ fun LoginPage( Text(color = Color.LightGray, text = stringResource(R.string.username_sample)) }, modifier = - modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .background(color = Color.Unspecified) - .testTag(USERNAME_FIELD_TAG) - .focusRequester(usernameFocusRequester), + modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .background(color = Color.Unspecified) + .testTag(USERNAME_FIELD_TAG) + .focusRequester(usernameFocusRequester), keyboardActions = KeyboardActions(onDone = { passwordFocusRequester.requestFocus() }), ) Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = modifier.fillMaxWidth()) { Text( text = stringResource(R.string.password), - modifier = modifier.wrapContentWidth().padding(vertical = 4.dp), + modifier = modifier + .wrapContentWidth() + .padding(vertical = 4.dp), ) Text( text = stringResource(R.string.forgot_password), color = MaterialTheme.colors.primary, style = TextStyle(textDecoration = TextDecoration.Underline), modifier = - modifier.wrapContentWidth().padding(vertical = 8.dp).clickable { + modifier + .wrapContentWidth() + .padding(vertical = 8.dp) + .clickable { showForgotPasswordDialog = !showForgotPasswordDialog }, ) @@ -248,17 +256,17 @@ fun LoginPage( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), modifier = - modifier - .fillMaxWidth() - .onFocusEvent { event -> - if (event.isFocused) { - coroutineScope.launch { bringIntoViewRequester.bringIntoView() } - } + modifier + .fillMaxWidth() + .onFocusEvent { event -> + if (event.isFocused) { + coroutineScope.launch { bringIntoViewRequester.bringIntoView() } } - .padding(vertical = 4.dp) - .background(color = Color.Unspecified) - .testTag(PASSWORD_FIELD_TAG) - .focusRequester(passwordFocusRequester), + } + .padding(vertical = 4.dp) + .background(color = Color.Unspecified) + .testTag(PASSWORD_FIELD_TAG) + .focusRequester(passwordFocusRequester), trailingIcon = { val image = if (showPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff IconButton(onClick = { showPassword = !showPassword }) { @@ -304,13 +312,15 @@ fun LoginPage( id = R.string.login_error, stringResource(R.string.invalid_offline_login_state), ) + LoginErrorState.ERROR_MATCHING_SYNC_STRATEGY -> + stringResource(id = R.string.login_error, stringResource(R.string.error_matching_sync_strategy)) }, modifier = - modifier - .wrapContentWidth() - .padding(vertical = 10.dp) - .align(Alignment.Start) - .testTag(LOGIN_ERROR_TEXT_TAG), + modifier + .wrapContentWidth() + .padding(vertical = 10.dp) + .align(Alignment.Start) + .testTag(LOGIN_ERROR_TEXT_TAG), ) Spacer(modifier = modifier.height(0.dp)) Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxWidth()) { @@ -329,10 +339,10 @@ fun LoginPage( ), onClick = onLoginButtonClicked, modifier = - modifier - .fillMaxWidth() - .bringIntoViewRequester(bringIntoViewRequester) - .testTag(LOGIN_BUTTON_TAG), + modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .testTag(LOGIN_BUTTON_TAG), elevation = null, ) { Text( @@ -342,7 +352,9 @@ fun LoginPage( } if (showProgressBar) { CircularProgressIndicator( - modifier = modifier.align(Alignment.Center).size(18.dp), + modifier = modifier + .align(Alignment.Center) + .size(18.dp), strokeWidth = 1.6.dp, color = Color.White, ) @@ -351,25 +363,35 @@ fun LoginPage( } Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = modifier.fillMaxWidth().padding(vertical = 20.dp), + modifier = modifier + .fillMaxWidth() + .padding(vertical = 20.dp), verticalAlignment = Alignment.Bottom, ) { Column { Text( text = stringResource(id = R.string.powered_by), - modifier = modifier.wrapContentWidth().padding(vertical = 8.dp).align(Alignment.Start), + modifier = modifier + .wrapContentWidth() + .padding(vertical = 8.dp) + .align(Alignment.Start), ) Image( painter = painterResource(id = R.drawable.ic_opensrp_logo), contentDescription = stringResource(id = R.string.app_logo), - modifier = modifier.align(Alignment.CenterHorizontally).requiredHeight(40.dp), + modifier = modifier + .align(Alignment.CenterHorizontally) + .requiredHeight(40.dp), ) } Text( fontSize = 16.sp, text = stringResource(id = R.string.app_version, versionCode, versionName), - modifier = modifier.wrapContentWidth().padding(0.dp).testTag(LOGIN_FOOTER), + modifier = modifier + .wrapContentWidth() + .padding(0.dp) + .testTag(LOGIN_FOOTER), ) } } @@ -396,18 +418,24 @@ fun ForgotPasswordDialog( }, buttons = { Row( - modifier = modifier.fillMaxWidth().padding(vertical = 20.dp), + modifier = modifier + .fillMaxWidth() + .padding(vertical = 20.dp), horizontalArrangement = Arrangement.End, ) { Text( text = stringResource(R.string.cancel), - modifier = modifier.padding(horizontal = 10.dp).clickable { onDismissDialog() }, + modifier = modifier + .padding(horizontal = 10.dp) + .clickable { onDismissDialog() }, ) Text( color = MaterialTheme.colors.primary, text = stringResource(R.string.dial_number), modifier = - modifier.padding(horizontal = 10.dp).clickable { + modifier + .padding(horizontal = 10.dp) + .clickable { onDismissDialog() forgotPassword() }, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt index 5d8a174b70..62f92b2b97 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.Bundle as FhirR4ModelBundle import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.auth.AuthCredentials import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration @@ -60,6 +61,7 @@ import org.smartregister.model.location.LocationHierarchy import org.smartregister.model.practitioner.PractitionerDetails import retrofit2.HttpException import timber.log.Timber +import java.security.AccessControlException @HiltViewModel class LoginViewModel @@ -139,10 +141,45 @@ constructor( onFetchPractitioner = { bundleResult, userInfo -> if (bundleResult.isSuccess) { val bundle = bundleResult.getOrDefault(FhirR4ModelBundle()) - savePractitionerDetails(bundle, userInfo) { - _showProgressBar.postValue(false) - updateNavigateHome(true) - } + bundle.entry.map { it.resource }.filterIsInstance() + .forEach { + val existingCredentials = secureSharedPreference.retrieveCredentials() + val existPractitionerDetails = sharedPreferences.read( + key = SharedPreferenceKey.PRACTITIONER_DETAILS.name, + decodeWithGson = true + ) + + if (existingCredentials == null || existPractitionerDetails == null) { + savePractitionerDetails(it, userInfo) { + _showProgressBar.postValue(false) + updateNavigateHome(true) + } + return@forEach + } + + val appSyncStrategies = applicationConfiguration.syncStrategy + var syncStrategyMatching = false + appSyncStrategies.forEach { syncStrategy -> + if (syncStrategy.equals("Location", ignoreCase = true)) { // may be use enums, in future + syncStrategyMatching = + existPractitionerDetails.fhirPractitionerDetails.locations.any { location -> + it.fhirPractitionerDetails.locations.contains(location) + } + } + } + + if (syncStrategyMatching){ + savePractitionerDetails(it, userInfo) { + _showProgressBar.postValue(false) + updateNavigateHome(true) + } + }else{ + _showProgressBar.postValue(false) + // TODO: Post login error for second user when not matching sync strategy + _loginErrorState.postValue(LoginErrorState.ERROR_MATCHING_SYNC_STRATEGY) + } + + } } else { _showProgressBar.postValue(false) Timber.e(bundleResult.exceptionOrNull()) @@ -207,36 +244,38 @@ constructor( key = SharedPreferenceKey.PRACTITIONER_DETAILS.name, decodeWithGson = true, ) - if (tokenAuthenticator.sessionActive() && practitionerDetails != null) { - _showProgressBar.postValue(false) - updateNavigateHome(true) - } else { - // Prevent user from logging in with different credentials - val existingCredentials = secureSharedPreference.retrieveCredentials() - if (existingCredentials != null && !username.equals(existingCredentials.username, true)) { + // Get credentials for the current user + val currentCredentials = secureSharedPreference.retrieveCredentials() + if (currentCredentials != null + && practitionerDetails != null + && username.equals(currentCredentials.username, ignoreCase = true) + && tokenAuthenticator.sessionActive()){ + // Allow login access _showProgressBar.postValue(false) - _loginErrorState.postValue(LoginErrorState.MULTI_USER_LOGIN_ATTEMPT) - } else { - tokenAuthenticator - .fetchAccessToken(username, password) - .onSuccess { fetchPractitioner(onFetchUserInfo, onFetchPractitioner) } - .onFailure { - _showProgressBar.postValue(false) - var errorState = LoginErrorState.ERROR_FETCHING_USER + updateNavigateHome(true) + return + } - if (it is HttpException) { - when (it.code()) { - 401 -> errorState = LoginErrorState.INVALID_CREDENTIALS - } - } else if (it is UnknownHostException) { - errorState = LoginErrorState.UNKNOWN_HOST - } + tokenAuthenticator + .fetchAccessToken(username, password) + .onSuccess { + fetchPractitioner(onFetchUserInfo, onFetchPractitioner) + } + .onFailure { + _showProgressBar.postValue(false) + var errorState = LoginErrorState.ERROR_FETCHING_USER - _loginErrorState.postValue(errorState) - Timber.e(it) + if (it is HttpException) { + when (it.code()) { + 401 -> errorState = LoginErrorState.INVALID_CREDENTIALS } + } else if (it is UnknownHostException) { + errorState = LoginErrorState.UNKNOWN_HOST + } + + _loginErrorState.postValue(errorState) + Timber.e(it) } - } } suspend fun fetchPractitioner( @@ -285,80 +324,81 @@ constructor( } fun savePractitionerDetails( - bundle: FhirR4ModelBundle, + practitionerDetails: PractitionerDetails, userInfo: UserInfo?, postProcess: () -> Unit, ) { - if (bundle.entry.isNullOrEmpty()) return viewModelScope.launch { - bundle.entry.forEach { entry -> - val practitionerDetails = entry.resource as PractitionerDetails - - val careTeams = practitionerDetails.fhirPractitionerDetails?.careTeams ?: listOf() - val organizations = practitionerDetails.fhirPractitionerDetails?.organizations ?: listOf() - val locations = practitionerDetails.fhirPractitionerDetails?.locations ?: listOf() - val practitioners = practitionerDetails.fhirPractitionerDetails?.practitioners ?: listOf() - val practitionerId = - practitionerDetails.fhirPractitionerDetails?.practitionerId.valueToString() - val locationHierarchies = - practitionerDetails.fhirPractitionerDetails?.locationHierarchyList ?: listOf() - - val careTeamIds = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *careTeams.toTypedArray()).run { - careTeams.map { it.id.extractLogicalIdUuid() } - } - } - val organizationIds = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *organizations.toTypedArray()).run { - organizations.map { it.id.extractLogicalIdUuid() } - } - } - val locationIds = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *locations.toTypedArray()).run { - locations.map { it.id.extractLogicalIdUuid() } - } - } - defaultRepository.createRemote(false, *practitioners.toTypedArray()) - practitionerDetails.fhirPractitionerDetails?.groups?.toTypedArray()?.let { - defaultRepository.createRemote(false, *it) + val careTeams = practitionerDetails.fhirPractitionerDetails?.careTeams ?: listOf() + val organizations = practitionerDetails.fhirPractitionerDetails?.organizations ?: listOf() + val locations = practitionerDetails.fhirPractitionerDetails?.locations ?: listOf() + val practitioners = practitionerDetails.fhirPractitionerDetails?.practitioners ?: listOf() + val practitionerId = + practitionerDetails.fhirPractitionerDetails?.practitionerId.valueToString() + val locationHierarchies = + practitionerDetails.fhirPractitionerDetails?.locationHierarchyList ?: listOf() + + // TODO: Get if another user logged in previously + // TODO: Get applicationConfiguration from configurationRegistry + // Todo: Check practitionerDetails matching with current user considering + // applicationConfiguration.syncStrategy + // Todo: If not matching throw error, else continue to save + val careTeamIds = + withContext(dispatcherProvider.io()) { + defaultRepository.createRemote(false, *careTeams.toTypedArray()).run { + careTeams.map { it.id.extractLogicalIdUuid() } + } } - practitionerDetails.fhirPractitionerDetails?.practitionerRoles?.toTypedArray()?.let { - defaultRepository.createRemote(false, *it) + val organizationIds = + withContext(dispatcherProvider.io()) { + defaultRepository.createRemote(false, *organizations.toTypedArray()).run { + organizations.map { it.id.extractLogicalIdUuid() } + } } - practitionerDetails.fhirPractitionerDetails?.organizationAffiliations?.toTypedArray()?.let { - defaultRepository.createRemote(false, *it) + val locationIds = + withContext(dispatcherProvider.io()) { + defaultRepository.createRemote(false, *locations.toTypedArray()).run { + locations.map { it.id.extractLogicalIdUuid() } + } } - if (practitionerId.isNotEmpty()) { - writePractitionerDetailsToShredPref( - practitionerDetails, - careTeamIds, - organizationIds, - locationIds, - locationHierarchies, - ) - } else { - // The assumption here is that only 1 practitioner is returned from the server in the - // practitioner details - practitioners.first().identifier.forEach { identifier -> - if ( - identifier.hasUse() && - identifier.use == org.hl7.fhir.r4.model.Identifier.IdentifierUse.SECONDARY && - identifier.hasValue() && - identifier.value == userInfo!!.keycloakUuid - ) { - writePractitionerDetailsToShredPref( - practitionerDetails, - careTeamIds, - organizationIds, - locationIds, - locationHierarchies, - ) - } + defaultRepository.createRemote(false, *practitioners.toTypedArray()) + practitionerDetails.fhirPractitionerDetails?.groups?.toTypedArray()?.let { + defaultRepository.createRemote(false, *it) + } + practitionerDetails.fhirPractitionerDetails?.practitionerRoles?.toTypedArray()?.let { + defaultRepository.createRemote(false, *it) + } + practitionerDetails.fhirPractitionerDetails?.organizationAffiliations?.toTypedArray()?.let { + defaultRepository.createRemote(false, *it) + } + + if (practitionerId.isNotEmpty()) { + writePractitionerDetailsToShredPref( + practitionerDetails, + careTeamIds, + organizationIds, + locationIds, + locationHierarchies, + ) + } else { + // The assumption here is that only 1 practitioner is returned from the server in the + // practitioner details + practitioners.first().identifier.forEach { identifier -> + if ( + identifier.hasUse() && + identifier.use == org.hl7.fhir.r4.model.Identifier.IdentifierUse.SECONDARY && + identifier.hasValue() && + identifier.value == userInfo!!.keycloakUuid + ) { + writePractitionerDetailsToShredPref( + practitionerDetails, + careTeamIds, + organizationIds, + locationIds, + locationHierarchies, + ) } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt index 9ab76d56cf..51ebea1190 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt @@ -259,8 +259,8 @@ constructor( menuConfig.actions?.find { actionConfig -> actionConfig.trigger == ActionTrigger.ON_COUNT } - registerCountMap[countAction?.id ?: menuConfig.id] = - registerRepository.countRegisterData(menuConfig.id) + // registerCountMap[countAction?.id ?: menuConfig.id] = + // registerRepository.countRegisterData(menuConfig.id) } } diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 765e7fc1c5..a69b99a68d 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -108,5 +108,6 @@ Unable to populate the questionnaire. Invalid Response. Processing questionnaire data… Loading questionnaire… + Error matching sync strategy diff --git a/android/quest/src/main/res/xml/network_security_config.xml b/android/quest/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..be5d7609d9 --- /dev/null +++ b/android/quest/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + http://10.0.2.2 + 10.0.2.2 + fhir-proxy-dev-1.d-tree.org + + \ No newline at end of file diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginViewModelTest.kt index 1cca2f6586..eeb4803f37 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginViewModelTest.kt @@ -179,6 +179,7 @@ internal class LoginViewModelTest : RobolectricTest() { @Test fun testSuccessfulOnlineLoginWithActiveSessionWithSavedPractitionerDetails() { updateCredentials() + secureSharedPreference.saveCredentials(thisUsername, thisPassword.toCharArray()) sharedPreferencesHelper.write( SharedPreferenceKey.PRACTITIONER_DETAILS.name, PractitionerDetails(),