Skip to content

fix(android): don't crash when stop() lands after the activity saved its state - #423

Merged
ryaa merged 1 commit into
capacitor-community:masterfrom
abumalick:fix/android-lifecycle-crashes-on-stop-and-release
Sep 25, 2026
Merged

ryaa merged 1 commit into
capacitor-community:masterfrom
abumalick:fix/android-lifecycle-crashes-on-stop-and-release

Conversation

@abumalick

@abumalick abumalick commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes an Android crash that fires when CameraPreview.stop() runs while the app is being backgrounded.

Found while chasing the top crash in a Capacitor app that keeps a CameraPreview on a photo-capture screen.

This PR originally also fixed Camera is being used after Camera.release() in Preview.java. #425 rewrote the preview session lifecycle and fixes that crash (and supersedes #315), so those changes were dropped when rebasing onto master. What remains is the one-line stop() fix.

IllegalStateException: Can not perform this action after onSaveInstanceState

stop() posts its entire body to the UI thread, so the fragment transaction can commit after the activity has saved its state:

FATAL EXCEPTION: main
java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState
    at android.app.FragmentManagerImpl.checkStateLoss(FragmentManager.java:1882)
    at android.app.FragmentManagerImpl.enqueueAction(FragmentManager.java:1905)
    at android.app.BackStackRecord.commitInternal(BackStackRecord.java:688)
    at android.app.BackStackRecord.commit(BackStackRecord.java:646)
    at com.ahm.capacitor.camera.preview.CameraPreview$1.run(CameraPreview.java:135)
    at android.os.Handler.handleCallback(Handler.java:942)
    at android.os.Looper.loop(Looper.java:288)
    at android.app.ActivityThread.main(ActivityThread.java:7959)

Because it is thrown on the plugin's own posted runnable, no try/catch around the JS CameraPreview.stop() call can catch it — the app dies.

The trap this creates is worth spelling out, because it is the natural thing to try: the obvious app-side workaround is to listen for appStateChange and call stop() when the app is backgrounded, so the preview never survives into the background. That makes this crash more likely, not less — it moves stop() to exactly the moment the activity is saving its state. On the device below, a build doing that crashed on the first attempt (2/2), while the same build without the listener did not reproduce it in 4 attempts.

The transaction only removes the preview fragment, so there is no state worth preserving across a process death. commitAllowingStateLoss() is the appropriate commit.

Testing

Redmi Note 8, Android 16, example app built from this branch (rebased on master at 971ab69):

Scenario Result
start → rotate to landscape and back → capture → stop 2/2 clean, no error lines from the plugin
start → landscape → HOME (the paused activity gets the configuration change) → return → stop 2/2 no crash

./gradlew testDebugUnitTest: 76 tests, 0 failures.

Notes

  • Android only; no iOS or JS/TS changes, no public API change.
  • startCamera() has the same commit() pattern. It needs start() to race with backgrounding, which is much rarer, so it is deliberately left out of this PR.

@abumalick

Copy link
Copy Markdown
Contributor Author

@ryaa Do you have time to have a quick look on this?

@ryaa

ryaa commented Sep 13, 2026

Copy link
Copy Markdown
Member

Hi @abumalick — before reviewing this PR, I had already implemented the fix for #424 and opened #425. After reviewing #423, I realized that #425 overlaps with part of the work here, so I would like to coordinate the two changes before either approach is finalized.

As part of its preview-session lifecycle handling, #425:

  • explicitly detaches Preview from the camera before releasing it;
  • clears the camera reference and preview/readiness state;
  • ignores display-orientation updates when no camera is attached.

I tested the original background/rotate/return scenario from this PR on the #425 branch and could no longer reproduce the released-camera crash. This is consistent with #425 superseding #315 and the Preview.java portion of #423.

However, #425 does not address the separate FragmentTransaction.commit() state-loss crash in CameraPreview.stop().

To verify that path independently, I temporarily delayed only the existing fragmentTransaction.commit() call by five seconds and then performed:

  1. start the preview and wait for it to become ready;
  2. tap Stop;
  3. immediately background the app;
  4. wait for the delayed commit.

This reliably reproduced the exact process-fatal exception on a Samsung SM-S731N:

FATAL EXCEPTION: main
java.lang.IllegalStateException:
Can not perform this action after onSaveInstanceState

at android.app.FragmentManagerImpl.checkStateLoss(...)
at android.app.BackStackRecord.commit(...)
at com.ahm.capacitor.camera.preview.CameraPreview...

The process was terminated, confirming that the commitAllowingStateLoss() change remains necessary. The reproduction-only delay was removed afterward and is not part of #425.

Would you be able to review #425, especially its pause/detach lifecycle handling?

Once #425 has been reviewed and merged, I think either of these approaches would work:

  1. rebase fix(android): don't crash when stop() lands after the activity saved its state #423 onto the updated master, drop the now-redundant Preview.java changes, and retain only the focused commitAllowingStateLoss() fix; or
  2. create a new branch from the updated master and open a new focused PR containing only the outstanding CameraPreview.stop() fix, then close fix(android): don't crash when stop() lands after the activity saved its state #423 as partially superseded while linking the replacement PR back here.

The first option preserves this PR’s existing discussion and history. The second may provide a cleaner standalone diff. I am happy to retest the final version on the same physical device whichever approach you prefer.

@abumalick

Copy link
Copy Markdown
Contributor Author

Thanks @ryaa for the thorough check, and for confirming the state-loss path independently.

Agreed that #425 supersedes the Preview.java half here (and #315). To be clear on my side: I don't mind how this lands. If it's simpler to fold commitAllowingStateLoss() into #425 or push it separately, feel free — no need to credit this PR, and I'll close it. What I care about is the crash being gone. Otherwise option 1 works for me: once #425 is merged I'll rebase onto master, drop the Preview.java changes and keep only the stop() fix.

#425 on the device from the original report

Redmi Note 8, now on Android 16 (it has been reflashed since the PR description). Example app built from each branch, driven over adb, rear camera, toBack: true.

Steps: start the preview in portrait → rotate to landscape → HOME → return → Stop. The launcher on this ROM is portrait-only, so pressing HOME delivers a landscape → portrait configuration change to the paused CameraActivity — the same onConfigurationChanged()-after-onPause() path as the original steps. Each run checked the orientation at every step and that the camera actually opened.

Build Result
v8.0.1 crash 2/2 — Camera is being used after Camera.release() at Preview.setCameraDisplayOrientation ← CameraActivity.onConfigurationChanged
#423 no crash, 2/2
#425 no crash, 2/2
#425 + commitAllowingStateLoss() in stop() (what option 1 would ship) no crash, 2/2

Also on #425: start → rotate to landscape and back with the preview live → capture → stop. The preview restarted on each resize, the capture after the rotations resolved, and stop was clean.

Pause/detach review

I read CameraActivity.onPause(), switchCamera(), onConfigurationChanged() and Preview.setCamera() / detachCamera():

  • onPause() settles the active capture, then detaches Preview (camera reference, preview size, readiness, preview callback) before stopPreview() / release(). switchCamera() follows the same detach-before-release order.
  • setCamera(null) now routes through detachCamera(), so the dangling reference from always assign new value to camera property #315/fix(android): don't crash when stop() lands after the activity saved its state #423 can no longer exist.
  • What onConfigurationChanged() can reach while paused — setCameraDisplayOrientation() and setCameraPreviewSize() — both return early without a camera.
  • takeSnapshot() checks both the camera and isPreviewReady(), so a JS call arriving while paused rejects with the not-ready error instead of reaching a released camera.

I didn't find a gap in that path. One minor thing the device logs showed: every normal stop() of a ready preview logs an error.

D Preview: output target lost; PreviewSessionCoordinator{session=1, state=WAITING_FOR_OUTPUT, camera=true, output=false, ready=true, ...}
D Preview: camera detached from preview; PreviewSessionCoordinator{session=1, state=IDLE, ...}
E CameraActivity: camera preview session 1 failed: camera preview was paused before the first frame arrived

stop() removes the container view before the fragment transaction runs, so the surface is destroyed first and onOutputLost() moves the session out of READY. onPause() then sees wasReady == false and calls failStartup(). Nothing is waiting on the session, so nothing is rejected — it is only a misleading error line, but it could send someone debugging a real startup failure the wrong way. Gating that failStartup() on a pending start/flip rather than on readiness would avoid it.

FYI, separate from this PR

startCamera() has the same shape as stop(): the fragment add is committed inside a runnable posted to the UI thread. Using your delay technique on start()'s commit (5 s), then Start → HOME after 1 s, on the same device:

start() commit Result
commit() crash 2/2 — IllegalStateException: Can not perform this action after onSaveInstanceState, thrown from the start() runnable
commitAllowingStateLoss() no crash, 2/2 — the camera opens when the app returns, stop is clean

This needs start() to race with backgrounding, which is much rarer than the stop() case, so I'd keep it out of #423. Happy to open a separate issue or a small PR if you think it's worth it.

@ryaa

ryaa commented Sep 17, 2026

Copy link
Copy Markdown
Member

Thanks again for the thorough review, @abumalick. I’ve now addressed the normal-stop logging issue you identified in #425.

CameraActivity.onPause() now reports a startup failure only when the current preview session still owns a pending start() or flip(), instead of relying on preview readiness. This prevents a normally stopped, already-ready session from being incorrectly marked as failed, while preserving rejection of genuinely pending operations. I also added regression coverage for normal stop, session-specific routing, and exactly-once settlement.

I verified the updated implementation on the same physical device with a normal start → first frame → stop sequence. The camera closed cleanly, and the misleading startup-failure messages were no longer emitted. The automated build, lint, and Android test suite also pass.

The separate commitAllowingStateLoss() work remains intentionally outside #425. Could you please review the latest #425 changes and confirm whether the PR is ready to merge into master? Once it is merged, you can pick up the remaining focused stop() fix from the updated master, as agreed.

@abumalick

Copy link
Copy Markdown
Contributor Author

Thanks @ryaa, the bd45040 change looks right to me.

  • The onPause() check now asks the router whether the paused session still owns a pending start() or flip(). onResume() always registers awaitStart(session) before opening the camera, so a real pause before the first frame still reaches failStartup() and rejects. A session whose start already resolved has nothing pending, so a normal stop() no longer marks it failed.
  • The old !wasReady branch had one other effect: it moved the coordinator to FAILED. Skipping that for a settled session loses nothing, because detachCamera() has already idled it and the next onResume() calls beginSession(), which resets the state.
  • NO_SESSION is 0L, the same value as the old fallback, and beginSession() starts counting at 1, so the explicit NO_SESSION guard in hasPendingOperationForSession() covers the only collision.
  • An operation registered to a different session is still settled by the settleStalePendingOperations() sweep. It cannot get the current session marked failed.

I ran ./gradlew testDebugUnitTest on bd45040 locally: 76 tests, 0 failures (25 in PreviewOperationRouterTest, including the 9 new ones).

On the same Redmi Note 8 (Android 16), with the example app built from bd45040:

Scenario Result
start → rotate to landscape and back → capture → stop 2/2 clean: the capture resolved, and no error lines from the plugin (the session 1 failed … paused before the first frame arrived line is gone)
start → landscape → HOME (the paused activity gets the configuration change) → return → stop 2/2 no crash, no false startup failure logged

From my side, #425 is ready to merge. Once it's in master I'll rebase #423, drop the Preview.java changes, keep only the stop() fix, and re-test it on the device.

@ryaa

ryaa commented Sep 24, 2026

Copy link
Copy Markdown
Member

Thanks, @abumalick, for the thorough review and additional device testing. I’ve now merged #425 into master and closed #424.

You can proceed with rebasing #423 onto the updated master, dropping the superseded Preview.java changes, and retaining the focused stop() fix as planned. Please let me know when it’s ready; I’ll review it and retest on the same Samsung device.

Once your changes are completed, reviewed, and merged, we can plan to release v8.0.2 with all these fixes.

Thanks again for helping get these Android lifecycle fixes landed!

…its state

stop() posts its whole body to the UI thread, so the fragment transaction
can commit after the host activity has saved its state. commit() then
throws "IllegalStateException: Can not perform this action after
onSaveInstanceState" from the plugin's own UI-thread runnable, where no
try/catch around the JS call can catch it, and the app dies.

The transaction only removes the preview fragment, so there is nothing
worth preserving across a process death; commitAllowingStateLoss() is the
correct commit here.
@abumalick
abumalick force-pushed the fix/android-lifecycle-crashes-on-stop-and-release branch from 6f2e573 to 6d14887 Compare September 24, 2026 07:07
@abumalick abumalick changed the title fix(android): two lifecycle crashes when the app is backgrounded or rotated with the preview running fix(android): don't crash when stop() lands after the activity saved its state Sep 24, 2026
@abumalick

Copy link
Copy Markdown
Contributor Author

Rebased onto master (971ab69). The Preview.java changes are dropped and the branch is now the single stop() hunk: commit() → commitAllowingStateLoss(). I updated the title and description to match.

Re-tested on the Redmi Note 8 (Android 16) with the example app built from the rebased branch:

  • start → rotate to landscape and back → capture → stop: 2/2 clean, no error lines from the plugin
  • start → landscape → HOME → return → stop: 2/2 no crash
  • ./gradlew testDebugUnitTest: 76/76 pass

Ready for your review, @ryaa.

@ryaa ryaa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PR Review Summary — 2026-09-25

Reviewed PR #423 at 6d14887, following the merge of #425. The remaining change is focused on preventing an Android crash when stop() removes the preview fragment after the activity has saved its state. Using commitAllowingStateLoss() for this removal addresses that path without duplicating the preview lifecycle fixes already merged in #425.

Validation

  • Tested on a physical Samsung SM-S731N: start → live preview → photo → stop; rotation followed by capture and stop; background/return followed by stop; and another start/capture/stop cycle. All passed without a crash.
  • To reproduce the timing issue reliably, temporarily delayed the fragment-removal commit in stop() by five seconds on the main thread, then backgrounded the app before the delayed commit ran. Logging checked whether the activity's fragment state had been saved and whether the removal transaction actually executed.
  • With the original commit() call, this test reproduced the process-fatal IllegalStateException: Can not perform this action after onSaveInstanceState.
  • Repeating the same test with this PR’s commitAllowingStateLoss() change confirmed that the preview fragment was successfully removed after the activity had saved its state, without a crash. This verifies that the fix handles the reproduced failure condition.
  • Removed all temporary diagnostic code and retested the unmodified PR successfully.
  • Reported automated validation: 76 passing unit tests and a successful Android clean build/test run. These checks supplement the device testing; they do not directly exercise the FragmentManager timing race.

No blocking findings remain for this change. The separate startCamera() saved-state path remains outside this PR's scope.

We can then proceed with planning v8.0.2 to include this fix alongside #425.

@ryaa
ryaa merged commit 9ad5162 into capacitor-community:master Sep 25, 2026
2 checks passed
@ryaa ryaa added this to the v8.0.2 milestone Sep 25, 2026
ryaa added a commit that referenced this pull request Sep 25, 2026
Changes:
- Bump plugin and example-app versions to 8.0.2
- Update Android and iOS example-app version metadata
- Add changelog entries for Android fixes in #423 and #425
- Align the CocoaPods source tag with the v-prefixed release tag
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.

2 participants