diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fcac34..61b6e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). +## [1.7.0] + +### Fixes + +- Fix issue with local storage isolation between WebView and main app on Android 28+ [RMET-4918](https://outsystemsrd.atlassian.net/browse/RMET-4918) + ## [1.6.1] ### Fixes diff --git a/pom.xml b/pom.xml index 3ab5e59..c704fb1 100644 --- a/pom.xml +++ b/pom.xml @@ -6,5 +6,5 @@ 4.0.0 io.ionic.libs ioninappbrowser-android - 1.6.1 + 1.7.0 diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 4def0b9..bf36993 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ () + // Buffer capacity is required because BroadcastReceiver.onReceive() is synchronous. + // We must use tryEmit() which would drop events without buffer space. + private val _events = MutableSharedFlow(extraBufferCapacity = 64) val events = _events.asSharedFlow() + private var receiver: BroadcastReceiver? = null + private var receiverRefCount = 0 + + /** + * Registers a BroadcastReceiver to listen for events from the isolated WebView process. + * This must be called before opening a WebView on Android 9+ to ensure events are received. + */ + @Synchronized + fun registerReceiver(context: Context) { + receiverRefCount++ + if (receiver != null) return + + val appContext = context.applicationContext + receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_IAB_EVENT) { + val event = IntentCompat.getSerializableExtra( + intent, + EXTRA_EVENT_DATA, + OSIABEvents::class.java + ) + event?.let { + _events.tryEmit(it) + } + } + } + } + + val filter = IntentFilter(ACTION_IAB_EVENT) + ContextCompat.registerReceiver( + appContext, + receiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + /** + * Unregisters the BroadcastReceiver. Should be called when the browser is closed. + * The receiver is only truly unregistered when all registered 'users' have unregistered. + */ + @Synchronized + fun unregisterReceiver(context: Context) { + if (receiverRefCount > 0) { + receiverRefCount-- + } + + if (receiverRefCount == 0) { + receiver?.let { + try { + context.applicationContext.unregisterReceiver(it) + } catch (e: Exception) { + // Receiver may not be registered, ignore + } + receiver = null + } + } + } + suspend fun postEvent(event: OSIABEvents) { _events.emit(event) } + + /** + * Broadcasts an event from the isolated WebView process to the main process. + * Only data-only events should be broadcast (BrowserPageLoaded, BrowserFinished, etc.). + */ + fun broadcastEvent(context: Context, event: OSIABEvents) { + val intent = Intent(ACTION_IAB_EVENT).apply { + setPackage(context.packageName) + putExtra(EXTRA_EVENT_DATA, event) + } + context.sendBroadcast(intent) + } } } diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABCustomTabsSessionHelper.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABCustomTabsSessionHelper.kt index a26ca53..f45ac20 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABCustomTabsSessionHelper.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABCustomTabsSessionHelper.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.outsystems.plugins.inappbrowser.osinappbrowserlib.RequiresEventBridgeRegistration::class) + package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers import android.content.ComponentName diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFlowHelper.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFlowHelper.kt index 3ae711b..f312132 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFlowHelper.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFlowHelper.kt @@ -1,6 +1,7 @@ package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents +import com.outsystems.plugins.inappbrowser.osinappbrowserlib.RequiresEventBridgeRegistration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.transformWhile @@ -14,7 +15,11 @@ class OSIABFlowHelper: OSIABFlowHelperInterface { * @param browserId Identifier for the browser instance to emit events to * @param scope CoroutineScope to launch * @param onEventReceived callback to send the collected event in + * + * @note For Android API 28+, you must call [OSIABEvents.registerReceiver] once during your application + * or activity lifecycle to ensure events from the isolated browser process are correctly received and bridged. */ + @RequiresEventBridgeRegistration override fun listenToEvents( browserId: String, scope: CoroutineScope, diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFlowHelperInterface.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFlowHelperInterface.kt index 7c0c329..0ff2bbc 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFlowHelperInterface.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFlowHelperInterface.kt @@ -1,6 +1,7 @@ package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents +import com.outsystems.plugins.inappbrowser.osinappbrowserlib.RequiresEventBridgeRegistration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -12,7 +13,11 @@ interface OSIABFlowHelperInterface { * @param browserId Identifier for the browser instance to emit events to * @param scope CoroutineScope to launch * @param onEventReceived callback to send the collected event in + * + * @note For Android API 28+, you must call [OSIABEvents.registerReceiver] once during your application + * or activity lifecycle to ensure events from the isolated browser process are correctly received and bridged. */ + @RequiresEventBridgeRegistration fun listenToEvents( browserId: String, scope: CoroutineScope, diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/routeradapters/OSIABCustomTabsRouterAdapter.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/routeradapters/OSIABCustomTabsRouterAdapter.kt index 0c4e69a..d6d1cb5 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/routeradapters/OSIABCustomTabsRouterAdapter.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/routeradapters/OSIABCustomTabsRouterAdapter.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.outsystems.plugins.inappbrowser.osinappbrowserlib.RequiresEventBridgeRegistration::class) + package com.outsystems.plugins.inappbrowser.osinappbrowserlib.routeradapters import android.content.Context @@ -39,8 +41,13 @@ class OSIABCustomTabsRouterAdapter( // for the browserPageLoaded event, which we only want to trigger on the first URL loaded in the CustomTabs instance private var isFirstLoad = true + private var isFinished = false override fun close(completionHandler: (Boolean) -> Unit) { + if (isFinished) { + completionHandler(true) + return + } var closeEventJob: Job? = null closeEventJob = flowHelper.listenToEvents(browserId, lifecycleScope) { event -> @@ -173,13 +180,16 @@ class OSIABCustomTabsRouterAdapter( is OSIABEvents.OSIABCustomTabsEvent -> { if(event.action == OSIABCustomTabsControllerActivity.EVENT_CUSTOM_TABS_READY) { try { - customTabsIntent.launchUrl(event.context, uri) - completionHandler(true) + 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) { + isFinished = true onBrowserFinished() eventsJob?.cancel() } @@ -193,6 +203,7 @@ class OSIABCustomTabsRouterAdapter( is OSIABEvents.BrowserFinished -> { // Ensure that custom tabs controller activity is fully destroyed startCustomTabsControllerActivity(true) + isFinished = true onBrowserFinished() eventsJob?.cancel() } diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/routeradapters/OSIABWebViewRouterAdapter.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/routeradapters/OSIABWebViewRouterAdapter.kt index 6f5b741..1a98a87 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/routeradapters/OSIABWebViewRouterAdapter.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/routeradapters/OSIABWebViewRouterAdapter.kt @@ -4,13 +4,13 @@ import android.content.Context import android.content.Intent import android.os.Bundle import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents +import com.outsystems.plugins.inappbrowser.osinappbrowserlib.RequiresEventBridgeRegistration import com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers.OSIABFlowHelperInterface import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABWebViewOptions import com.outsystems.plugins.inappbrowser.osinappbrowserlib.views.OSIABWebViewActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import java.lang.ref.WeakReference import java.util.UUID class OSIABWebViewRouterAdapter( @@ -38,30 +38,41 @@ class OSIABWebViewRouterAdapter( const val CUSTOM_HEADERS_EXTRA = "CUSTOM_HEADERS_EXTRA" } - private var webViewActivityRef: WeakReference? = null + private var isFinished = false - private fun setWebViewActivity(activity: OSIABWebViewActivity?) { - webViewActivityRef = if (activity == null) { - null - } else { - WeakReference(activity) + private fun finalizeBrowser() { + if (!isFinished) { + isFinished = true + onBrowserFinished() + OSIABEvents.unregisterReceiver(context) } } - private fun getWebViewActivity(): OSIABWebViewActivity? { - return webViewActivityRef?.get() - } - + /** + * Closes the WebView by sending a broadcast to the separate process. + * The WebView activity will receive this and call finish() on itself. + */ + @OptIn(RequiresEventBridgeRegistration::class) override fun close(completionHandler: (Boolean) -> Unit) { - getWebViewActivity().let { activity -> - if(activity == null) { - completionHandler(false) - } - else { - activity.finish() - setWebViewActivity(null) - onBrowserFinished() + if (isFinished) { + completionHandler(true) + return + } + + // Send close broadcast to the WebView process + val closeIntent = Intent(OSIABEvents.ACTION_CLOSE_WEBVIEW).apply { + setPackage(context.packageName) + putExtra(OSIABEvents.EXTRA_BROWSER_ID, browserId) + } + context.sendBroadcast(closeIntent) + + // Listen for the BrowserFinished event to confirm close + var closeJob: Job? = null + closeJob = flowHelper.listenToEvents(browserId, lifecycleScope) { event -> + if (event is OSIABEvents.BrowserFinished) { + finalizeBrowser() completionHandler(true) + closeJob?.cancel() } } } @@ -71,23 +82,23 @@ class OSIABWebViewRouterAdapter( * @param url URL to be opened. * @param completionHandler The callback with the result of opening the url. */ + @OptIn(RequiresEventBridgeRegistration::class) override fun handleOpen(url: String, completionHandler: (Boolean) -> Unit) { lifecycleScope.launch { try { // Collect the browser events + OSIABEvents.registerReceiver(context) var eventsJob: Job? = null eventsJob = flowHelper.listenToEvents(browserId, lifecycleScope) { event -> when (event) { is OSIABEvents.OSIABWebViewEvent -> { - setWebViewActivity(event.activity) completionHandler(true) } is OSIABEvents.BrowserPageLoaded -> { onBrowserPageLoaded() } is OSIABEvents.BrowserFinished -> { - setWebViewActivity(null) - onBrowserFinished() + finalizeBrowser() eventsJob?.cancel() } is OSIABEvents.BrowserPageNavigationCompleted -> { @@ -113,6 +124,7 @@ class OSIABWebViewRouterAdapter( ) } catch (e: Exception) { + finalizeBrowser() completionHandler(false) } } diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt index 59ec090..d75999e 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt @@ -2,16 +2,18 @@ package com.outsystems.plugins.inappbrowser.osinappbrowserlib.views import android.Manifest import android.app.Activity +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager +import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.MediaStore import android.util.Log import android.view.Gravity -import android.graphics.Bitmap import android.view.View import android.webkit.CookieManager import android.webkit.GeolocationPermissions @@ -39,7 +41,6 @@ import androidx.core.content.FileProvider import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents -import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents.OSIABWebViewEvent import com.outsystems.plugins.inappbrowser.osinappbrowserlib.R import com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers.OSIABPdfHelper import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABToolbarPosition @@ -69,6 +70,8 @@ class OSIABWebViewActivity : AppCompatActivity() { private lateinit var appName: String private lateinit var browserId: String + private var closeReceiver: BroadcastReceiver? = null + // for the browserPageLoaded event, which we only want to trigger on the first URL loaded in the WebView private var isFirstLoad = true @@ -148,6 +151,15 @@ class OSIABWebViewActivity : AppCompatActivity() { return File.createTempFile("${prefix}${timeStamp}_", suffix, storageDir) } + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + WebView.setDataDirectorySuffix("OSInAppBrowser") + } catch (e: Exception) { + Log.d(LOG_TAG, "Suffix already set or error: ${e.message}") + } + } + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -164,7 +176,24 @@ class OSIABWebViewActivity : AppCompatActivity() { browserId = intent.getStringExtra(OSIABEvents.EXTRA_BROWSER_ID) ?: "" - sendWebViewEvent(OSIABWebViewEvent(browserId, this@OSIABWebViewActivity)) + // Register receiver for close commands from main process + closeReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val targetBrowserId = intent?.getStringExtra(OSIABEvents.EXTRA_BROWSER_ID) + if (targetBrowserId == browserId) { + finish() + } + } + } + val filter = IntentFilter(OSIABEvents.ACTION_CLOSE_WEBVIEW) + ContextCompat.registerReceiver( + this, + closeReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + + sendWebViewEvent(OSIABEvents.OSIABWebViewEvent(browserId)) appName = applicationInfo.loadLabel(packageManager).toString() @@ -241,6 +270,14 @@ class OSIABWebViewActivity : AppCompatActivity() { } override fun onDestroy() { + closeReceiver?.let { + try { + unregisterReceiver(it) + } catch (e: Exception) { + // Receiver may not be registered, ignore + } + closeReceiver = null + } webView.destroy() super.onDestroy() } @@ -916,7 +953,7 @@ class OSIABWebViewActivity : AppCompatActivity() { */ private fun sendWebViewEvent(event: OSIABEvents) { lifecycleScope.launch { - OSIABEvents.postEvent(event) + OSIABEvents.broadcastEvent(this@OSIABWebViewActivity, event) } }