Conversation
Codecov Report❌ Patch coverage is
❌ Your patch check has failed because the patch coverage (0.0%) is below the target coverage (60.0%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #3830 +/- ##
=========================================
+ Coverage 25.0% 25.2% +0.1%
Complexity 844 844
=========================================
Files 297 299 +2
Lines 16102 15884 -218
Branches 2689 2637 -52
=========================================
- Hits 4038 4013 -25
+ Misses 11580 11389 -191
+ Partials 484 482 -2
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #3829 by preventing automatic logout/login redirection while a user is actively filling out a Questionnaire, and prompting the user to re-authenticate only after the form is saved/submitted.
Changes:
- Add a suppression flag in
TokenAuthenticatorto prevent automatic login redirection during questionnaire completion. - Toggle suppression on/off from
QuestionnaireActivitylifecycle. - After returning from a questionnaire, detect session expiry and show a non-cancellable “session expired, form saved” dialog that routes to
LoginActivity.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt |
Enables/disables auth redirect suppression while questionnaire UI is active. |
android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt |
Adds post-questionnaire refresh-token check and a session-expired confirmation dialog that navigates to login. |
android/engine/src/main/res/values/strings.xml |
Adds a new UI string for “form saved, session expired” messaging. |
android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt |
Introduces suppressLoginRedirect and uses it to skip automatic login redirection on auth failure. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -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 | |||
| } | |||
| } | |||
There was a problem hiding this comment.
New suppression behavior (suppressLoginRedirect) changes the auth failure flow but isn’t covered by tests. There are existing Robolectric tests for TokenAuthenticator; please add coverage that verifies: (1) when suppression is enabled and KEY_INTENT is returned, the authenticator does not start the login intent, and (2) when suppression is disabled, the login intent is started as before (and any required cleanup like session PIN deletion still occurs).
| @@ -607,6 +612,11 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { | |||
| (supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG) as QuestionnaireFragment?) | |||
| ?.getQuestionnaireResponse() | |||
|
|
|||
| override fun onDestroy() { | |||
| tokenAuthenticator.suppressLoginRedirect = false | |||
| super.onDestroy() | |||
There was a problem hiding this comment.
tokenAuthenticator.suppressLoginRedirect is toggled as a global flag from this activity. Please add/extend Robolectric coverage to ensure it is set to true while the questionnaire is active and reliably reset to false when the activity finishes/destroys (including the common finish paths like submit, save-draft, and cancel). This helps prevent the app getting stuck in a permanently suppressed state if lifecycle paths change.
| @@ -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> | |||
There was a problem hiding this comment.
The session_expired string is grammatically incorrect ("Session has been expired and must login again"). Since this dialog/title is used with the new session-expired messaging, consider updating it to something like "Your session has expired. Please log in again."
| <string name="session_expired">Session has been expired and must login again</string> | |
| <string name="session_expired">Your session has expired. Please log in again.</string> |
| if ( | ||
| activityResult.resultCode == Activity.RESULT_OK && | ||
| !onResultType.isNullOrBlank() && | ||
| ActivityOnResultType.valueOf(onResultType) == ActivityOnResultType.QUESTIONNAIRE | ||
| ) { | ||
| onSubmitQuestionnaire(activityResult) | ||
| } | ||
|
|
||
| // Check if session expired while questionnaire was open | ||
| if (!tokenAuthenticator.isCurrentRefreshTokenActive()) { | ||
| delay(3000) // Let user see data was saved | ||
| showSessionExpiredDialog() |
There was a problem hiding this comment.
The new post-questionnaire session-expiry behavior in the startForResult callback isn’t covered by existing AppMainActivityTests. Please add tests to verify the dialog is only shown for questionnaire results (not for LOCATION or other result types), and only when the refresh token is inactive.
| if ( | |
| activityResult.resultCode == Activity.RESULT_OK && | |
| !onResultType.isNullOrBlank() && | |
| ActivityOnResultType.valueOf(onResultType) == ActivityOnResultType.QUESTIONNAIRE | |
| ) { | |
| onSubmitQuestionnaire(activityResult) | |
| } | |
| // Check if session expired while questionnaire was open | |
| if (!tokenAuthenticator.isCurrentRefreshTokenActive()) { | |
| delay(3000) // Let user see data was saved | |
| showSessionExpiredDialog() | |
| val isQuestionnaireResult = | |
| activityResult.resultCode == Activity.RESULT_OK && | |
| !onResultType.isNullOrBlank() && | |
| ActivityOnResultType.valueOf(onResultType) == ActivityOnResultType.QUESTIONNAIRE | |
| if (isQuestionnaireResult) { | |
| onSubmitQuestionnaire(activityResult) | |
| // Check if session expired while questionnaire was open | |
| if (!tokenAuthenticator.isCurrentRefreshTokenActive()) { | |
| delay(3000) // Let user see data was saved | |
| showSessionExpiredDialog() | |
| } |
| } | ||
|
|
||
| // Check if session expired while questionnaire was open | ||
| if (!tokenAuthenticator.isCurrentRefreshTokenActive()) { | ||
| delay(3000) // Let user see data was saved | ||
| showSessionExpiredDialog() |
There was a problem hiding this comment.
The session-expiry check runs for every ActivityResult handled by startForResult, including non-questionnaire flows like the location settings intent (showLocationSettingsDialog() uses startForResult.launch(intent)). This can unexpectedly force a login prompt after returning from location settings (or any other future startForResult usage). Consider gating the refresh-token check so it only runs for questionnaire results (e.g., when onResultType == QUESTIONNAIRE, and ideally after a successful save/submit).
| } | |
| // Check if session expired while questionnaire was open | |
| if (!tokenAuthenticator.isCurrentRefreshTokenActive()) { | |
| delay(3000) // Let user see data was saved | |
| showSessionExpiredDialog() | |
| // Check if session expired while questionnaire was open | |
| if (!tokenAuthenticator.isCurrentRefreshTokenActive()) { | |
| delay(3000) // Let user see data was saved | |
| showSessionExpiredDialog() | |
| } |
| if (!tokenAuthenticator.isCurrentRefreshTokenActive()) { | ||
| delay(3000) // Let user see data was saved | ||
| showSessionExpiredDialog() |
There was a problem hiding this comment.
delay(3000) is a hard-coded timing value in the result handler. If this delay is meant to be consistent UX behavior, consider extracting it to a named constant (or configurable value) to make the intent clear and avoid magic numbers scattered in callbacks.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
When suppressLoginRedirect is true, the KEY_INTENT branch skips both launching the login and deleting the session PIN. Previously, session PIN cleanup happened whenever re-authentication was required; leaving the PIN intact after an auth failure can change security/login behavior (e.g., stale PIN still present even though the session is invalid). Consider still clearing the session PIN even when redirect is suppressed, and only suppress the UI navigation portion.
| private var isLoginPageRendered = false | ||
|
|
||
| /** When true, suppresses automatic LoginActivity launch (e.g., during questionnaire filling) */ | ||
| var suppressLoginRedirect = false |
There was a problem hiding this comment.
Is there another approach that could be used instead of relying on this property of the class to know when the redirect is suppressed?
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
The logging part of the if statement is unnecessary.
IMPORTANT: Where possible all PRs must be linked to a Github issue
Fixes #3829
Engineer Checklist
strings.xmlfile./gradlew spotlessApplyand./gradlew spotlessCheckto check my code follows the project's style guideCode Reviewer Checklist
strings.xmlfile