-
Notifications
You must be signed in to change notification settings - Fork 5
fix: separate local storage between WebView processes #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feels confusing for this class to be Can you explain why you implemented it that way?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right that I addressed this by marking those specific fields as |
||
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From what I saw here, we never set |
||
| ) : 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) | ||
ItsChaceD marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) { | ||
ItsChaceD marked this conversation as resolved.
Show resolved
Hide resolved
ItsChaceD marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 |
|---|---|---|
|
|
@@ -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<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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Unless you had issues with the reverse order. |
||
| } | ||
| } | ||
|
|
@@ -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() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we call Your thoughts? |
||
| completionHandler(false) | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
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
localStorageor 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.isolateStorageconfig 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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).