Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?: ""
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions android/engine/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
<string name="percentage" translatable="false">%</string>
<string name="logging_out">Logging out. Please wait…</string>
<string name="session_expired">Session has been expired and must login again</string>
<string name="session_expired_form_saved">Your form has been saved successfully. Your session has expired, please log in again to continue.</string>
<string name="sex">Sex</string>
<string name="age">Age</string>
<string name="dob">DOB</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Context>(relaxed = true)
val tokenAuthenticator =
spyk(
TokenAuthenticator(
secureSharedPreference = spiedSecureSharedPreference,
configService = configService,
oAuthService = oAuthService,
dispatcherProvider = dispatcherProvider,
accountManager = accountManager,
context = mockedContext,
),
)
tokenAuthenticator.suppressLoginRedirect = true

val callbackSlot = slot<AccountManagerCallback<Bundle>>()
val accountManagerFuture = mockk<AccountManagerFuture<Bundle>>()
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<Context>(relaxed = true)
val tokenAuthenticator =
spyk(
TokenAuthenticator(
secureSharedPreference = spiedSecureSharedPreference,
configService = configService,
oAuthService = oAuthService,
dispatcherProvider = dispatcherProvider,
accountManager = accountManager,
context = mockedContext,
),
)

val callbackSlot = slot<AccountManagerCallback<Bundle>>()
val accountManagerFuture = mockk<AccountManagerFuture<Bundle>>()
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ 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
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -84,6 +89,8 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler,

@Inject lateinit var dispatcherProvider: DispatcherProvider

@Inject lateinit var tokenAuthenticator: TokenAuthenticator

val appMainViewModel by viewModels<AppMainViewModel>()
private val sentryNavListener =
SentryNavigationListener(enableNavigationBreadcrumbs = true, enableNavigationTracing = true)
Expand All @@ -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()
}
}
}

Expand Down Expand Up @@ -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<LoginActivity>()
},
text = org.smartregister.fhircore.engine.R.string.questionnaire_alert_ack_button_title,
),
cancellable = false,
)
}

private fun overrideOnBackPressListener() {
onBackPressedDispatcher.addCallback(
object : OnBackPressedCallback(true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<QuestionnaireViewModel>()
private lateinit var questionnaireConfig: QuestionnaireConfig
private lateinit var actionParameters: ArrayList<ActionParameter>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading