ionic-team / ionic-team/capacitor

[Bug]: <input type="file"> result is silently dropped when the app process is killed while the chooser activity is in the foreground

Open
#8,592 0 comments 0 reactions 0 assignees View on GitHub
triage
Dominant language
TypeScript
Stars
16.7k
Forks
1.3k
Avg merge
3d 15h
Merged PRs (30d)
10

Description

### 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 `` 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:

```java
// 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:

```java
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:

```java
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:

```java
// 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 terminated
```

The file chooser is not a `PluginCall`, so none of this applies to it.

### Reproduction

1. Android device under memory pressure (or `adb shell am send-trim-memory` / background process limit set to a low value).
2. In a Capacitor app, tap an ``.
3. While the camera is in the foreground, the system kills the app process.
4. 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
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 result
```

The 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/android` 8.5.0, `@capacitor/core` 8.5.0
- `compileSdk` / `targetSdk` 36
- Android 15, Moto G54 5G

Happy to test a patch.

Contributor guide

Open the contributing guide

Research direction

Start in BridgeWebChromeClient around the activityListener guard and file chooser callback, then trace Bridge.saveInstanceState and restoreInstanceState alongside Plugin.startActivityForResult. Reproduce process death with the documented Android steps and verify that a recreated chooser no longer silently drops its result, including the orphaned-callback behavior described in the issue.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, java
Domain
mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.