Skip to content

fix(android): two lifecycle crashes when the app is backgrounded or rotated with the preview running - #423

Open
abumalick wants to merge 1 commit into
capacitor-community:masterfrom
abumalick:fix/android-lifecycle-crashes-on-stop-and-release
Open

fix(android): two lifecycle crashes when the app is backgrounded or rotated with the preview running#423
abumalick wants to merge 1 commit into
capacitor-community:masterfrom
abumalick:fix/android-lifecycle-crashes-on-stop-and-release

Conversation

@abumalick

Copy link
Copy Markdown

Two independent Android crashes that both fire when the app is backgrounded (or rotated) while the preview is running. Both are reproduced on a device below, with the fix verified against the same steps.

Found while chasing the top crash in a Capacitor app that keeps a CameraPreview on a photo-capture screen: 211 crashes across 34 users on the first one, on every release from 1.0.0 onward.

1. 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. Measured on the device below, a build doing that crashed here on the first attempt (2/2), while the same build without the listener did not reproduce it at all 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.

2. RuntimeException: Camera is being used after Camera.release()

CameraActivity.onPause() releases the camera and calls mPreview.setCamera(null, -1) to announce it — but setCamera() is wrapped in if (camera != null), so the null is ignored and Preview.mCamera keeps pointing at the released Camera. A later configuration change reaches it:

FATAL EXCEPTION: main
java.lang.RuntimeException: Camera is being used after Camera.release() was called
    at android.hardware.Camera.setDisplayOrientation(Native Method)
    at com.ahm.capacitor.camera.preview.Preview.setCameraDisplayOrientation(Preview.java:145)
    at com.ahm.capacitor.camera.preview.CameraActivity.onConfigurationChanged(CameraActivity.java:423)
    at android.app.Fragment.performConfigurationChanged(Fragment.java:2601)

Note an mCamera == null guard at the call site would not have helped: the reference is non-null and dangling.

This overlaps #315, which diagnosed the same cause — credit there, and I'm happy to close this half in its favour. But #315 has been open since 2023, and on its own it is not sufficient: setCameraDisplayOrientation() dereferences mCamera unconditionally and is reachable from onConfigurationChanged() while the activity is paused, so clearing the field just changes the exception type. Built with only #315 applied, the identical steps still crash on the same line:

FATAL EXCEPTION: main
java.lang.NullPointerException: Attempt to invoke virtual method
  'void android.hardware.Camera.setDisplayOrientation(int)' on a null object reference
    at com.ahm.capacitor.camera.preview.Preview.setCameraDisplayOrientation(Preview.java:146)
    at com.ahm.capacitor.camera.preview.CameraActivity.onConfigurationChanged(CameraActivity.java:423)

So both the assignment and the guard are needed. This PR keeps the existing if (camera != null) shape and adds an else branch rather than hoisting the assignment, but #315's form works equally well — happy to rebase onto it if you would rather merge that first.

Reproduction

Device: Redmi Note 8, Android 13. Plugin 8.0.1, start({ toBack: true, position: 'rear' }), preview mounted on a full-screen capture view.

  1. Open the screen that starts the preview
  2. Background the app (HOME)
  3. Rotate the device to landscape while backgrounded
  4. Return to the foreground
  5. Leave the capture screen (unmounts the preview → CameraPreview.stop())
Build Result
8.0.1 as published crashCamera is being used after Camera.release() (§2)
8.0.1 + an app-side appStateChange stop crashCan not perform this action after onSaveInstanceState (§1), 2/2
8.0.1 + #315 only crashNullPointerException on the same line as §2
8.0.1 + this PR no crash, 2/2 — clean startstopstartstop lifecycle

Notes

  • Android only; no iOS or JS/TS changes, no public API change.
  • App crashes when resumed from background with enableZoom: true #421 (crash on resume from background) may share a root cause here, but it has no stack trace attached so I can't say.
  • I have no way to test this against every OEM; the state-loss window in particular is timing-dependent, and it did not reproduce on the stock build on this one device even though it is the most frequent crash in the field.

Two independent crashes, both reached when the app is backgrounded (or
rotated) while the preview is up.

1. IllegalStateException: Can not perform this action after
   onSaveInstanceState

   stop() posts its whole body to the UI thread, so the fragment
   transaction can commit after the activity has saved its state. Because
   it throws on the plugin's own UI-thread runnable, no try/catch around
   the JS call can catch it — 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.

2. RuntimeException: Camera is being used after Camera.release()

   CameraActivity.onPause() releases the camera and calls
   mPreview.setCamera(null, -1) to announce it, but setCamera() is wrapped
   in `if (camera != null)`, so the null is ignored and Preview.mCamera
   keeps pointing at the released Camera. A later configuration change
   reaches setCameraDisplayOrientation() and calls setDisplayOrientation()
   on it.

   Clearing mCamera is necessary but not sufficient:
   setCameraDisplayOrientation() dereferences mCamera unconditionally and
   is reachable from CameraActivity.onConfigurationChanged() while the
   activity is paused, so clearing the field alone turns the
   RuntimeException into a NullPointerException on the same line. Both the
   assignment and the guard are needed.
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