Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

bug(browser): Browser.close() on Android re-opens the last URL after a deep link back into the app

オープン
#2,610 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

@pramoth がすでに取り組んでいます。

2026年10月15日 から。

  • #2611 @pramoth による — オープン

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
35/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
活発
技術スタック
android, java
領域
mobile

調査の方向性

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 の本文から書いたものです。

説明

platform: android platform: ios

Bug Report

Plugin(s)
  • @capacitor/browser 8.0.5 (latest). main is identical in BrowserPlugin.java and BrowserControllerActivity.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:

  1. open() sets the static browserControllerListener and starts BrowserControllerActivity
    (singleTask). The activity's onCreate calls the listener, which launches the Custom Tab.
  2. 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, once MainActivity
    goes idle (finish-imm:idle, ~240 ms later in the log below).
  3. Within that window, close() sees browserControllerActivityInstance != null and starts
    BrowserControllerActivity with the close extra. The old instance is finishing, so Android creates
    a new instance (result code=0) instead of calling onNewIntent.
  4. The new instance's onCreate does not check for the close extra. It calls
    browserControllerListener, which is still set: the old instance's onDestroy clears it, and that
    has not run yet. So activity.open(...) runs again, with the URL from the last open().

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 the open()
    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:

  1. npm install && npm run build && npx cap sync android
  2. node redirect-server.mjs
  3. adb reverse tcp:8787 tcp:8787, then npx cap run android
  4. 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.
  • MainActivity uses android: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 the close extra, 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 earlier open().
  • onDestroy: forget only this controller. Don't clear a listener that may already belong to a newer
    open().
  • open(): set the listener before startActivity, so onCreate can'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

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

ionic-team/capacitor-plugins のほかの issue

ionic-team/capacitor-plugins の issue をすべて見る

似ている issue

Java の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。