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)
+ }
}