From 1b5247182d5409bbc13cbea583e4a3d79350a02b Mon Sep 17 00:00:00 2001 From: Ahmad Vazirna Date: Wed, 22 Apr 2026 09:48:47 +0200 Subject: [PATCH 1/8] Introduce NavResult sealed class for form navigation outcomes --- app/src/org/commcare/activities/NavResult.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/src/org/commcare/activities/NavResult.kt diff --git a/app/src/org/commcare/activities/NavResult.kt b/app/src/org/commcare/activities/NavResult.kt new file mode 100644 index 0000000000..675b2e7957 --- /dev/null +++ b/app/src/org/commcare/activities/NavResult.kt @@ -0,0 +1,14 @@ +package org.commcare.activities + +import org.javarosa.xpath.XPathException + +/** + * Outcome of stepping the form to the next renderable event. + */ +sealed class NavResult { + object Question : NavResult() + object FieldListGroup : NavResult() + object PromptNewRepeat : NavResult() + object EndOfForm : NavResult() + data class Error(val exception: XPathException) : NavResult() +} \ No newline at end of file From 08dc293287c28025437b57b435f4b1f290488f76 Mon Sep 17 00:00:00 2001 From: Ahmad Vazirna Date: Wed, 22 Apr 2026 10:09:16 +0200 Subject: [PATCH 2/8] Extract stepToRenderableEvent and renderNavResult from showNextView Split the form navigation logic into a pure state-stepping function (stepToRenderableEvent) that returns a NavResult, plus a main-thread dispatcher (renderNavResult) that handles view creation and dialog display. Behavior unchanged; this is the seam needed to later move stepping off the main thread. --- .../FormEntryActivityUIController.java | 137 ++++++++++-------- 1 file changed, 74 insertions(+), 63 deletions(-) diff --git a/app/src/org/commcare/activities/FormEntryActivityUIController.java b/app/src/org/commcare/activities/FormEntryActivityUIController.java index 37bd673d66..ab93d680a2 100644 --- a/app/src/org/commcare/activities/FormEntryActivityUIController.java +++ b/app/src/org/commcare/activities/FormEntryActivityUIController.java @@ -417,71 +417,82 @@ private void showNextView(boolean resuming) { // Any info stored about the last changed widget is useless when we move to a new view resetLastChangedWidget(); - if (FormEntryActivity.mFormController.getEvent() != FormEntryController.EVENT_END_OF_FORM) { - int event; + if (FormEntryActivity.mFormController.getEvent() == FormEntryController.EVENT_END_OF_FORM) { + return; + } - try { - group_skip: - do { - event = FormEntryActivity.mFormController.stepToNextEvent(FormEntryController.STEP_OVER_GROUP); - switch (event) { - case FormEntryController.EVENT_QUESTION: - QuestionsView next = createView(); - if (!resuming) { - showView(next, LocalePreferences.isLocaleRTL() ? AnimationType.LEFT : AnimationType.RIGHT); - } else { - showView(next, AnimationType.FADE, false); - } - break group_skip; - case FormEntryController.EVENT_END_OF_FORM: - // auto-advance questions might advance past the last form quesion - // In special case when questionsView is null (there is no question), - // to avoid exit dialog without saving option, shown from refreshCurrentView(), - // save and exit method called. - if (questionsView != null) { - refreshCurrentView(); - } else { - activity.triggerUserFormComplete(); - } - break group_skip; - case FormEntryController.EVENT_PROMPT_NEW_REPEAT: - createRepeatDialog(); - break group_skip; - case FormEntryController.EVENT_GROUP: - //We only hit this event if we're at the _opening_ of a field - //list, so it seems totally fine to do it this way, technically - //though this should test whether the index is the field list - //host. - if (FormEntryActivity.mFormController.indexIsInFieldList() - && FormEntryActivity.mFormController.getQuestionPrompts().length != 0) { - QuestionsView nextGroupView = createView(); - if (!resuming) { - showView(nextGroupView, LocalePreferences.isLocaleRTL() ? AnimationType.LEFT : AnimationType.RIGHT); - } else { - showView(nextGroupView, AnimationType.FADE, false); - } - break group_skip; - } - // otherwise it's not a field-list group, so just skip it - break; - case FormEntryController.EVENT_REPEAT: - Log.i(TAG, "repeat: " + FormEntryActivity.mFormController.getFormIndex().getReference()); - // skip repeats - break; - case FormEntryController.EVENT_REPEAT_JUNCTURE: - Log.i(TAG, "repeat juncture: " - + FormEntryActivity.mFormController.getFormIndex().getReference()); - // skip repeat junctures until we implement them - break; - default: - Log.w(TAG, - "JavaRosa added a new EVENT type and didn't tell us... shame on them."); - break; - } - } while (event != FormEntryController.EVENT_END_OF_FORM); - } catch (XPathException e) { - new UserfacingErrorHandling<>().logErrorAndShowDialog(activity, e, FormEntryConstants.EXIT); + renderNavResult(stepToRenderableEvent(), resuming); + } + + /** + * Walks the form forward via {@link FormEntryController#stepToNextEvent} until a renderable + * event is reached. Pure controller-state access; no view creation or UI side effects. + */ + private NavResult stepToRenderableEvent() { + try { + while (true) { + int event = FormEntryActivity.mFormController.stepToNextEvent(FormEntryController.STEP_OVER_GROUP); + switch (event) { + case FormEntryController.EVENT_QUESTION: + return NavResult.Question.INSTANCE; + case FormEntryController.EVENT_END_OF_FORM: + return NavResult.EndOfForm.INSTANCE; + case FormEntryController.EVENT_PROMPT_NEW_REPEAT: + return NavResult.PromptNewRepeat.INSTANCE; + case FormEntryController.EVENT_GROUP: + //We only hit this event if we're at the _opening_ of a field + //list, so it seems totally fine to do it this way, technically + //though this should test whether the index is the field list + //host. + if (FormEntryActivity.mFormController.indexIsInFieldList() + && FormEntryActivity.mFormController.getQuestionPrompts().length != 0) { + return NavResult.FieldListGroup.INSTANCE; + } + // otherwise it's not a field-list group, so just skip it + break; + case FormEntryController.EVENT_REPEAT: + Log.i(TAG, "repeat: " + FormEntryActivity.mFormController.getFormIndex().getReference()); + // skip repeats + break; + case FormEntryController.EVENT_REPEAT_JUNCTURE: + Log.i(TAG, "repeat juncture: " + + FormEntryActivity.mFormController.getFormIndex().getReference()); + // skip repeat junctures until we implement them + break; + default: + Log.w(TAG, + "JavaRosa added a new EVENT type and didn't tell us... shame on them."); + break; + } + } + } catch (XPathException e) { + return new NavResult.Error(e); + } + } + + private void renderNavResult(NavResult result, boolean resuming) { + if (result instanceof NavResult.Question || result instanceof NavResult.FieldListGroup) { + QuestionsView next = createView(); + if (!resuming) { + showView(next, LocalePreferences.isLocaleRTL() ? AnimationType.LEFT : AnimationType.RIGHT); + } else { + showView(next, AnimationType.FADE, false); + } + } else if (result instanceof NavResult.PromptNewRepeat) { + createRepeatDialog(); + } else if (result instanceof NavResult.EndOfForm) { + // auto-advance questions might advance past the last form question + // In special case when questionsView is null (there is no question), + // to avoid exit dialog without saving option, shown from refreshCurrentView(), + // save and exit method called. + if (questionsView != null) { + refreshCurrentView(); + } else { + activity.triggerUserFormComplete(); } + } else if (result instanceof NavResult.Error) { + new UserfacingErrorHandling<>().logErrorAndShowDialog( + activity, ((NavResult.Error)result).getException(), FormEntryConstants.EXIT); } } From aa3984848a6dab5eb421e1e39e900db4267c4615 Mon Sep 17 00:00:00 2001 From: Ahmad Vazirna Date: Wed, 22 Apr 2026 10:58:10 +0200 Subject: [PATCH 3/8] Add AsyncFormNavigator helper for off-main-thread form navigation Introduces a reusable helper that runs form-stepping work on a background dispatcher and delivers the resulting NavResult back on the main thread, with a re-entrancy guard and overlay visibility hook. Not wired into the UI yet. Co-Authored-By: Claude Opus 4.7 --- .../commcare/activities/AsyncFormNavigator.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 app/src/org/commcare/activities/AsyncFormNavigator.kt diff --git a/app/src/org/commcare/activities/AsyncFormNavigator.kt b/app/src/org/commcare/activities/AsyncFormNavigator.kt new file mode 100644 index 0000000000..629ed1c74f --- /dev/null +++ b/app/src/org/commcare/activities/AsyncFormNavigator.kt @@ -0,0 +1,53 @@ +package org.commcare.activities + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Runs form-navigation stepping on a background thread and delivers the + * resulting renderable event back on the main thread. + * + * Re-entry while a navigation is already in flight is a no-op. + */ +class AsyncFormNavigator( + private val lifecycleOwner: LifecycleOwner, + private val stepWork: StepWork, + private val overlayCallback: OverlayCallback +) { + + fun interface StepWork { + fun step(): NavResult + } + + fun interface OverlayCallback { + fun setVisible(visible: Boolean) + } + + fun interface ResultCallback { + fun onResult(result: NavResult) + } + + private var navigationInFlight = false + + fun isNavigationInFlight(): Boolean = navigationInFlight + + fun navigate(onComplete: ResultCallback) { + if (navigationInFlight) { + return + } + navigationInFlight = true + overlayCallback.setVisible(true) + lifecycleOwner.lifecycleScope.launch { + try { + val result = withContext(Dispatchers.Default) { stepWork.step() } + onComplete.onResult(result) + } finally { + navigationInFlight = false + overlayCallback.setVisible(false) + } + } + } +} \ No newline at end of file From f5aab51483411f12dd4c22232220e6b64d101d26 Mon Sep 17 00:00:00 2001 From: Ahmad Vazirna Date: Wed, 22 Apr 2026 11:03:58 +0200 Subject: [PATCH 4/8] Run form navigation stepping off the main thread Wires showNextView through AsyncFormNavigator so stepToRenderableEvent runs on Dispatchers.Default. A loading overlay with an indeterminate ProgressBar is shown while the step is in flight and hidden before renderNavResult runs on the main thread. shouldIgnoreNavigationAction now also returns true while a navigation is in flight to prevent re-entry from rapid Next taps or a Previous during a forward step. Co-Authored-By: Claude Opus 4.7 --- app/res/layout/screen_form_entry.xml | 16 ++++++++++++++++ .../FormEntryActivityUIController.java | 12 ++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/res/layout/screen_form_entry.xml b/app/res/layout/screen_form_entry.xml index 1ebf0dd240..ff80fe6e29 100644 --- a/app/res/layout/screen_form_entry.xml +++ b/app/res/layout/screen_form_entry.xml @@ -212,6 +212,22 @@ android:background="@color/transclucent_nearly_solid_grey" android:clickable="true" android:visibility="gone"/> + + + + + diff --git a/app/src/org/commcare/activities/FormEntryActivityUIController.java b/app/src/org/commcare/activities/FormEntryActivityUIController.java index ab93d680a2..25b48bb4b3 100644 --- a/app/src/org/commcare/activities/FormEntryActivityUIController.java +++ b/app/src/org/commcare/activities/FormEntryActivityUIController.java @@ -84,6 +84,8 @@ public class FormEntryActivityUIController implements CommCareActivityUIControll private static final String KEY_LAST_CHANGED_WIDGET = "index-of-last-changed-widget"; private TextView finishText; + private View loadingOverlay; + private AsyncFormNavigator asyncFormNavigator; enum AnimationType { LEFT, RIGHT, FADE @@ -144,6 +146,11 @@ public void setupUI() { mViewPane = activity.findViewById(R.id.form_entry_pane); + loadingOverlay = activity.findViewById(R.id.form_entry_loading_overlay); + asyncFormNavigator = new AsyncFormNavigator( + activity, + this::stepToRenderableEvent, + visible -> loadingOverlay.setVisibility(visible ? View.VISIBLE : View.GONE)); activity.requestMajorLayoutUpdates(); @@ -421,7 +428,7 @@ private void showNextView(boolean resuming) { return; } - renderNavResult(stepToRenderableEvent(), resuming); + asyncFormNavigator.navigate(result -> renderNavResult(result, resuming)); } /** @@ -609,7 +616,8 @@ protected void next() { } protected boolean shouldIgnoreNavigationAction() { - return blockingActionsManager.isBlocked(); + return blockingActionsManager.isBlocked() + || (asyncFormNavigator != null && asyncFormNavigator.isNavigationInFlight()); } protected boolean shouldIgnoreSwipeAction() { From 2ec2e6e1652595c808c040931182b8861a4a9aaf Mon Sep 17 00:00:00 2001 From: Ahmad Vazirna Date: Wed, 22 Apr 2026 11:46:14 +0200 Subject: [PATCH 5/8] Delay form loading overlay by 150 ms and add localized label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the overlay view in a FormLoadingOverlay helper whose show() posts a 150 ms-delayed visibility change and whose hide() cancels any pending show so fast navigations don't flash the overlay. Adds a centered "Loading next question…" TextView below the ProgressBar, fed from the new form.entry.loading.next.question locale entry. Co-Authored-By: Claude Opus 4.7 --- .../locales/android_translatable_strings.txt | 1 + app/res/layout/screen_form_entry.xml | 17 +++++++- .../FormEntryActivityUIController.java | 15 +++++-- .../commcare/activities/FormLoadingOverlay.kt | 40 +++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 app/src/org/commcare/activities/FormLoadingOverlay.kt diff --git a/app/assets/locales/android_translatable_strings.txt b/app/assets/locales/android_translatable_strings.txt index c5c9a103b3..6c5cd0df6d 100644 --- a/app/assets/locales/android_translatable_strings.txt +++ b/app/assets/locales/android_translatable_strings.txt @@ -166,6 +166,7 @@ sync.recover.started=Recovering local DB State. Please do not turn off the app! form.entry.segfault=There was an unrecoverable error during form entry! If the problem persists, seek CommCare Support form.entry.processing=Processing your Form form.entry.processing.title=Processing Form +form.entry.loading.next.question=Loading next question… form.entry.complete.save.success=Form successfully completed form.entry.incomplete.save.success=Form saved as incomplete form.entry.save.error=Sorry, form save failed. Please contact CommCare Support to look into the issue. diff --git a/app/res/layout/screen_form_entry.xml b/app/res/layout/screen_form_entry.xml index ff80fe6e29..619c3395f4 100644 --- a/app/res/layout/screen_form_entry.xml +++ b/app/res/layout/screen_form_entry.xml @@ -222,11 +222,24 @@ android:focusable="true" android:visibility="gone"> - + android:orientation="vertical" + android:gravity="center_horizontal"> + + + + + diff --git a/app/src/org/commcare/activities/FormEntryActivityUIController.java b/app/src/org/commcare/activities/FormEntryActivityUIController.java index 25b48bb4b3..1fb33a1720 100644 --- a/app/src/org/commcare/activities/FormEntryActivityUIController.java +++ b/app/src/org/commcare/activities/FormEntryActivityUIController.java @@ -84,7 +84,7 @@ public class FormEntryActivityUIController implements CommCareActivityUIControll private static final String KEY_LAST_CHANGED_WIDGET = "index-of-last-changed-widget"; private TextView finishText; - private View loadingOverlay; + private FormLoadingOverlay formLoadingOverlay; private AsyncFormNavigator asyncFormNavigator; enum AnimationType { @@ -146,11 +146,20 @@ public void setupUI() { mViewPane = activity.findViewById(R.id.form_entry_pane); - loadingOverlay = activity.findViewById(R.id.form_entry_loading_overlay); + formLoadingOverlay = new FormLoadingOverlay( + activity.findViewById(R.id.form_entry_loading_overlay), + activity.findViewById(R.id.form_entry_loading_label), + Localization.get("form.entry.loading.next.question")); asyncFormNavigator = new AsyncFormNavigator( activity, this::stepToRenderableEvent, - visible -> loadingOverlay.setVisibility(visible ? View.VISIBLE : View.GONE)); + visible -> { + if (visible) { + formLoadingOverlay.show(); + } else { + formLoadingOverlay.hide(); + } + }); activity.requestMajorLayoutUpdates(); diff --git a/app/src/org/commcare/activities/FormLoadingOverlay.kt b/app/src/org/commcare/activities/FormLoadingOverlay.kt new file mode 100644 index 0000000000..0fc8a34c33 --- /dev/null +++ b/app/src/org/commcare/activities/FormLoadingOverlay.kt @@ -0,0 +1,40 @@ +package org.commcare.activities + +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.TextView + +/** + * Wraps the form-entry loading overlay view. [show] posts a delayed + * visibility change so fast navigations don't flash the overlay; [hide] + * cancels any pending show and hides the view immediately. + */ +class FormLoadingOverlay @JvmOverloads constructor( + private val overlayView: View, + labelView: TextView, + labelText: String, + private val showDelayMillis: Long = DEFAULT_SHOW_DELAY_MILLIS +) { + + private val handler = Handler(Looper.getMainLooper()) + private val showRunnable = Runnable { overlayView.visibility = View.VISIBLE } + + init { + labelView.text = labelText + } + + fun show() { + handler.removeCallbacks(showRunnable) + handler.postDelayed(showRunnable, showDelayMillis) + } + + fun hide() { + handler.removeCallbacks(showRunnable) + overlayView.visibility = View.GONE + } + + companion object { + const val DEFAULT_SHOW_DELAY_MILLIS: Long = 150 + } +} From a35839da7e23466e7d9934cbdb3d01743d3382e9 Mon Sep 17 00:00:00 2001 From: Ahmad Vazirna Date: Wed, 22 Apr 2026 12:10:31 +0200 Subject: [PATCH 6/8] Give AsyncFormNavigator a cancellable Job and per-call callbacks Moves navigationInFlight out of AsyncFormNavigator and onto FormEntryActivityUIController so the UI controller owns nav gating (checked from shouldIgnoreNavigationAction). The navigator now takes the start/result callbacks per navigate() call instead of an overlay callback in the constructor, retains the launched Job, and exposes cancel() so lifecycle hooks can abort an in-flight step. Co-Authored-By: Claude Opus 4.7 --- .../commcare/activities/AsyncFormNavigator.kt | 38 ++++++++----------- .../FormEntryActivityUIController.java | 23 +++++------ 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/app/src/org/commcare/activities/AsyncFormNavigator.kt b/app/src/org/commcare/activities/AsyncFormNavigator.kt index 629ed1c74f..7b9b37ad6b 100644 --- a/app/src/org/commcare/activities/AsyncFormNavigator.kt +++ b/app/src/org/commcare/activities/AsyncFormNavigator.kt @@ -3,51 +3,43 @@ package org.commcare.activities import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * Runs form-navigation stepping on a background thread and delivers the * resulting renderable event back on the main thread. - * - * Re-entry while a navigation is already in flight is a no-op. */ class AsyncFormNavigator( private val lifecycleOwner: LifecycleOwner, - private val stepWork: StepWork, - private val overlayCallback: OverlayCallback + private val stepWork: StepWork ) { fun interface StepWork { fun step(): NavResult } - fun interface OverlayCallback { - fun setVisible(visible: Boolean) + fun interface StartCallback { + fun onStart() } fun interface ResultCallback { fun onResult(result: NavResult) } - private var navigationInFlight = false + private var job: Job? = null - fun isNavigationInFlight(): Boolean = navigationInFlight - - fun navigate(onComplete: ResultCallback) { - if (navigationInFlight) { - return - } - navigationInFlight = true - overlayCallback.setVisible(true) - lifecycleOwner.lifecycleScope.launch { - try { - val result = withContext(Dispatchers.Default) { stepWork.step() } - onComplete.onResult(result) - } finally { - navigationInFlight = false - overlayCallback.setVisible(false) - } + fun navigate(resuming: Boolean, onStart: StartCallback, onResult: ResultCallback) { + onStart.onStart() + job = lifecycleOwner.lifecycleScope.launch { + val result = withContext(Dispatchers.Default) { stepWork.step() } + onResult.onResult(result) } } + + fun cancel() { + job?.cancel() + job = null + } } \ No newline at end of file diff --git a/app/src/org/commcare/activities/FormEntryActivityUIController.java b/app/src/org/commcare/activities/FormEntryActivityUIController.java index 1fb33a1720..4958f8e4d8 100644 --- a/app/src/org/commcare/activities/FormEntryActivityUIController.java +++ b/app/src/org/commcare/activities/FormEntryActivityUIController.java @@ -81,6 +81,7 @@ public class FormEntryActivityUIController implements CommCareActivityUIControll private BlockingActionsManager blockingActionsManager; private boolean formRelevanciesUpdateInProgress = false; + private boolean navigationInFlight = false; private static final String KEY_LAST_CHANGED_WIDGET = "index-of-last-changed-widget"; private TextView finishText; @@ -152,14 +153,7 @@ public void setupUI() { Localization.get("form.entry.loading.next.question")); asyncFormNavigator = new AsyncFormNavigator( activity, - this::stepToRenderableEvent, - visible -> { - if (visible) { - formLoadingOverlay.show(); - } else { - formLoadingOverlay.hide(); - } - }); + this::stepToRenderableEvent); activity.requestMajorLayoutUpdates(); @@ -437,7 +431,15 @@ private void showNextView(boolean resuming) { return; } - asyncFormNavigator.navigate(result -> renderNavResult(result, resuming)); + navigationInFlight = true; + asyncFormNavigator.navigate( + resuming, + formLoadingOverlay::show, + result -> { + navigationInFlight = false; + formLoadingOverlay.hide(); + renderNavResult(result, resuming); + }); } /** @@ -625,8 +627,7 @@ protected void next() { } protected boolean shouldIgnoreNavigationAction() { - return blockingActionsManager.isBlocked() - || (asyncFormNavigator != null && asyncFormNavigator.isNavigationInFlight()); + return blockingActionsManager.isBlocked() || navigationInFlight; } protected boolean shouldIgnoreSwipeAction() { From 90530eb65eb9500ff8ce38734cf73fdc6b721303 Mon Sep 17 00:00:00 2001 From: Ahmad Vazirna Date: Wed, 22 Apr 2026 12:39:07 +0200 Subject: [PATCH 7/8] Drop superseded AsyncFormNavigator results via a monotonic nav-id Each navigate() captures an incrementing id and re-checks it after the background step returns; results whose id has been superseded (by a newer navigate() or a cancel()) are silently dropped. cancel() bumps the id too so a pending continuation can't race past Job.cancel(). Co-Authored-By: Claude Opus 4.7 --- app/src/org/commcare/activities/AsyncFormNavigator.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/org/commcare/activities/AsyncFormNavigator.kt b/app/src/org/commcare/activities/AsyncFormNavigator.kt index 7b9b37ad6b..c145b50252 100644 --- a/app/src/org/commcare/activities/AsyncFormNavigator.kt +++ b/app/src/org/commcare/activities/AsyncFormNavigator.kt @@ -29,16 +29,22 @@ class AsyncFormNavigator( } private var job: Job? = null + private var currentNavId: Long = 0 fun navigate(resuming: Boolean, onStart: StartCallback, onResult: ResultCallback) { + val navId = ++currentNavId onStart.onStart() job = lifecycleOwner.lifecycleScope.launch { val result = withContext(Dispatchers.Default) { stepWork.step() } + if (navId != currentNavId) { + return@launch + } onResult.onResult(result) } } fun cancel() { + currentNavId++ job?.cancel() job = null } From 21f76cc295d831c5a4d782ad3d31817d3269705d Mon Sep 17 00:00:00 2001 From: Ahmad Vazirna Date: Wed, 22 Apr 2026 12:39:25 +0200 Subject: [PATCH 8/8] Cancel in-flight form navigation on activity pause Adds FormEntryActivityUIController.cancelNavigation() which cancels the navigator, clears navigationInFlight, and hides the loading overlay. FormEntryActivity.onPause() invokes it so a background step doesn't keep running (or deliver a late result against stale state) while the activity is paused. Co-Authored-By: Claude Opus 4.7 --- app/src/org/commcare/activities/FormEntryActivity.java | 2 ++ .../activities/FormEntryActivityUIController.java | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/app/src/org/commcare/activities/FormEntryActivity.java b/app/src/org/commcare/activities/FormEntryActivity.java index 25e0aa0d96..bf66491309 100644 --- a/app/src/org/commcare/activities/FormEntryActivity.java +++ b/app/src/org/commcare/activities/FormEntryActivity.java @@ -911,6 +911,8 @@ public void taskCancelled() { protected void onPause() { super.onPause(); + uiController.cancelNavigation(); + if (!isFinishing() && uiController.questionsView != null && currentPromptIsQuestion()) { saveAnswersForCurrentScreen(false); } diff --git a/app/src/org/commcare/activities/FormEntryActivityUIController.java b/app/src/org/commcare/activities/FormEntryActivityUIController.java index 4958f8e4d8..0832f92623 100644 --- a/app/src/org/commcare/activities/FormEntryActivityUIController.java +++ b/app/src/org/commcare/activities/FormEntryActivityUIController.java @@ -630,6 +630,15 @@ protected boolean shouldIgnoreNavigationAction() { return blockingActionsManager.isBlocked() || navigationInFlight; } + protected void cancelNavigation() { + if (!navigationInFlight) { + return; + } + asyncFormNavigator.cancel(); + navigationInFlight = false; + formLoadingOverlay.hide(); + } + protected boolean shouldIgnoreSwipeAction() { return isAnimatingSwipe || isDialogShowing; }