Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

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

Đang mở
#2,610 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

@pramoth đang làm issue này rồi.

Từ ngày 15/10/2026.

  • #2611 của @pramoth — đang mở

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
35/100
Loại issue
Lỗi
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
android, java
Lĩnh vực
mobile

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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.

Ngôn ngữ chính
Java
Star
685
Fork
691
Merge trung bình
16 giờ 33 phút
Pull request đã merge (30 ngày)
1

Chuẩn bị môi trường

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của ionic-team/capacitor-plugins

Tất cả issue của ionic-team/capacitor-plugins

Issue tương tự

Thêm issue về Java

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.