diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt index c4abca4b5e1..d5a25304d46 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt @@ -67,6 +67,9 @@ constructor( private val authConfiguration by lazy { configService.provideAuthConfiguration() } private var isLoginPageRendered = false + /** When true, suppresses automatic LoginActivity launch (e.g., during questionnaire filling) */ + var suppressLoginRedirect = false + fun getAccessToken(): String { val account = findAccount() ?: return "" val accessToken = accountManager.peekAuthToken(account, AUTH_TOKEN_TYPE) ?: "" @@ -118,12 +121,16 @@ constructor( bundle.containsKey(AccountManager.KEY_INTENT) -> { val launchIntent = bundle.get(AccountManager.KEY_INTENT) as? Intent - // Deletes session PIN to allow reset - secureSharedPreference.deleteSessionPin() + if (suppressLoginRedirect) { + Timber.w("Login redirect suppressed — questionnaire in progress") + } else { + // Deletes session PIN to allow reset + secureSharedPreference.deleteSessionPin() - if (launchIntent != null && !isLoginPageRendered) { - context.startActivity(launchIntent.putExtra(CANCEL_BACKGROUND_SYNC, true)) - isLoginPageRendered = true + if (launchIntent != null && !isLoginPageRendered) { + context.startActivity(launchIntent.putExtra(CANCEL_BACKGROUND_SYNC, true)) + isLoginPageRendered = true + } } } } diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index ec06e3d0b9f..3a3464dfb2f 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -121,6 +121,7 @@ % Logging out. Please wait… Session has been expired and must login again + Your form has been saved successfully. Your session has expired, please log in again to continue. Sex Age DOB diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt index 35a3bcc4039..30ab91a228b 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt @@ -18,9 +18,12 @@ package org.smartregister.fhircore.engine.auth import android.accounts.Account import android.accounts.AccountManager +import android.accounts.AccountManagerCallback import android.accounts.AccountManagerFuture import android.accounts.AuthenticatorException import android.accounts.OperationCanceledException +import android.content.Context +import android.content.Intent import android.os.Bundle import androidx.core.os.bundleOf import androidx.test.core.app.ApplicationProvider @@ -447,6 +450,95 @@ class TokenAuthenticatorTest : RobolectricTest() { Assert.assertTrue(tokenAuthenticator.isCurrentRefreshTokenActive()) } + @Test + fun testGetAccessTokenWithSuppressedLoginRedirectDoesNotDeletePinOrLaunchLogin() { + val account = Account(sampleUsername, PROVIDER) + val accessToken = "gibberishaccesstoken" + val spiedSecureSharedPreference = spyk(secureSharedPreference) + val mockedContext = mockk(relaxed = true) + val tokenAuthenticator = + spyk( + TokenAuthenticator( + secureSharedPreference = spiedSecureSharedPreference, + configService = configService, + oAuthService = oAuthService, + dispatcherProvider = dispatcherProvider, + accountManager = accountManager, + context = mockedContext, + ), + ) + tokenAuthenticator.suppressLoginRedirect = true + + val callbackSlot = slot>() + val accountManagerFuture = mockk>() + every { accountManagerFuture.result } returns bundleOf(AccountManager.KEY_INTENT to Intent()) + every { tokenAuthenticator.findAccount() } returns account + every { tokenAuthenticator.isTokenActive(any()) } returns false + every { accountManager.peekAuthToken(account, AUTH_TOKEN_TYPE) } returns accessToken + every { accountManager.invalidateAuthToken(account.type, accessToken) } just runs + every { + accountManager.getAuthToken( + account, + AUTH_TOKEN_TYPE, + any(), + true, + capture(callbackSlot), + any(), + ) + } returns accountManagerFuture + + tokenAuthenticator.getAccessToken() + // Simulate AccountManager invoking the callback with a re-authentication intent + callbackSlot.captured.run(accountManagerFuture) + + verify(exactly = 0) { spiedSecureSharedPreference.deleteSessionPin() } + verify(exactly = 0) { mockedContext.startActivity(any()) } + } + + @Test + fun testGetAccessTokenWithoutSuppressedLoginRedirectDeletesPinAndLaunchesLogin() { + val account = Account(sampleUsername, PROVIDER) + val accessToken = "gibberishaccesstoken" + val spiedSecureSharedPreference = spyk(secureSharedPreference) + val mockedContext = mockk(relaxed = true) + val tokenAuthenticator = + spyk( + TokenAuthenticator( + secureSharedPreference = spiedSecureSharedPreference, + configService = configService, + oAuthService = oAuthService, + dispatcherProvider = dispatcherProvider, + accountManager = accountManager, + context = mockedContext, + ), + ) + + val callbackSlot = slot>() + val accountManagerFuture = mockk>() + every { accountManagerFuture.result } returns bundleOf(AccountManager.KEY_INTENT to Intent()) + every { tokenAuthenticator.findAccount() } returns account + every { tokenAuthenticator.isTokenActive(any()) } returns false + every { accountManager.peekAuthToken(account, AUTH_TOKEN_TYPE) } returns accessToken + every { accountManager.invalidateAuthToken(account.type, accessToken) } just runs + every { + accountManager.getAuthToken( + account, + AUTH_TOKEN_TYPE, + any(), + true, + capture(callbackSlot), + any(), + ) + } returns accountManagerFuture + + tokenAuthenticator.getAccessToken() + // Simulate AccountManager invoking the callback with a re-authentication intent + callbackSlot.captured.run(accountManagerFuture) + + verify { spiedSecureSharedPreference.deleteSessionPin() } + verify { mockedContext.startActivity(any()) } + } + companion object { private const val SCOPE = "openid" private const val PROVIDER = "provider" diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index 6d29524f456..6b3d109f647 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -40,6 +40,7 @@ import io.sentry.android.navigation.SentryNavigationListener import java.time.Instant import javax.inject.Inject import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -47,6 +48,7 @@ import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.QuestionnaireResponse import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions +import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator import org.smartregister.fhircore.engine.datastore.ProtoDataStore import org.smartregister.fhircore.engine.domain.model.LauncherType import org.smartregister.fhircore.engine.rulesengine.services.LocationCoordinate @@ -58,6 +60,8 @@ import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.extension.isDeviceOnline +import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory import org.smartregister.fhircore.engine.util.extension.parcelable import org.smartregister.fhircore.engine.util.extension.serializable import org.smartregister.fhircore.engine.util.extension.showToast @@ -66,6 +70,7 @@ import org.smartregister.fhircore.engine.util.location.PermissionUtils import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.event.AppEvent import org.smartregister.fhircore.quest.event.EventBus +import org.smartregister.fhircore.quest.ui.login.LoginActivity import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireActivity import org.smartregister.fhircore.quest.ui.shared.ActivityOnResultType import org.smartregister.fhircore.quest.ui.shared.ON_RESULT_TYPE @@ -84,6 +89,8 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, @Inject lateinit var dispatcherProvider: DispatcherProvider + @Inject lateinit var tokenAuthenticator: TokenAuthenticator + val appMainViewModel by viewModels() private val sentryNavListener = SentryNavigationListener(enableNavigationBreadcrumbs = true, enableNavigationTracing = true) @@ -110,12 +117,20 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult: ActivityResult -> val onResultType = activityResult.data?.extras?.getString(ON_RESULT_TYPE) - if ( - activityResult.resultCode == Activity.RESULT_OK && - !onResultType.isNullOrBlank() && - ActivityOnResultType.valueOf(onResultType) == ActivityOnResultType.QUESTIONNAIRE - ) { - lifecycleScope.launch { onSubmitQuestionnaire(activityResult) } + lifecycleScope.launch { + if ( + activityResult.resultCode == Activity.RESULT_OK && + !onResultType.isNullOrBlank() && + ActivityOnResultType.valueOf(onResultType) == ActivityOnResultType.QUESTIONNAIRE + ) { + onSubmitQuestionnaire(activityResult) + } + + // Check if session expired while questionnaire was open + if (isDeviceOnline() && !tokenAuthenticator.isCurrentRefreshTokenActive()) { + delay(3000) // Let user see data was saved + showSessionExpiredDialog() + } } } @@ -312,6 +327,24 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, } } + private fun showSessionExpiredDialog() { + AlertDialogue.showAlert( + context = this, + alertIntent = AlertIntent.CONFIRM, + message = getString(org.smartregister.fhircore.engine.R.string.session_expired_form_saved), + title = getString(org.smartregister.fhircore.engine.R.string.session_expired), + confirmButton = + AlertDialogButton( + listener = { dialog -> + dialog.dismiss() + launchActivityWithNoBackStackHistory() + }, + text = org.smartregister.fhircore.engine.R.string.questionnaire_alert_ack_button_title, + ), + cancellable = false, + ) + } + private fun overrideOnBackPressListener() { onBackPressedDispatcher.addCallback( object : OnBackPressedCallback(true) { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index 78dcaa3d4bf..ec3060ed1ef 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -52,6 +52,7 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Resource import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions +import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.isReadOnly import org.smartregister.fhircore.engine.domain.model.isSummary @@ -79,6 +80,8 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var fhirParser: IParser + + @Inject lateinit var tokenAuthenticator: TokenAuthenticator val viewModel by viewModels() private lateinit var questionnaireConfig: QuestionnaireConfig private lateinit var actionParameters: ArrayList @@ -142,6 +145,8 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { return } + tokenAuthenticator.suppressLoginRedirect = true + viewBinding.questionnaireToolbar.setNavigationIcon(R.drawable.ic_cancel) viewBinding.questionnaireToolbar.setNavigationOnClickListener { handleBackPress() } viewBinding.questionnaireTitle.text = questionnaireConfig.title @@ -607,6 +612,11 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { (supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG) as QuestionnaireFragment?) ?.getQuestionnaireResponse() + override fun onDestroy() { + tokenAuthenticator.suppressLoginRedirect = false + super.onDestroy() + } + companion object { const val QUESTIONNAIRE_FRAGMENT_TAG = "questionnaireFragment" diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt index 2c80d743971..b8a84fe9a36 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt @@ -247,4 +247,27 @@ class QuestionnaireActivityTest : RobolectricTest() { ) questionnaireActivity = questionnaireActivityController.create().resume().get() } + + @Test + fun testSuppressLoginRedirectIsEnabledOnLaunchAndDisabledOnDestroy() { + // suppressLoginRedirect is toggled synchronously in onCreate/onDestroy, so the questionnaire + // does not need to be seeded. A local controller keeps the shared tearDown from destroying it + // twice, and driving the lifecycle outside a coroutine scope avoids cancelling the in-flight + // launchQuestionnaire() coroutine. + val bundle = QuestionnaireActivity.intentBundle(questionnaireConfig, emptyList()) + val controller = + Robolectric.buildActivity( + QuestionnaireActivity::class.java, + Intent().apply { putExtras(bundle) }, + ) + val activity = controller.create().resume().get() + + // While the questionnaire is open the token authenticator must not redirect to login + Assert.assertTrue(activity.tokenAuthenticator.suppressLoginRedirect) + + controller.destroy() + + // Once the questionnaire is closed the default redirect behaviour is restored + Assert.assertFalse(activity.tokenAuthenticator.suppressLoginRedirect) + } }