Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Comment thread
OS-pedrogustavobilro marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixes

- Fire `browserClosed` only when the Custom Tab actually terminates, using `ActivityResultLauncher` instead of inferring closure from `TAB_HIDDEN`. Prevents the event from firing prematurely when the tab is minimized to Picture-in-Picture or the app is backgrounded. This fix applies to Android 14 and higher, on older versions the previous behavior is preserved because the `ActivityResult` callback is not reliably delivered when the tab is dismissed from Picture-in-Picture.

## [2.0.4]

### Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.browser.customtabs.CustomTabsCallback
import androidx.browser.customtabs.CustomTabsClient
Expand Down Expand Up @@ -62,54 +63,41 @@ class OSIABCustomTabsSessionHelper: OSIABCustomTabsSessionHelperInterface {
flowHelper: OSIABFlowHelperInterface,
) : CustomTabsCallback() {

private var isCustomTabsActivityOnTop = false
private var pendingTabHiddenEvent = false

init {
var browserEventsJob: Job? = null

browserEventsJob = flowHelper.listenToEvents(browserId, lifecycleScope) { event ->
if(event is OSIABEvents.OSIABCustomTabsEvent) {
when (event.action) {
OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_RESUMED -> {
isCustomTabsActivityOnTop = true
if (pendingTabHiddenEvent) {
pendingTabHiddenEvent = false
lifecycleScope.launch {
OSIABEvents.postEvent(OSIABEvents.BrowserFinished(browserId))
}
}
}
OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_PAUSED -> {
isCustomTabsActivityOnTop = false
pendingTabHiddenEvent = false
}
OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_DESTROYED -> {
browserEventsJob?.cancel()
}
}
if (event is OSIABEvents.OSIABCustomTabsEvent
&& event.action == OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_DESTROYED) {
browserEventsJob?.cancel()
}
}
}

override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
super.onNavigationEvent(navigationEvent, extras)
val browserEvent = when (navigationEvent) {
NAVIGATION_FINISHED -> OSIABEvents.BrowserPageLoaded(browserId)
TAB_HIDDEN -> {
if(isCustomTabsActivityOnTop) {
OSIABEvents.BrowserFinished(browserId)
}
else {
// App not open but custom tabs is hidden (home button, recent apps, etc.)
pendingTabHiddenEvent = true
return
}
if (navigationEvent == NAVIGATION_FINISHED) {
lifecycleScope.launch {
OSIABEvents.postEvent(OSIABEvents.BrowserPageLoaded(browserId))
}
else -> return
}
lifecycleScope.launch {
OSIABEvents.postEvent(browserEvent)
}

override fun onMinimized(extras: Bundle) {
super.onMinimized(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.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
lifecycleScope.launch {
OSIABEvents.postEvent(
OSIABEvents.OSIABCustomTabsEvent(
browserId = browserId,
action = OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_DESTROYED,
context = null
)
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,28 @@ class OSIABCustomTabsRouterAdapter(
var closeEventJob: Job? = null

closeEventJob = flowHelper.listenToEvents(browserId, lifecycleScope) { event ->
if(event is OSIABEvents.OSIABCustomTabsEvent) {
when(event.action) {
OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_READY -> {
completionHandler(false)
}
OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_DESTROYED -> {
completionHandler(true)
}
else -> {
return@listenToEvents
}
}
if(event is OSIABEvents.OSIABCustomTabsEvent
&& event.action == OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_DESTROYED) {
completionHandler(true)
closeEventJob?.cancel()
}
}

startCustomTabsControllerActivity(true)
startCustomTabsControllerActivity(doClose = true)
}

private fun resolveStartAnimationRes(animation: OSIABAnimation): Pair<Int, Int> = when (animation) {
OSIABAnimation.FADE_IN -> android.R.anim.fade_in to android.R.anim.fade_out
OSIABAnimation.FADE_OUT -> android.R.anim.fade_out to android.R.anim.fade_in
OSIABAnimation.SLIDE_IN_LEFT -> android.R.anim.slide_in_left to android.R.anim.slide_out_right
OSIABAnimation.SLIDE_OUT_RIGHT -> android.R.anim.slide_out_right to android.R.anim.slide_in_left
}

private fun resolveExitAnimationRes(animation: OSIABAnimation): Pair<Int, Int> = when (animation) {
OSIABAnimation.FADE_IN -> android.R.anim.fade_out to android.R.anim.fade_in
OSIABAnimation.FADE_OUT -> android.R.anim.fade_in to android.R.anim.fade_out
OSIABAnimation.SLIDE_IN_LEFT -> android.R.anim.slide_out_right to android.R.anim.slide_in_left
OSIABAnimation.SLIDE_OUT_RIGHT -> android.R.anim.slide_in_left to android.R.anim.slide_out_right
}

private fun buildCustomTabsIntent(customTabsSession: CustomTabsSession?): CustomTabsIntent {
Expand All @@ -76,57 +81,11 @@ class OSIABCustomTabsRouterAdapter(
builder.setShowTitle(options.showTitle)
builder.setUrlBarHidingEnabled(options.hideToolbarOnScroll)

when (options.startAnimation) {
OSIABAnimation.FADE_IN -> builder.setStartAnimations(
context,
android.R.anim.fade_in,
android.R.anim.fade_out
)

OSIABAnimation.FADE_OUT -> builder.setStartAnimations(
context,
android.R.anim.fade_out,
android.R.anim.fade_in
)

OSIABAnimation.SLIDE_IN_LEFT -> builder.setStartAnimations(
context,
android.R.anim.slide_in_left,
android.R.anim.slide_out_right
)

OSIABAnimation.SLIDE_OUT_RIGHT -> builder.setStartAnimations(
context,
android.R.anim.slide_out_right,
android.R.anim.slide_in_left
)
}

when (options.exitAnimation) {
OSIABAnimation.FADE_IN -> builder.setExitAnimations(
context,
android.R.anim.fade_out,
android.R.anim.fade_in
)

OSIABAnimation.FADE_OUT -> builder.setExitAnimations(
context,
android.R.anim.fade_in,
android.R.anim.fade_out
)
val (startEnter, startExit) = resolveStartAnimationRes(options.startAnimation)
builder.setStartAnimations(context, startEnter, startExit)

OSIABAnimation.SLIDE_IN_LEFT -> builder.setExitAnimations(
context,
android.R.anim.slide_out_right,
android.R.anim.slide_in_left
)

OSIABAnimation.SLIDE_OUT_RIGHT -> builder.setExitAnimations(
context,
android.R.anim.slide_in_left,
android.R.anim.slide_out_right
)
}
val (exitEnter, exitExit) = resolveExitAnimationRes(options.exitAnimation)
builder.setExitAnimations(context, exitEnter, exitExit)

if (options.viewStyle == OSIABViewStyle.BOTTOM_SHEET) {
options.bottomSheetOptions?.let { bottomSheetOptions ->
Expand Down Expand Up @@ -161,9 +120,7 @@ class OSIABCustomTabsRouterAdapter(
completionHandler(false)
return@generateNewCustomTabsSession
}

openCustomTabsIntent(it, uri, completionHandler)
startCustomTabsControllerActivity()
}
)
} catch (e: Exception) {
Expand All @@ -174,21 +131,13 @@ class OSIABCustomTabsRouterAdapter(

private fun openCustomTabsIntent(session: CustomTabsSession, uri: Uri, completionHandler: (Boolean) -> Unit) {
val customTabsIntent = buildCustomTabsIntent(session)
customTabsIntent.intent.data = uri

var eventsJob: Job? = null
eventsJob = flowHelper.listenToEvents(browserId, lifecycleScope) { event ->
when (event) {
is OSIABEvents.OSIABCustomTabsEvent -> {
if(event.action == OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_READY) {
try {
event.context?.let { ctx ->
customTabsIntent.launchUrl(ctx, uri)
completionHandler(true)
} ?: completionHandler(false)
} catch (e: Exception) {
completionHandler(false)
}
}
else if(event.action == OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_DESTROYED) {
if(event.action == OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_DESTROYED) {
isFinished = true
onBrowserFinished()
eventsJob?.cancel()
Expand All @@ -200,19 +149,30 @@ class OSIABCustomTabsRouterAdapter(
isFirstLoad = false
}
}
is OSIABEvents.BrowserFinished -> {
// Ensure that custom tabs controller activity is fully destroyed
startCustomTabsControllerActivity(true)
isFinished = true
onBrowserFinished()
eventsJob?.cancel()
}
else -> {}
}
}

try {
val (startEnter, startExit) = resolveStartAnimationRes(options.startAnimation)
startCustomTabsControllerActivity(
customTabsIntent = customTabsIntent.intent,
startEnterAnimRes = startEnter,
startExitAnimRes = startExit
)
completionHandler(true)
} catch (e: Exception) {
eventsJob?.cancel()
completionHandler(false)
}
}

private fun startCustomTabsControllerActivity(doClose: Boolean = false) {
private fun startCustomTabsControllerActivity(
doClose: Boolean = false,
customTabsIntent: Intent? = null,
startEnterAnimRes: Int = 0,
startExitAnimRes: Int = 0
) {
val intent = Intent(context, OSIABCustomTabsControllerActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(OSIABEvents.EXTRA_BROWSER_ID, browserId)
Expand All @@ -223,6 +183,12 @@ class OSIABCustomTabsRouterAdapter(
intent.putExtra(OSIABCustomTabsControllerActivity.ACTION_CLOSE_CUSTOM_TABS, true)
}

if (customTabsIntent != null) {
intent.putExtra(OSIABCustomTabsControllerActivity.EXTRA_CUSTOM_TABS_INTENT, customTabsIntent)
intent.putExtra(OSIABCustomTabsControllerActivity.EXTRA_START_ENTER_ANIM_RES, startEnterAnimRes)
intent.putExtra(OSIABCustomTabsControllerActivity.EXTRA_START_EXIT_ANIM_RES, startExitAnimRes)
}

context.startActivity(intent)
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.outsystems.plugins.inappbrowser.osinappbrowserlib.views

import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents.OSIABCustomTabsEvent
Expand All @@ -18,12 +23,17 @@ import kotlinx.coroutines.launch
class OSIABCustomTabsControllerActivity: AppCompatActivity() {
companion object {
const val EVENT_CUSTOM_TABS_DESTROYED = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EVENT_CUSTOM_TABS_DESTROYED"
const val EVENT_CUSTOM_TABS_READY = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EVENT_CUSTOM_TABS_READY"
const val EVENT_CUSTOM_TABS_PAUSED = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EVENT_CUSTOM_TABS_PAUSED"
const val EVENT_CUSTOM_TABS_RESUMED = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EVENT_CUSTOM_TABS_RESUMED"
const val ACTION_CLOSE_CUSTOM_TABS = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.ACTION_CLOSE_CUSTOM_TABS"
const val EXTRA_CUSTOM_TABS_INTENT = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EXTRA_CUSTOM_TABS_INTENT"
const val EXTRA_START_ENTER_ANIM_RES = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EXTRA_START_ENTER_ANIM_RES"
const val EXTRA_START_EXIT_ANIM_RES = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EXTRA_START_EXIT_ANIM_RES"
}

private var customTabsLauncher: ActivityResultLauncher<Intent>? = null
private var hasLaunchedCustomTabs = false
private var isFirstResume = true


private fun setup(intent: Intent) {
window.setFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
Expand All @@ -32,16 +42,30 @@ class OSIABCustomTabsControllerActivity: AppCompatActivity() {

if (intent.getBooleanExtra(ACTION_CLOSE_CUSTOM_TABS, false)) {
finish()
return
}
else {
intent.getStringExtra(OSIABEvents.EXTRA_BROWSER_ID)?.let { browserId ->
sendCustomTabsEvent(lifecycleScope, browserId, EVENT_CUSTOM_TABS_READY)

if (!hasLaunchedCustomTabs) {
val customTabsIntent = IntentCompat.getParcelableExtra(intent, EXTRA_CUSTOM_TABS_INTENT, Intent::class.java)
if (customTabsIntent != null) {
hasLaunchedCustomTabs = true
val enterAnimRes = intent.getIntExtra(EXTRA_START_ENTER_ANIM_RES, 0)
val exitAnimRes = intent.getIntExtra(EXTRA_START_EXIT_ANIM_RES, 0)
val options = if (enterAnimRes != 0 && exitAnimRes != 0) {
ActivityOptionsCompat.makeCustomAnimation(this, enterAnimRes, exitAnimRes)
} else {
null
}
customTabsLauncher?.launch(customTabsIntent, options)
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
customTabsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
finish()
}
setup(intent)
}

Expand All @@ -50,17 +74,18 @@ class OSIABCustomTabsControllerActivity: AppCompatActivity() {
setup(intent)
}

override fun onPause() {
super.onPause()
intent.getStringExtra(OSIABEvents.EXTRA_BROWSER_ID)?.let { browserId ->
sendCustomTabsEvent(lifecycleScope, browserId, EVENT_CUSTOM_TABS_PAUSED)
}
}

override fun onResume() {
super.onResume()
intent.getStringExtra(OSIABEvents.EXTRA_BROWSER_ID)?.let { browserId ->
sendCustomTabsEvent(lifecycleScope, browserId, EVENT_CUSTOM_TABS_RESUMED)
// On Android <14 the ActivityResult callback is not reliably delivered
// when the Custom Tab is dismissed from PiP, leaving this controller
// stranded on top of the task and blocking subsequent open() calls.
// 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()
}
}

Expand Down Expand Up @@ -95,4 +120,4 @@ class OSIABCustomTabsControllerActivity: AppCompatActivity() {
scope.cancel()
}
}
}
}
Loading
Loading