[Bug]: <input type="file"> result is silently dropped when the app process is killed while the chooser activity is in the foreground
Bug
When the Android system kills the app process while a file-chooser activity (camera or gallery) is in the foreground, the result is silently dropped on return. filePathCallback.onReceiveValue() is never called — not even with null — so the <input type="file"> is left permanently pending in a WebView that no longer has the page state anyway.
There is no exception, no log line, and nothing visible to the user: the app simply reappears and the photo never arrives.
Why it happens
In BridgeWebChromeClient (8.5.0), the callback lives only in memory:
// BridgeWebChromeClient.java:52
private ActivityResultListener activityListener;
// :344 — the lambda captures filePathCallback
activityListener = (activityResult) -> {
Uri[] result = null;
if (activityResult.getResultCode() == Activity.RESULT_OK) {
result = new Uri[] { imageFileUri };
}
filePathCallback.onReceiveValue(result); // :349
};Neither activityListener nor filePathCallback is written to the Bundle. After process death the recreated BridgeWebChromeClient has activityListener == null, so the guard at :71 drops the incoming result:
if (activityListener != null) {
activityListener.onActivityResult(result);
}grep for filePathCallback and activityListener in Bridge.java returns 0 (positive control: lastPluginId returns 4).
The machinery already exists — the file chooser just doesn't use it
PluginCall does survive process death. Plugin.startActivityForResult (:173) registers the call for persistence before launching:
bridge.setPluginCallForLastActivity(call); // :179
lastPluginCallId = call.getCallbackId();
bridge.saveCall(call);
activityResultLauncher.launch(intent);and Bridge.saveInstanceState (:1086) persists it, with the comment in the source itself:
// If there was a last PluginCall for a started activity, we need to
// persist it so we can load it again in case our app gets terminatedThe file chooser is not a PluginCall, so none of this applies to it.
Reproduction
- Android device under memory pressure (or
adb shell am send-trim-memory/ background process limit set to a low value). - In a Capacitor app, tap an
<input type="file" accept="image/*" capture="environment">. - While the camera is in the foreground, the system kills the app process.
- Take the photo and return.
Expected: the result reaches the input, or at least onReceiveValue(null) is called so the page can react.
Actual: nothing happens. The app is recreated and the result is discarded.
Field measurement
Reproduced on a Moto G54 5G (Android 15, 3.5 GB RAM, targetSdkVersion 36), 4 of 5 taps across two sessions. The system log shows the pattern clearly:
13:20:32.609 ActivityTaskManager: START u0 {act=android.media.action.IMAGE_CAPTURE ...} from uid <app>
13:20:34.342 ActivityManager: appDiedLocked: app=ProcessRecord{... com.example.app} isKilledByAm=false
13:20:34.342 ActivityManager: Process com.example.app (pid 27962) has died: prev LAST
13:20:42.993 ActivityManager: Start proc 32361:com.example.app
13:20:43.xxx wm_on_activity_result_called: [..., RESUME_ACTIVITY] <- the Activity DOES get the resultThe activity result is delivered to the recreated Activity; it is the bridge that has nothing left to hand it to.
Frequency tracks memory pressure rather than anything in app code: in the window above, 113 processes were killed in 4.5 minutes (including the camera app itself, twice), versus 5.2/min in an earlier window where only 1 of 2 taps died.
Suggested direction
Persist enough to rebuild the chain across process death — at minimum the pending imageFileUri and a flag that a chooser was in flight — using the same saveInstanceState/restoreInstanceState path that PluginCall already uses. Even without restoring the callback, calling onReceiveValue(null) on a restored-but-orphaned chooser would let the web layer detect and report the loss instead of hanging silently.
Environment
@capacitor/android8.5.0,@capacitor/core8.5.0compileSdk/targetSdk36- Android 15, Moto G54 5G
Happy to test a patch.
Source: ionic-team/capacitor