Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
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).

## [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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.ionic.libs</groupId>
<artifactId>ioninappbrowser-android</artifactId>
<version>1.6.1</version>
<version>1.7.0</version>
</project>
1 change: 1 addition & 0 deletions src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<activity
android:name=".views.OSIABWebViewActivity"
android:exported="false"
android:process=":OSInAppBrowser"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make sure plugins (OutSystems especially) document this change, wouldn't be surprised if it ends up breaking existing Android apps in some unexpected way, due to the many different ways people use InAppBrowser.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, doesn't this mean that existing apps that have cookies or local storage data for a certain webpage in InAppBrowser, will no longer have that data when they update the app to this version where the data directory changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, it may break existing apps that use InAppBrowser.. However, if we leave the current behavior then it may be a security flaw because we shouldn't give the browser access to the main app's data. So we have 2 options:

  1. "Always Isolated". This fixes the security bug and also currently the plugin behaves differently on both platforms, which can lead to bugs in the app's logic.. so it makes Android behavior match iOS. Storage will persist as normal after the first run.

Document a warning saying something like (Thanks AI for the docs help 😅):

Warning

Breaking Change (Android): Apps upgrading to this version will lose any existing localStorage or cookies previously stored by the InAppBrowser. This is because the WebView now runs in a separate process with its own data directory. Users may need to re-authenticate with websites that relied on persisted session data.

  1. "Opt-in" Isolation. Keep the current shared behavior as default, but add an isolateStorage config option. Best for backwards compatibility and existing data, but not the best security practice.

If you think there will be a bigger impact on customers with the first approach, then we could maybe do "opt-in" for this version and then have it always isolated for the next major version. Thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this raises security concerns then I don't think we want an opt-in.

We should just be careful on releasing this and informing customers, to make sure impacts are minimized. Hard to say on how many customers could be impacted, due to the myriad of ways and webpages and use cases you can have for InAppBrowser.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. For example, some customers try to use the InAppBrowser to perform login flows (which they shouldn't, but they do).

android:configChanges="orientation|screenSize|uiMode"
android:label="OSIABWebViewActivity"
android:theme="@style/AppTheme.WebView"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,126 @@
package com.outsystems.plugins.inappbrowser.osinappbrowserlib

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.views.OSIABWebViewActivity
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.io.Serializable

sealed class OSIABEvents {
@RequiresOptIn(
level = RequiresOptIn.Level.WARNING,
message = "This API requires a prior call to OSIABEvents.registerReceiver(context) to work correctly with process isolation on Android 9+."
)
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class RequiresEventBridgeRegistration

sealed class OSIABEvents : Serializable {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels confusing for this class to be Serializable, subclasses have Context and Activity which are not serializable. The serialization is only relevant for the broadcast receiver, which is only called for webview, but from what I see in the PR you don't set OSIABWebViewActivity anymore for OSIABWebViewEvent.

Can you explain why you implemented it that way?

Copy link
Contributor Author

@ItsChaceD ItsChaceD Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that Activity and Context cannot be serialized, but it is a hard requirement for it to be Parcelable / Serializable since we are sending the object between processes via an Intent.

fun broadcastEvent(context: Context, event: OSIABEvents) {
  val intent = Intent(ACTION_IAB_EVENT).apply {
      setPackage(context.packageName)
      putExtra(EXTRA_EVENT_DATA, event)
  }
  context.sendBroadcast(intent)
}

I addressed this by marking those specific fields as @Transient.

abstract val browserId: String

data class BrowserPageLoaded(override val browserId: String) : OSIABEvents()
data class BrowserFinished(override val browserId: String) : OSIABEvents()
data class BrowserPageNavigationCompleted(override val browserId: String, val url: String?) : OSIABEvents()

data class OSIABCustomTabsEvent(
override val browserId: String,
val action: String,
val context: Context
@Transient val context: Context? = null
) : OSIABEvents()

data class OSIABWebViewEvent(
override val browserId: String,
val activity: OSIABWebViewActivity
@Transient val activity: OSIABWebViewActivity? = null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I saw here, we never set activity anymore when instantiating this event. If you can confirm on your end - if so we can remove this property from the class mayhaps?

) : OSIABEvents()

companion object {
const val EXTRA_BROWSER_ID = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EXTRA_BROWSER_ID"
const val ACTION_IAB_EVENT = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.ACTION_IAB_EVENT"
const val ACTION_CLOSE_WEBVIEW = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.ACTION_CLOSE_WEBVIEW"
const val EXTRA_EVENT_DATA = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EXTRA_EVENT_DATA"

private val _events = MutableSharedFlow<OSIABEvents>()
// Buffer capacity is required because BroadcastReceiver.onReceive() is synchronous.
// We must use tryEmit() which would drop events without buffer space.
private val _events = MutableSharedFlow<OSIABEvents>(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)
}
}

}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:OptIn(com.outsystems.plugins.inappbrowser.osinappbrowserlib.RequiresEventBridgeRegistration::class)

package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers

import android.content.ComponentName
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:OptIn(com.outsystems.plugins.inappbrowser.osinappbrowserlib.RequiresEventBridgeRegistration::class)

package com.outsystems.plugins.inappbrowser.osinappbrowserlib.routeradapters

import android.content.Context
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -38,30 +38,41 @@ class OSIABWebViewRouterAdapter(
const val CUSTOM_HEADERS_EXTRA = "CUSTOM_HEADERS_EXTRA"
}

private var webViewActivityRef: WeakReference<OSIABWebViewActivity>? = 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()
}
Comment on lines +62 to 76
Copy link
Collaborator

@OS-pedrogustavobilro OS-pedrogustavobilro Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we change the order, i.e. first listen to events, and only after send the broadcast?

Sending the broadcast isn't instantaneous I guess, but because the events flow is a SharedFlow (i.e., a hot flow), it will not wait to have producers to start emitting, meaning that if theory the flow gets updated before we start collecting, the item emission will get lost. Reversing the order would make sure that does not happen.

Unless you had issues with the reverse order.

}
}
Expand All @@ -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 -> {
Expand All @@ -113,6 +124,7 @@ class OSIABWebViewRouterAdapter(
)

} catch (e: Exception) {
finalizeBrowser()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we call finalizeBrowser here? Maybe it's harmless, but I'm thinking of a scenario where there's an exception and the IAB doesn't open to begin with, but finalizeBrowser will call onBrowserFinished under the hood, of which I'm not 100% certain if it's what we want.

Your thoughts?

completionHandler(false)
}
}
Expand Down
Loading
Loading