bug(browser): Browser.close() on Android re-opens the last URL after a deep link back into the app
評価
調査の方向性
Start in BrowserControllerActivity.java, at onCreate, onDestroy and onNewIntent, and in BrowserPlugin.java, at open() and close(), where the static listener is set and read. Reproduce with the linked sample repo and redirect-server.mjs, then run the stress steps in the table. Done means close() opens nothing and each Browser.open() launches its URL once, with no stuck controller.
索引モデルが issue の本文から書いたものです。
説明
Bug Report
Plugin(s)
@capacitor/browser8.0.5 (latest).mainis identical inBrowserPlugin.javaandBrowserControllerActivity.java.- Also reproduced on 8.0.4.
Capacitor Version
💊 Capacitor Doctor 💊
Latest Dependencies:
@capacitor/cli: 8.5.3
@capacitor/core: 8.5.3
@capacitor/android: 8.5.3
@capacitor/ios: 8.5.3
Installed Dependencies:
@capacitor/ios: not installed
@capacitor/cli: 8.5.3
@capacitor/android: 8.5.3
@capacitor/core: 8.5.3
[success] Android looking great! 👌
Platform(s)
Android. iOS is not affected.
Current Behavior
The page opened with Browser.open() redirects back into the app through a custom-scheme deep link,
and the app's appUrlOpen handler calls Browser.close(). This is the usual OAuth sign-in or sign-out
pattern. Browser.close() then opens the same URL again in a new Custom Tab. That page redirects
again, so the app receives every deep link twice. In our app, every Android sign-out sent the OIDC
end-session request to the server twice.
Here is the sequence, from the plugin source and the wm_* lifecycle events in the logcat below:
open()sets the staticbrowserControllerListenerand startsBrowserControllerActivity
(singleTask). The activity'sonCreatecalls the listener, which launches the Custom Tab.- The deep link starts the app's
MainActivity(singleTask, as in the Capacitor template). Android
clears everything above it in the task, including the Custom Tab and the controller
(wm_finish_activity … clear-task-stack). If the redirect came back while the Custom Tab was still
starting, the controller has not been stopped yet. Android then destroys it later, onceMainActivity
goes idle (finish-imm:idle, ~240 ms later in the log below). - Within that window,
close()seesbrowserControllerActivityInstance != nulland starts
BrowserControllerActivitywith thecloseextra. The old instance is finishing, so Android creates
a new instance (result code=0) instead of callingonNewIntent. - The new instance's
onCreatedoes not check for thecloseextra. It calls
browserControllerListener, which is still set: the old instance'sonDestroyclears it, and that
has not run yet. Soactivity.open(...)runs again, with the URL from the lastopen().
The second appUrlOpen calls close() again, which creates a third controller that launches the URL
a third time. In our runs, Chrome dropped that third launch, so the chain stopped at two deep links.
A worse follow-on: a stuck controller. Sometimes an old controller's onDestroy (which clears the
listener) runs after close() has started a new controller but before that controller's onCreate. The
new controller then finds no listener, launches nothing, and stays on top of the app's task. It is
rarer: 2 in ~180 taps here, at 80–100 ms before close(). On 8.0.5 / API 35 we observed:
- it never finishes on its own (we left it for 6.5 minutes);
- it is translucent, so the app looks normal, but taps no longer reach the app;
- the next
Browser.open()is delivered to it (result code=3) and opens nothing, and theopen()
promise never settles; - only Back finishes it.
In 8.0.5, onResume finishes the controller only below API 34, and onNewIntent only for close. In
our production app on 8.0.4, the stuck controller finished on that next open(), but the open was still
lost: no Custom Tab and no resolved promise.
Expected Behavior
Browser.close()never opens anything. When the Custom Tab is already gone, it does nothing.- Each
Browser.open()opens its URL once.
Code Reproduction
https://github.com/pramoth/capacitor-browser-close-repro — created with npm init @capacitor/app. All the code is in src/js/main.js:
App.addListener('appUrlOpen', async ({ url }) => {
log(`appUrlOpen ${url}`);
await Browser.close();
});
// button
await Browser.open({ url: 'http://localhost:8787/?to=' + encodeURIComponent('capbrowserrepro://callback?open=1') });
The only native change from the template is an intent filter for capbrowserrepro:// on MainActivity.
The repo also contains redirect-server.mjs, a 15-line Node server that answers every request with a
302 to capbrowserrepro://callback and logs each hit.
Steps:
npm install && npm run build && npx cap sync androidnode redirect-server.mjsadb reverse tcp:8787 tcp:8787, thennpx cap run android- Tap 1. Browser.open(redirecting URL) a few times.
Result: the on-screen counter reads Browser.open() calls: 3 appUrlOpen events: 6. Each
appUrlOpen arrives twice with the same open=N, and the redirect server logs two GETs per tap.
Results on the API 35 emulator:
| plugin | delay before close() |
taps | appUrlOpen events |
redirect-server GETs |
|---|---|---|---|---|
| 8.0.5 | 0 ms | 15 | 30 | 30 |
| 8.0.5 | 0 / 20 / 40 / 60 / 80 / 100 ms (stress run) | 87 | 169 | – |
| 8.0.4 | 0 ms | 5 | 10 | 10 |
| 8.0.5 + suggested fix | 0 / 80 / 100 ms (stress run) | 150 | 150 | 150 |
The local redirect server only makes the redirect fast and deterministic. The bug needs the deep link to
arrive before Android has stopped BrowserControllerActivity, which is ~0.5 s after open() on the
emulator. A public redirect over a slow link (e.g. httpbin.org from Thailand: 1.4 s) misses that window,
so the controller is destroyed immediately and close() finds nothing. A real OAuth server on a fast
network, or one that already has a session, comes back within the window.
Other Technical Details
- Android emulator, API 35 (
sdk_gphone64_arm64), Chrome 124.0.6367.219 as the Custom Tabs provider. MainActivityusesandroid:launchMode="singleTask"from the Capacitor template.- Our production app (Angular + Keycloak sign-in/sign-out through
Browser.open) hit this on every
Android sign-out. iOS never did.
Additional Context
Logcat for one tap in the sample (8.0.5, close() called right away). adb logcat -b main,system,events
is filtered to ActivityTaskManager and the wm_* events for BrowserControllerActivity. app is
io.github.repro.browserclose (uid 10208); Chrome is uid 10146.
17:52:54.362 console: Browser.open #1 http://localhost:8787/?to=capbrowserrepro%3A%2F%2Fcallback%3Fopen%3D1
17:52:54.369 ActivityTaskManager: START u0 {flg=0x14000000 cmp=app/BrowserControllerActivity} with LAUNCH_SINGLE_TASK from uid 10208 result code=0
17:52:54.399 ActivityTaskManager: START u0 {dat=http://localhost:8787/... cmp=com.android.chrome/IntentDispatcher} with LAUNCH_MULTIPLE from uid 10208 result code=0
17:52:54.400 wm_on_create_called: [172569187,BrowserControllerActivity,performCreate,11]
17:52:54.469 ActivityTaskManager: START u0 {dat=http://localhost:8787/... cmp=com.android.chrome/CustomTabActivity} with LAUNCH_MULTIPLE from uid 10146 result code=0
--- the redirect comes back: deep link into MainActivity clears the task above it ---
17:52:54.575 wm_finish_activity: [0,172569187,276,app/BrowserControllerActivity,clear-task-stack]
17:52:54.576 ActivityTaskManager: START u0 {dat=capbrowserrepro://callback/... cmp=app/.MainActivity} with LAUNCH_SINGLE_TASK from uid 10146 result code=3
17:52:54.580 console: appUrlOpen #1 capbrowserrepro://callback?open=1
--- Browser.close(): controller 172569187 is finishing, so a NEW controller is created ---
17:52:54.588 ActivityTaskManager: START u0 {cmp=app/BrowserControllerActivity (has extras)} with LAUNCH_SINGLE_TASK from uid 10208 result code=0
17:52:54.636 wm_on_create_called: [154901932,BrowserControllerActivity,performCreate,3]
17:52:54.636 ActivityTaskManager: START u0 {dat=http://localhost:8787/... cmp=com.android.chrome/IntentDispatcher} with LAUNCH_MULTIPLE from uid 10208 result code=0 <-- same URL again, launched by the app
17:52:54.678 wm_finish_activity: [0,154901932,276,app/BrowserControllerActivity,clear-task-stack]
17:52:54.678 ActivityTaskManager: START u0 {dat=capbrowserrepro://callback/... cmp=app/.MainActivity} with LAUNCH_SINGLE_TASK from uid 10146 result code=3
17:52:54.682 console: appUrlOpen #2 capbrowserrepro://callback?open=1 <-- same open=1
17:52:54.685 ActivityTaskManager: START u0 {cmp=app/BrowserControllerActivity (has extras)} with LAUNCH_SINGLE_TASK from uid 10208 result code=0
17:52:54.698 wm_on_create_called: [208448521,BrowserControllerActivity,performCreate,3]
17:52:54.698 ActivityTaskManager: START u0 {dat=http://localhost:8787/... cmp=com.android.chrome/IntentDispatcher} with LAUNCH_MULTIPLE from uid 10208 result code=0 <-- third launch (Chrome drops it)
--- only now, once MainActivity is idle, are the old controllers destroyed (onDestroy clears the listener) ---
17:52:54.813 wm_destroy_activity: [0,172569187,276,app/BrowserControllerActivity,finish-imm:idle]
17:52:54.819 wm_on_destroy_called: [172569187,BrowserControllerActivity,performDestroy,0]
Redirect server for the same tap: two GETs for open=1.
10:52:54.114 GET /?to=capbrowserrepro%3A%2F%2Fcallback%3Fopen%3D1 -> 302 capbrowserrepro://callback?open=1
10:52:54.252 GET /?to=capbrowserrepro%3A%2F%2Fcallback%3Fopen%3D1 -> 302 capbrowserrepro://callback?open=1
Logcat for the stuck controller. This build is 8.0.5 with Log.d added to open(), close(),
onCreate and onDestroy, and close() runs 80 ms after appUrlOpen. A, B and C are controller
instances.
18:25:36.038 START {dat=capbrowserrepro://callback/...} -> MainActivity 1st deep link; controller A finishing
18:25:36.126 BrowserRace: close: instance set -> starting controller
18:25:36.142 BrowserRace: onCreate B close-extra=true listener=true replay
18:25:36.334 START {dat=capbrowserrepro://callback/...} -> MainActivity 2nd deep link
18:25:36.434 BrowserRace: close: instance set -> starting controller
18:25:36.438 BrowserRace: onDestroy A clears listener=true <-- lands between close() and C's onCreate
18:25:36.444 ActivityTaskManager: START u0 {cmp=app/BrowserControllerActivity (has extras)} with LAUNCH_SINGLE_TASK from uid 10208 result code=0
18:25:36.463 BrowserRace: onCreate C close-extra=true listener=false <-- launches nothing, stays on top
The same state from an uninstrumented run: a later Browser.open() reaches the stuck controller, and
only Back gets rid of it.
18:13:53.074 Capacitor: callback: …, pluginId: Browser, methodName: open, methodData: {"url":"https:\/\/capacitorjs.com"}
18:13:53.076 ActivityTaskManager: START u0 {flg=0x14000000 cmp=app/BrowserControllerActivity} with LAUNCH_SINGLE_TASK from uid 10208 result code=3
18:13:53.077 wm_on_paused_called / wm_on_resume_called (stuck controller) nothing launched; open() never settles
18:14:10.726 wm_finish_activity: [0,257479185,284,app/BrowserControllerActivity,app-request] after KEYCODE_BACK
We saw one other stuck controller in an uninstrumented run. That one was started by open() itself:
its onCreate ran 5 ms after the START and launched nothing, and no controller was destroyed in
between. That fits open() setting the listener only after startActivity() returns, or the
non-volatile static being read stale on the main thread. The patch covers both, but I can't tell which
one it was.
Suggested fix (against 8.0.5 / main):
BrowserControllerActivity.onCreate: if the intent has thecloseextra,finish()and return.
Never run the open listener for a close.- Consume the listener in
onCreate(one-shot), so no later controller can replay an earlieropen(). onDestroy: forget only this controller. Don't clear a listener that may already belong to a newer
open().open(): set the listener beforestartActivity, soonCreatecan't run first and miss it.- Make the two statics
volatile: they are written on the plugin thread and read on the main thread.
Patch
diff --git a/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java b/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java
index 578fde6..9a7f9f2 100644
--- a/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java
+++ b/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java
@@ -19,6 +19,14 @@ public class BrowserControllerActivity extends ComponentActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ // close() starts a new instance when the previous one was already cleared off the task
+ // (e.g. by a deep link into a singleTask activity) but not destroyed yet. There is nothing
+ // to close then, and the last open() must not run again.
+ if (getIntent().hasExtra("close")) {
+ finish();
+ return;
+ }
+
customTabLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), (result) -> {
if (implementation != null) {
implementation.notifyBrowserFinished();
@@ -26,8 +34,11 @@ public class BrowserControllerActivity extends ComponentActivity {
finish();
});
- if (BrowserPlugin.browserControllerListener != null) {
- BrowserPlugin.browserControllerListener.onControllerReady(this);
+ // Each open() is served once, by the controller it started.
+ BrowserControllerListener listener = BrowserPlugin.browserControllerListener;
+ BrowserPlugin.browserControllerListener = null;
+ if (listener != null) {
+ listener.onControllerReady(this);
}
}
@@ -64,6 +75,6 @@ public class BrowserControllerActivity extends ComponentActivity {
protected void onDestroy() {
super.onDestroy();
implementation = null;
- BrowserPlugin.setBrowserControllerListener(null);
+ BrowserPlugin.onControllerDestroyed(this);
}
}
diff --git a/android/src/main/java/com/capacitorjs/plugins/browser/BrowserPlugin.java b/android/src/main/java/com/capacitorjs/plugins/browser/BrowserPlugin.java
index 639bfa0..a8ab903 100644
--- a/android/src/main/java/com/capacitorjs/plugins/browser/BrowserPlugin.java
+++ b/android/src/main/java/com/capacitorjs/plugins/browser/BrowserPlugin.java
@@ -15,8 +15,9 @@ public class BrowserPlugin extends Plugin {
private Browser implementation;
- public static BrowserControllerListener browserControllerListener;
- private static BrowserControllerActivity browserControllerActivityInstance;
+ // Written on the plugin thread, read on the main thread.
+ public static volatile BrowserControllerListener browserControllerListener;
+ private static volatile BrowserControllerActivity browserControllerActivityInstance;
public static void setBrowserControllerListener(BrowserControllerListener listener) {
browserControllerListener = listener;
@@ -25,6 +26,16 @@ public class BrowserPlugin extends Plugin {
}
}
+ /**
+ * Called by each controller as it is destroyed. It forgets that controller only: the listener
+ * may already belong to a newer open() whose controller has not been created yet.
+ */
+ static void onControllerDestroyed(BrowserControllerActivity activity) {
+ if (browserControllerActivityInstance == activity) {
+ browserControllerActivityInstance = null;
+ }
+ }
+
public void load() {
implementation = new Browser(getContext());
implementation.setBrowserEventListener(this::onBrowserEvent);
@@ -61,11 +72,7 @@ public class BrowserPlugin extends Plugin {
// open the browser and finish
- Intent intent = new Intent(getContext(), BrowserControllerActivity.class);
- intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- getContext().startActivity(intent);
-
+ // Set the listener before starting the activity, so its onCreate can't run first and miss it.
Integer finalToolbarColor = toolbarColor;
setBrowserControllerListener((activity) -> {
try {
@@ -77,6 +84,11 @@ public class BrowserPlugin extends Plugin {
call.reject("Unable to display URL");
}
});
+
+ Intent intent = new Intent(getContext(), BrowserControllerActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ getContext().startActivity(intent);
}
@PluginMethod
With this patch applied to 8.0.5, a stress run (150 taps at 0, 80 and 100 ms) gave 150 appUrlOpen, 150
Chrome launches and 150 redirect-server GETs, with no stuck controller. Unpatched, we saw 2 stuck
controllers in ~180 taps across 0–250 ms, both at 80–100 ms. In 142 of the 150 taps, close() landed in the window and the
new instance finished at once. A normal Browser.close() of a Custom Tab that is still showing works as
before, and browserFinished fires once.
Happy to open a PR with this.
Workaround until then: on Android, skip Browser.close() when the app is reopened by the redirect.
The deep link into the singleTask MainActivity has already removed the Custom Tab. iOS still needs
close(), because SFSafariViewController stays presented.
Possibly related (same lifecycle code, both closed for lack of a repro): #2557, #2095. #2095
describes a Browser.open() shortly after returning through a deep link that closes again at once,
with a 1.5 s setTimeout as the workaround. That timing fits the same "old controller not destroyed
yet" window, but I haven't verified that it's the same cause.
- 主要言語
- Java
- スター
- 685
- フォーク
- 691
- 平均マージ
- 16時間 33分
- マージ済み PR(30日)
- 1
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
ionic-team/capacitor-plugins のほかの issue
-
triage
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
ionic-team/capacitor-plugins#2616 ·
-
[@capacitor/app] iOS: 'Expression implicitly coerced from String? to Any' warning in getAppLanguageオープンtriage
難易度 1/5 1時間未満 初心者へのやさしさ 88/100
ionic-team/capacitor-plugins#2604 ·
-
triage
難易度 3/5 1〜2日 初心者へのやさしさ 66/100
ionic-team/capacitor-plugins#2615 ·
-
triage
難易度 3/5 1〜2日 初心者へのやさしさ 58/100
ionic-team/capacitor-plugins#2614 ·
-
platform: android platform: ios
難易度 4/5 3〜5日 初心者へのやさしさ 52/100
ionic-team/capacitor-plugins#2613 ·
ionic-team/capacitor-plugins の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
QuiltMC/update-quilt-meta#28 ·
-
awaiting triage Documentation P3
難易度 1/5 1〜3時間 初心者へのやさしさ 86/100
メンテナーはふだん 1 日以内に返信
-
area/build area/maintainability component/camunda-process-test kind/tech-debt
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
メンテナーはふだん 1 日以内に返信
-
Kafka: Filter empty strings and whitespace in IcebergSinkConfig stringToList対応中かも @mohamed-elbrik が今日担当しました。 オープンbug
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信
-
area/kotlin
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
メンテナーはふだん 1 日以内に返信