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..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,10 +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 @@ -43,7 +46,11 @@ interface BrowserEventListener { private CustomTabsClient customTabsClient; private CustomTabsSession browserSession; private boolean isInitialLoad = false; - private EventGroup group; + private final AtomicBoolean browserFinishedFired = new AtomicBoolean(false); + + @Nullable + private ActivityResultLauncher customTabLauncher; + private CustomTabsServiceConnection connection = new CustomTabsServiceConnection() { @Override public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { @@ -61,7 +68,6 @@ public void onServiceDisconnected(ComponentName name) {} */ public Browser(@NonNull Context context) { this.context = context; - this.group = new EventGroup(this::handleGroupCompletion); } /** @@ -81,6 +87,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 +124,13 @@ 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); + browserFinishedFired.set(false); + if (customTabLauncher != null) { + tabsIntent.intent.setData(url); + customTabLauncher.launch(tabsIntent.intent); + } else { + tabsIntent.launchUrl(context, url); + } } /** @@ -120,9 +141,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 +149,6 @@ public boolean bindService() { */ public void unbindService() { context.unbindService(connection); - group.enter(); } private void handledNavigationEvent(int navigationEvent) { @@ -143,20 +161,14 @@ 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. - if (browserEventListener != null) { + public void notifyBrowserFinished() { + // 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); } } @@ -174,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 74fd599c10..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 @@ -1,19 +1,30 @@ package com.capacitorjs.plugins.browser; -import android.app.Activity; 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; +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; + private boolean isFirstResume = true; @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); @@ -21,32 +32,38 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - if (intent.hasExtra("close")) { + 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 onResume() { - super.onResume(); - if (isCustomTabsOpen) { - isCustomTabsOpen = false; + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (intent.hasExtra("close")) { 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; + implementation = null; 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; - } - } -}