From 49a39643392ee50a1a0224948f92cd2cc20d233d Mon Sep 17 00:00:00 2001 From: Rui Mendes Date: Thu, 2 Jul 2026 15:33:48 +0100 Subject: [PATCH 1/4] fix(browser): only fire browserFinished when the Custom Tab actually terminates --- .../capacitorjs/plugins/browser/Browser.java | 42 +++++++++-------- .../browser/BrowserControllerActivity.java | 34 +++++++------- .../plugins/browser/EventGroup.java | 45 ------------------- 3 files changed, 42 insertions(+), 79 deletions(-) delete mode 100644 browser/android/src/main/java/com/capacitorjs/plugins/browser/EventGroup.java diff --git a/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java b/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java index fff0ddc43f..22188fab62 100644 --- a/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java +++ b/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java @@ -7,6 +7,7 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.*; @@ -43,7 +44,8 @@ interface BrowserEventListener { private CustomTabsClient customTabsClient; private CustomTabsSession browserSession; private boolean isInitialLoad = false; - private EventGroup group; + @Nullable + private ActivityResultLauncher customTabLauncher; private CustomTabsServiceConnection connection = new CustomTabsServiceConnection() { @Override public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { @@ -61,7 +63,6 @@ public void onServiceDisconnected(ComponentName name) {} */ public Browser(@NonNull Context context) { this.context = context; - this.group = new EventGroup(this::handleGroupCompletion); } /** @@ -81,6 +82,16 @@ public BrowserEventListener getBrowserEventListenerListener() { return browserEventListener; } + /** + * Provide the ActivityResultLauncher used to open the Custom Tab. When + * set, the Custom Tab is launched via this launcher so that + * {@link #notifyBrowserFinished()} can be triggered from the launcher's + * result callback (only when the tab activity actually terminates). + */ + public void setCustomTabLauncher(@Nullable ActivityResultLauncher launcher) { + this.customTabLauncher = launcher; + } + /** * Open the browser to the specified URL. * @param url @@ -108,8 +119,12 @@ public void open(Uri url, @Nullable Integer toolbarColor) { tabsIntent.intent.putExtra(Intent.EXTRA_REFERRER, Uri.parse(Intent.URI_ANDROID_APP_SCHEME + "//" + context.getPackageName())); isInitialLoad = true; - group.reset(); - tabsIntent.launchUrl(context, url); + if (customTabLauncher != null) { + tabsIntent.intent.setData(url); + customTabLauncher.launch(tabsIntent.intent); + } else { + tabsIntent.launchUrl(context, url); + } } /** @@ -120,9 +135,7 @@ public boolean bindService() { if (null == customTabPackageName) { customTabPackageName = FALLBACK_CUSTOM_TAB_PACKAGE_NAME; } - boolean result = CustomTabsClient.bindCustomTabsService(context, customTabPackageName, connection); - group.leave(); - return result; + return CustomTabsClient.bindCustomTabsService(context, customTabPackageName, connection); } /** @@ -130,7 +143,6 @@ public boolean bindService() { */ public void unbindService() { context.unbindService(connection); - group.enter(); } private void handledNavigationEvent(int navigationEvent) { @@ -143,19 +155,13 @@ private void handledNavigationEvent(int navigationEvent) { isInitialLoad = false; } break; - case CustomTabsCallback.TAB_HIDDEN: - group.leave(); - break; - case CustomTabsCallback.TAB_SHOWN: - group.enter(); - break; } } - private void handleGroupCompletion() { - // events such as TAB_HIDDEN and onPause can occur for multiple reasons and in - // different sequences so there is no single point to fire this. so we rely on the - // event group to track when it is safe to assume that the browser is done. + public void notifyBrowserFinished() { + // Notify listeners that the browser session has finished. Called by the + // host activity when the Custom Tab activity actually returns a result + // (i.e. the tab was truly dismissed, not just backgrounded or minimised). if (browserEventListener != null) { browserEventListener.onBrowserEvent(BROWSER_FINISHED); } diff --git a/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java b/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java index 74fd599c10..7fc834fb4d 100644 --- a/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java +++ b/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java @@ -1,19 +1,31 @@ package com.capacitorjs.plugins.browser; -import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import androidx.activity.ComponentActivity; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; -public class BrowserControllerActivity extends Activity { +public class BrowserControllerActivity extends ComponentActivity { - private boolean isCustomTabsOpen = false; + private ActivityResultLauncher customTabLauncher; + private Browser implementation; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - isCustomTabsOpen = false; + + customTabLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (implementation != null) { + implementation.notifyBrowserFinished(); + } + finish(); + } + ); if (BrowserPlugin.browserControllerListener != null) { BrowserPlugin.browserControllerListener.onControllerReady(this); @@ -28,25 +40,15 @@ protected void onNewIntent(Intent intent) { } } - @Override - protected void onResume() { - super.onResume(); - if (isCustomTabsOpen) { - isCustomTabsOpen = false; - finish(); - } else { - isCustomTabsOpen = true; - } - } - public void open(Browser implementation, Uri url, Integer toolbarColor) { + this.implementation = implementation; + implementation.setCustomTabLauncher(customTabLauncher); implementation.open(url, toolbarColor); } @Override protected void onDestroy() { super.onDestroy(); - isCustomTabsOpen = false; BrowserPlugin.setBrowserControllerListener(null); } } diff --git a/browser/android/src/main/java/com/capacitorjs/plugins/browser/EventGroup.java b/browser/android/src/main/java/com/capacitorjs/plugins/browser/EventGroup.java deleted file mode 100644 index 2c9541edc6..0000000000 --- a/browser/android/src/main/java/com/capacitorjs/plugins/browser/EventGroup.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.capacitorjs.plugins.browser; - -/** - * Simple class to handle indeterminate sequence of events. Not thread safe. - */ -class EventGroup { - - interface EventGroupCompletion { - void onGroupCompletion(); - } - - private int count; - private boolean isComplete; - private EventGroupCompletion completion; - - public EventGroup(EventGroupCompletion onCompletion) { - super(); - count = 0; - isComplete = false; - completion = onCompletion; - } - - public void enter() { - count++; - } - - public void leave() { - count--; - checkForCompletion(); - } - - public void reset() { - count = 0; - isComplete = false; - } - - private void checkForCompletion() { - if (count <= 0) { - if (isComplete == false && completion != null) { - completion.onGroupCompletion(); - } - isComplete = true; - } - } -} From 1276c03066d982968f08ff7d1e1847c99da365c0 Mon Sep 17 00:00:00 2001 From: Rui Mendes Date: Fri, 3 Jul 2026 16:15:55 +0100 Subject: [PATCH 2/4] chore(browser): format java code --- .../com/capacitorjs/plugins/browser/Browser.java | 2 ++ .../plugins/browser/BrowserControllerActivity.java | 13 +++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java b/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java index 22188fab62..937ec37378 100644 --- a/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java +++ b/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java @@ -44,8 +44,10 @@ interface BrowserEventListener { private CustomTabsClient customTabsClient; private CustomTabsSession browserSession; private boolean isInitialLoad = false; + @Nullable private ActivityResultLauncher customTabLauncher; + private CustomTabsServiceConnection connection = new CustomTabsServiceConnection() { @Override public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { diff --git a/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java b/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java index 7fc834fb4d..4caf3c578a 100644 --- a/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java +++ b/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java @@ -17,15 +17,12 @@ public class BrowserControllerActivity extends ComponentActivity { protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - customTabLauncher = registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), - result -> { - if (implementation != null) { - implementation.notifyBrowserFinished(); - } - finish(); + customTabLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), (result) -> { + if (implementation != null) { + implementation.notifyBrowserFinished(); } - ); + finish(); + }); if (BrowserPlugin.browserControllerListener != null) { BrowserPlugin.browserControllerListener.onControllerReady(this); From c7ef3c141d7aeec21d4e3cc31776142c6006f68a Mon Sep 17 00:00:00 2001 From: Rui Mendes Date: Tue, 15 Sep 2026 16:22:48 +0100 Subject: [PATCH 3/4] clear implementation reference in onDestroy --- .../capacitorjs/plugins/browser/BrowserControllerActivity.java | 1 + 1 file changed, 1 insertion(+) diff --git a/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java b/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java index 4caf3c578a..37316dbf67 100644 --- a/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java +++ b/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java @@ -46,6 +46,7 @@ public void open(Browser implementation, Uri url, Integer toolbarColor) { @Override protected void onDestroy() { super.onDestroy(); + implementation = null; BrowserPlugin.setBrowserControllerListener(null); } } From d058f53be42869d38b723ca7019f693b68398c22 Mon Sep 17 00:00:00 2001 From: Rui Mendes Date: Mon, 21 Sep 2026 17:47:33 +0100 Subject: [PATCH 4/4] fix an issue on android <14 --- .../capacitorjs/plugins/browser/Browser.java | 24 +++++++++++++++---- .../browser/BrowserControllerActivity.java | 17 +++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java b/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java index 937ec37378..99626d7133 100644 --- a/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java +++ b/browser/android/src/main/java/com/capacitorjs/plugins/browser/Browser.java @@ -6,11 +6,13 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.*; +import java.util.concurrent.atomic.AtomicBoolean; /** * The Browser class implements Custom Chrome Tabs. See @@ -44,6 +46,7 @@ interface BrowserEventListener { private CustomTabsClient customTabsClient; private CustomTabsSession browserSession; private boolean isInitialLoad = false; + private final AtomicBoolean browserFinishedFired = new AtomicBoolean(false); @Nullable private ActivityResultLauncher customTabLauncher; @@ -121,6 +124,7 @@ public void open(Uri url, @Nullable Integer toolbarColor) { tabsIntent.intent.putExtra(Intent.EXTRA_REFERRER, Uri.parse(Intent.URI_ANDROID_APP_SCHEME + "//" + context.getPackageName())); isInitialLoad = true; + browserFinishedFired.set(false); if (customTabLauncher != null) { tabsIntent.intent.setData(url); customTabLauncher.launch(tabsIntent.intent); @@ -161,10 +165,10 @@ private void handledNavigationEvent(int navigationEvent) { } public void notifyBrowserFinished() { - // Notify listeners that the browser session has finished. Called by the - // host activity when the Custom Tab activity actually returns a result - // (i.e. the tab was truly dismissed, not just backgrounded or minimised). - if (browserEventListener != null) { + // Guarded so the finished event is delivered at most once per open() + // session, even when multiple signals (ActivityResult, onMinimized) + // fire for the same browser instance. + if (browserFinishedFired.compareAndSet(false, true) && browserEventListener != null) { browserEventListener.onBrowserEvent(BROWSER_FINISHED); } } @@ -182,6 +186,18 @@ private CustomTabsSession getCustomTabsSession() { public void onNavigationEvent(int navigationEvent, Bundle extras) { handledNavigationEvent(navigationEvent); } + + @Override + public void onMinimized(@NonNull Bundle extras) { + // On Android <14 the ActivityResult callback is not + // reliably delivered when the Custom Tab is dismissed from PiP, + // so we notify listeners as the Custom Tab enters PiP as a + // compromise. Skipped on 14+ where the ActivityResult + // callback handles it correctly. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + notifyBrowserFinished(); + } + } } ); } diff --git a/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java b/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java index 37316dbf67..578fde6e10 100644 --- a/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java +++ b/browser/android/src/main/java/com/capacitorjs/plugins/browser/BrowserControllerActivity.java @@ -2,6 +2,7 @@ import android.content.Intent; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import androidx.activity.ComponentActivity; import androidx.activity.result.ActivityResultLauncher; @@ -12,6 +13,7 @@ public class BrowserControllerActivity extends ComponentActivity { private ActivityResultLauncher customTabLauncher; private Browser implementation; + private boolean isFirstResume = true; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -29,6 +31,21 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } } + @Override + protected void onResume() { + super.onResume(); + // On Android <14 the ActivityResult callback is not reliably delivered + // when the Custom Tab is dismissed from PiP, leaving this launcher + // stranded on top of the task and freezing the app UI. Finishing on + // the second resume (fires when control returns from the Custom Tab) releases + // the task. Skipped on 14+ where the ActivityResult callback handles it. + if (isFirstResume) { + isFirstResume = false; + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + finish(); + } + } + @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent);