Skip to content

Run form-entry Next navigation off the main thread#3667

Draft
avazirna wants to merge 8 commits intomasterfrom
question-view-init-off-main-thread
Draft

Run form-entry Next navigation off the main thread#3667
avazirna wants to merge 8 commits intomasterfrom
question-view-init-off-main-thread

Conversation

@avazirna
Copy link
Copy Markdown
Contributor

Product Description

When tapping Next in a form, the CommCare client previously froze the
UI while it walked the form model between renderable events — on forms
with heavy XPath evaluation (complex relevance, calculations, lookups)
this produced noticeable stalls and dropped frames. With this change,
that walk runs on a background dispatcher and a "Loading next question…"
overlay appears after 150 ms on slow steps (invisible on fast ones).
Rapid double-taps now advance a single question instead of double-stepping.

Technical Summary

No ticket; produced from the off-main-thread form navigation plan.

  • NavResult (sealed class) models the outcome of a step: Question,
    FieldListGroup, PromptNewRepeat, EndOfForm, Error(XPathException).
  • showNextView split into stepToRenderableEvent() (pure controller
    walk, returns NavResult) and renderNavResult() (view work on Main).
  • AsyncFormNavigator (new Kotlin) runs the stepper via
    withContext(Dispatchers.Default) on lifecycleScope.launch, retains
    the launched Job, and uses a monotonic currentNavId to drop
    results whose navigation was superseded or cancelled.
  • FormLoadingOverlay (new Kotlin) posts a 150 ms-delayed VISIBLE
    so fast navigations don't flash; hide() cancels any pending post.
  • navigationInFlight lives on FormEntryActivityUIController;
    shouldIgnoreNavigationAction() checks it so Next/Previous/swipe
    are dropped during an in-flight step.
  • FormEntryActivity.onPause() calls uiController.cancelNavigation()
    which cancels the navigator, clears the flag, and hides the overlay —
    prevents a late result firing against a paused UI.
  • Locale key form.entry.loading.next.question added for the overlay.

Out of scope / deferred:

  • createView() / showView() still run on Main (view inflation must).
  • Select-choice prefetch, same pattern on showPreviousView /
    refreshCurrentView, and a pendingNavigationAfterRestore flag
    for config-change mid-nav resumption.

Safety Assurance

Safety story

  • The nav-id in AsyncFormNavigator guarantees late/stale results
    are dropped, including results that resume past a Job.cancel()
    due to cooperative cancellation.
  • cancelNavigation() on onPause() prevents a background step from
    delivering a result after the activity is paused.
  • shouldIgnoreNavigationAction() blocks additional Next/Previous
    during an in-flight step, so the main thread is idle between the
    showNextView prologue and the result callback. FormController /
    FormDef are not internally synchronized; correctness relies on
    this invariant — other main-thread code paths that touch the form
    controller (save-answers, etc.) run either before navigate() or
    after the result callback.
  • Existing XPath-error behavior preserved: errors from stepping flow
    through UserfacingErrorHandling via NavResult.Error.
  • Blast radius is limited to the forward-navigation path; back-nav,
    refresh-current-view, and repeat-dialog entry are unchanged.

Automated test coverage

None added in this PR. Follow-up to add Robolectric coverage for:
delayed-overlay visibility, superseded-result drop, cancel-on-pause,
and dropped rapid double-tap. Plus an Espresso test exercising a
relevance-heavy form.

QA Plan

  • Forward-nav through a relevance-heavy form — overlay only on slow
    steps, renders correct next question afterward.
  • Rapid double-tap Next — single advance.
  • Swipe forward — same result as Next button.
  • End-of-form triggers form-complete.
  • EVENT_PROMPT_NEW_REPEAT opens the add-repeat dialog.
  • XPath error during stepping shows the existing error dialog.
  • Background the app mid-nav and return — no stale overlay, no crash.
  • Rotate mid-nav — no crash; form resumes on the pre-nav question.
    (Mid-step resumption is a known limitation — deferred follow-up.)
  • Back / Previous behavior unchanged.

Labels and Review

  • Do we need to enhance the manual QA test coverage? If yes,
    RELEASES.md is updated accordingly
  • Does the PR introduce any major changes worth communicating?
    If yes, RELEASES.md is updated accordingly
  • Risk label is set correctly
  • The set of people pinged as reviewers is appropriate for the
    level of risk of the change

avazirna and others added 8 commits April 22, 2026 10:00
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

📝 Walkthrough

Walkthrough

This pull request introduces asynchronous form navigation in the CommCare form entry flow. A new AsyncFormNavigator class orchestrates background form stepping on a default dispatcher while maintaining lifecycle awareness and preventing stale result delivery via navigation ID tracking. The UI layer is refactored to show a FormLoadingOverlay during navigation, gate further navigation actions via a navigationInFlight flag, and render results through a new renderNavResult() method. A new NavResult sealed class hierarchy represents navigation outcomes. Layout and localization changes support the loading UI display.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant FormEntry as FormEntryActivity
    participant UICtrl as FormEntryActivityUIController
    participant Overlay as FormLoadingOverlay
    participant Navigator as AsyncFormNavigator
    participant Dispatcher as Dispatchers.Default
    participant Controller as FormController

    User->>FormEntry: User swipes/taps next
    FormEntry->>UICtrl: showNextView()
    
    alt shouldIgnoreNavigationAction() check
        UICtrl-->>FormEntry: Early return (navigationInFlight=true)
    else Navigation allowed
        UICtrl->>Navigator: navigate(resuming, onStart, onResult)
        Navigator->>UICtrl: onStart.onStart()
        UICtrl->>Overlay: show()
        Overlay-->>UICtrl: Loading overlay displayed
        
        Navigator->>Dispatcher: Launch coroutine
        Dispatcher->>Controller: step() (background)
        Controller-->>Dispatcher: NavResult
        Dispatcher->>Navigator: Result received
        
        Navigator->>UICtrl: onResult.onResult(navResult)
        UICtrl->>UICtrl: renderNavResult(navResult)
        UICtrl->>Overlay: hide()
        Overlay-->>UICtrl: Loading overlay hidden
        UICtrl->>FormEntry: Render next question/view
        FormEntry-->>User: Updated form display
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: refactoring form-entry Next navigation to run off the main thread.
Description check ✅ Passed The description comprehensively covers all template sections: Product Description, Technical Summary (with deferred items noted), Safety Assurance (including nav-id guarantees, pause cancellation, and action gating), Automated test coverage (none added, follow-ups planned), and QA Plan (detailed scenarios).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch question-view-init-off-main-thread

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/org/commcare/activities/FormEntryActivity.java (1)

911-919: ⚠️ Potential issue | 🔴 Critical

cancelNavigation() cannot truly interrupt the background step before saveAnswersForCurrentScreen runs.

AsyncFormNavigator.cancel() uses Job.cancel(), but stepWork.step() is a blocking Java call (mFormController.stepToNextEvent(...)) with no suspension points, so coroutine cancellation will not interrupt it. The background thread may continue executing FormEntryController mutations while the main thread immediately proceeds into saveAnswersForCurrentScreen(false) at line 917 and subsequent pause handling — both touching the same non-thread-safe mFormController. This can corrupt form state or lose answers on pause-during-navigation.

Consider awaiting the in-flight job (e.g., runBlocking { job?.join() } in cancel(), or an AtomicBoolean cancelled flag plus a synchronous wait) before letting onPause() continue, or gate saveAnswersForCurrentScreen on the navigation no longer being in flight.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/activities/FormEntryActivity.java` around lines 911 -
919, The cancelNavigation() call in onPause() does not wait for
AsyncFormNavigator.cancel() to actually stop the background stepWork.step()
(which calls mFormController.stepToNextEvent(...)), so
saveAnswersForCurrentScreen(false) can run concurrently against mutable
mFormController state; change cancelNavigation()/AsyncFormNavigator.cancel() to
synchronously wait for the in-flight job to complete (for example have cancel()
join the coroutine job or set an AtomicBoolean and block until stepWork
finishes) and/or make onPause() gate calling saveAnswersForCurrentScreen() until
the navigator reports navigationComplete; update AsyncFormNavigator.cancel(),
stepWork.step(), and the code path used by onPause()/cancelNavigation() to use
this join/flag handshake so no FormEntryController mutations happen concurrently
with saveAnswersForCurrentScreen().
🧹 Nitpick comments (1)
app/src/org/commcare/activities/AsyncFormNavigator.kt (1)

14-21: Consider passing lifecycleScope or a dispatcher explicitly for testability.

Hard-coding lifecycleOwner.lifecycleScope + Dispatchers.Default inside the class makes this navigator awkward to unit test (no way to inject a TestDispatcher). A small refactor to accept CoroutineScope and CoroutineDispatcher (with current values as defaults) would ease the follow-up test work called out in the PR description.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/activities/AsyncFormNavigator.kt` around lines 14 - 21,
The AsyncFormNavigator currently hard-codes lifecycleOwner.lifecycleScope and
Dispatchers.Default which hinders testability; refactor the constructor to
accept a CoroutineScope and a CoroutineDispatcher (e.g., add params
coroutineScope: CoroutineScope = lifecycleOwner.lifecycleScope and dispatcher:
CoroutineDispatcher = Dispatchers.Default) and update any internal usages to use
these injected values instead of
lifecycleOwner.lifecycleScope/Dispatchers.Default; keep the existing StepWork
functional interface unchanged so callers can still pass step(), and ensure
tests can pass a TestScope/TestDispatcher by injecting them.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/org/commcare/activities/AsyncFormNavigator.kt`:
- Around line 34-50: The navigate/cancel flow leaves blocking Java work
uncontrolled and races stale results; fix by (1) running the blocking
stepWork.step() on a dedicated single-thread ExecutorService (store its Future
in a field like stepFuture) instead of relying on cooperative cancellation, call
stepFuture.cancel(true) in cancel() to attempt to interrupt the background
thread, and ensure you stop any FormEntryController mutations from that thread;
(2) assign the coroutine Job to job immediately by doing val assignedJob =
lifecycleOwner.lifecycleScope.launch { ... } then job = assignedJob (or
construct a Job and pass it into launch) so cancel() always sees the latest job,
and make cancel perform job?.cancelAndJoin() (use runBlocking if cancel() must
be synchronous) to wait for the coroutine to finish; and (3) add a simple
AtomicBoolean navigating guard in navigate() to serialize invocations (set true
at start, false on completion) to prevent overlap between onStart/onResult
windows. Ensure references to stepWork.step(),
lifecycleOwner.lifecycleScope.launch, job, currentNavId, cancel(), and
stepFuture/Future are updated accordingly.

In `@app/src/org/commcare/activities/FormEntryActivityUIController.java`:
- Around line 449-489: The stepToRenderableEvent method currently only catches
XPathException, so unchecked exceptions from
FormEntryController.stepToNextEvent/indexIsInFieldList/getQuestionPrompts can
crash the coroutine and leave navigationInFlight/overlay stuck; widen the catch
to Throwable (or RuntimeException) in stepToRenderableEvent and return new
NavResult.Error(throwable) so all failures surface as NavResult.Error, or
alternatively wrap the caller's navigate callback to always clear
navigationInFlight and hide the overlay in a finally block—update references in
stepToRenderableEvent, NavResult.Error, and the navigate/navigationInFlight
overlay handling to ensure any thrown Throwable is converted to NavResult.Error
or always clears the in-flight state.

---

Outside diff comments:
In `@app/src/org/commcare/activities/FormEntryActivity.java`:
- Around line 911-919: The cancelNavigation() call in onPause() does not wait
for AsyncFormNavigator.cancel() to actually stop the background stepWork.step()
(which calls mFormController.stepToNextEvent(...)), so
saveAnswersForCurrentScreen(false) can run concurrently against mutable
mFormController state; change cancelNavigation()/AsyncFormNavigator.cancel() to
synchronously wait for the in-flight job to complete (for example have cancel()
join the coroutine job or set an AtomicBoolean and block until stepWork
finishes) and/or make onPause() gate calling saveAnswersForCurrentScreen() until
the navigator reports navigationComplete; update AsyncFormNavigator.cancel(),
stepWork.step(), and the code path used by onPause()/cancelNavigation() to use
this join/flag handshake so no FormEntryController mutations happen concurrently
with saveAnswersForCurrentScreen().

---

Nitpick comments:
In `@app/src/org/commcare/activities/AsyncFormNavigator.kt`:
- Around line 14-21: The AsyncFormNavigator currently hard-codes
lifecycleOwner.lifecycleScope and Dispatchers.Default which hinders testability;
refactor the constructor to accept a CoroutineScope and a CoroutineDispatcher
(e.g., add params coroutineScope: CoroutineScope = lifecycleOwner.lifecycleScope
and dispatcher: CoroutineDispatcher = Dispatchers.Default) and update any
internal usages to use these injected values instead of
lifecycleOwner.lifecycleScope/Dispatchers.Default; keep the existing StepWork
functional interface unchanged so callers can still pass step(), and ensure
tests can pass a TestScope/TestDispatcher by injecting them.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9ddc3e7e-9cdb-412e-a4a2-cd589efba9fa

📥 Commits

Reviewing files that changed from the base of the PR and between c88d3a9 and 21f76cc.

📒 Files selected for processing (7)
  • app/assets/locales/android_translatable_strings.txt
  • app/res/layout/screen_form_entry.xml
  • app/src/org/commcare/activities/AsyncFormNavigator.kt
  • app/src/org/commcare/activities/FormEntryActivity.java
  • app/src/org/commcare/activities/FormEntryActivityUIController.java
  • app/src/org/commcare/activities/FormLoadingOverlay.kt
  • app/src/org/commcare/activities/NavResult.kt

Comment on lines +34 to +50
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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the AsyncFormNavigator.kt file
fd -t f AsyncFormNavigator.kt

Repository: dimagi/commcare-android

Length of output: 120


🏁 Script executed:

# Look at the file structure and content around lines 34-50
cat -n app/src/org/commcare/activities/AsyncFormNavigator.kt | head -60

Repository: dimagi/commcare-android

Length of output: 1723


🏁 Script executed:

# Check the entire class to understand the context better
wc -l app/src/org/commcare/activities/AsyncFormNavigator.kt

Repository: dimagi/commcare-android

Length of output: 123


🏁 Script executed:

# Find usages of AsyncFormNavigator
rg -t kotlin "AsyncFormNavigator" --max-count 20

Repository: dimagi/commcare-android

Length of output: 146


🏁 Script executed:

# Search for StepWork implementations to understand what step() does
rg -t kotlin "StepWork" -A 3 --max-count 20

Repository: dimagi/commcare-android

Length of output: 550


🏁 Script executed:

# Look for FormEntryActivity and how cancelNavigation is called
fd -t f FormEntryActivity.kt

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Search for cancelNavigation usage
rg -t kotlin "cancelNavigation" -B 2 -A 2

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Search for FormEntry related files
fd -t f "FormEntry" --iglob "*FormEntry*"

Repository: dimagi/commcare-android

Length of output: 269


🏁 Script executed:

# Check git log for AsyncFormNavigator to see when it was added
git log --oneline -- app/src/org/commcare/activities/AsyncFormNavigator.kt | head -5

Repository: dimagi/commcare-android

Length of output: 127


🏁 Script executed:

# Check git diff to see if this is recent
git log -1 --format="%ai" -- app/src/org/commcare/activities/AsyncFormNavigator.kt

Repository: dimagi/commcare-android

Length of output: 92


🏁 Script executed:

# Get the full diff for AsyncFormNavigator to see the context
git diff HEAD~1 -- app/src/org/commcare/activities/AsyncFormNavigator.kt

Repository: dimagi/commcare-android

Length of output: 97


🏁 Script executed:

# Check the commit details
git show --stat 21f76cc29

Repository: dimagi/commcare-android

Length of output: 50380


🏁 Script executed:

# Search for all recent files that might use AsyncFormNavigator
git diff HEAD~1 --name-only | head -20

Repository: dimagi/commcare-android

Length of output: 253


🏁 Script executed:

# Search for FormEntryActivityUIController
fd -t f "FormEntryActivityUIController.kt"

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Search for where cancel() might be called
rg -t kotlin "\.cancel\(\)" -B 2 -A 2 | head -50

Repository: dimagi/commcare-android

Length of output: 2905


🏁 Script executed:

# Search for navigationInFlight mentioned in commit message
rg -t kotlin "navigationInFlight" -B 2 -A 2

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check if lifecycleScope.launch is synchronous or asynchronous
rg -t kotlin "lifecycleScope" -B 2 -A 5 | head -80

Repository: dimagi/commcare-android

Length of output: 2778


🏁 Script executed:

# Look for how cancel() is intended to be called based on the comment
rg -t kotlin "onPause\|cancelNavigation" -B 3 -A 3

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check what withContext does with blocking work
rg -t kotlin "withContext\(Dispatchers" -B 2 -A 5 | head -60

Repository: dimagi/commcare-android

Length of output: 5293


🏁 Script executed:

# Look for kotlin coroutine documentation references or patterns
rg -t kotlin "cancelAndJoin\|runBlocking" -B 2 -A 2

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check if there are any InternalCoroutinesApi violations in this file
rg -t kotlin "InternalCoroutinesApi|tryResume" app/src/org/commcare/activities/AsyncFormNavigator.kt

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Verify the actual behavior of lifecycleScope.launch - is it synchronous or async?
# Check the common patterns in the codebase for launching and cancelling
rg -t kotlin "lifecycleScope.launch" -A 8 | head -100

Repository: dimagi/commcare-android

Length of output: 1545


🏁 Script executed:

# Look for any existing serialization guards or job handling patterns
rg -t kotlin "AtomicBoolean|atomic|synchronized" app/src/org/commcare/activities/ -B 2 -A 2

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check what FormEntryController is and if it's thread-safe
fd -t f "FormEntryController.kt" -o "FormEntryController.java"

Repository: dimagi/commcare-android

Length of output: 234


🏁 Script executed:

# Look for any synchronization patterns around FormEntryController mutations
rg -t kotlin -t java "FormEntryController" -B 2 -A 5 | head -80

Repository: dimagi/commcare-android

Length of output: 6475


🏁 Script executed:

# Verify kotlinx.coroutines version to understand cancellation semantics
rg -t gradle "kotlinx-coroutines" app/build.gradle

Repository: dimagi/commcare-android

Length of output: 143


🏁 Script executed:

# Check Kotlin coroutine documentation on lifecycleScope.launch behavior
rg -t kotlin "suspend\|launch" app/src/org/commcare/activities/AsyncFormNavigator.kt -B 2 -A 5

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Verify if there's a real window where two navigate() calls could both start executing
# by checking if there are any barriers between navigate() calls
rg -t kotlin "navigate\(" -B 3 -A 8 --max-count 10

Repository: dimagi/commcare-android

Length of output: 1190


cancel() is not effective for blocking stepWork.step() and leaves a window for stale results.

Two related issues:

  1. stepWork.step() runs via withContext(Dispatchers.Default) but is pure blocking Java with no cooperative cancellation checks. job?.cancel() will only prevent onResult from firing (already guarded by navId), it will not stop the FormEntryController mutations on the background thread. Callers that assume a synchronous post-cancel state (e.g., FormEntryActivity.onPause()cancelNavigation()saveAnswersForCurrentScreen) will race with the still-running step on the shared, non-thread-safe controller.

  2. lifecycleScope.launch dispatches asynchronously even for the main body; between onStart.onStart() and entering the coroutine, another navigate() can be invoked, potentially overlapping work. Consider whether invocations must be serialized.

At minimum, consider job?.cancelAndJoin() from a runBlocking/suspend context at cancel time, or add an AtomicBoolean "navigating" guard and block new navigate calls until the previous step returns. Also consider capturing job before launch returns in a way that guarantees cancel() observes the latest job.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/activities/AsyncFormNavigator.kt` around lines 34 - 50,
The navigate/cancel flow leaves blocking Java work uncontrolled and races stale
results; fix by (1) running the blocking stepWork.step() on a dedicated
single-thread ExecutorService (store its Future in a field like stepFuture)
instead of relying on cooperative cancellation, call stepFuture.cancel(true) in
cancel() to attempt to interrupt the background thread, and ensure you stop any
FormEntryController mutations from that thread; (2) assign the coroutine Job to
job immediately by doing val assignedJob = lifecycleOwner.lifecycleScope.launch
{ ... } then job = assignedJob (or construct a Job and pass it into launch) so
cancel() always sees the latest job, and make cancel perform
job?.cancelAndJoin() (use runBlocking if cancel() must be synchronous) to wait
for the coroutine to finish; and (3) add a simple AtomicBoolean navigating guard
in navigate() to serialize invocations (set true at start, false on completion)
to prevent overlap between onStart/onResult windows. Ensure references to
stepWork.step(), lifecycleOwner.lifecycleScope.launch, job, currentNavId,
cancel(), and stepFuture/Future are updated accordingly.

Comment on lines +449 to +489
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);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only XPathException is caught — other runtime exceptions from stepping will crash the coroutine.

FormEntryController.stepToNextEvent, indexIsInFieldList, and getQuestionPrompts can throw other unchecked exceptions (e.g., XPathTypeMismatchException extends XPathException so OK, but generic RuntimeException from evaluator or IllegalStateException do not). On the old synchronous path those propagated up to callers that wrapped the flow with UserfacingErrorHandling. Here, an uncaught exception inside the Dispatchers.Default coroutine will be swallowed/logged by the coroutine exception handler and the overlay will never be hidden, leaving the UI stuck with navigationInFlight=true and the overlay visible after 150 ms.

Consider widening the catch to Throwable (or at least RuntimeException) and surfacing via NavResult.Error, or wrapping the navigate callback to guarantee navigationInFlight/overlay are cleared on any failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/activities/FormEntryActivityUIController.java` around
lines 449 - 489, The stepToRenderableEvent method currently only catches
XPathException, so unchecked exceptions from
FormEntryController.stepToNextEvent/indexIsInFieldList/getQuestionPrompts can
crash the coroutine and leave navigationInFlight/overlay stuck; widen the catch
to Throwable (or RuntimeException) in stepToRenderableEvent and return new
NavResult.Error(throwable) so all failures surface as NavResult.Error, or
alternatively wrap the caller's navigate callback to always clear
navigationInFlight and hide the overlay in a finally block—update references in
stepToRenderableEvent, NavResult.Error, and the navigate/navigationInFlight
overlay handling to ensure any thrown Throwable is converted to NavResult.Error
or always clears the in-flight state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant