-
Notifications
You must be signed in to change notification settings - Fork 336
Description
Problem
There is currently no way to use post messages with LauncherActivity
as used by a Bubblewrap generated app.
The provided demo code uses a completely different structure to launch the TWA and it's not clear how to convert Bubblewrap generated code to use this different structure or exactly what is lost by no longer using LauncherActivity
/TwaLauncher
.
The solution so far
Here's an implementation that almost works:
package com.example.app
import android.content.ComponentName
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcel
import android.os.RemoteException
import android.util.Log
import androidx.browser.customtabs.CustomTabsCallback
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsServiceConnection
import androidx.browser.customtabs.CustomTabsSession
import com.google.androidbrowserhelper.trusted.LauncherActivity
import com.google.androidbrowserhelper.trusted.QualityEnforcer
import com.google.androidbrowserhelper.trusted.TwaLauncher
class LauncherActivity : LauncherActivity() {
private var mSession: CustomTabsSession? = null
private var mValidated = false
private var logTag = "Example"
private var mLauncherActivity: LauncherActivity? = null
override fun onCreate(savedInstanceState: Bundle?) {
mLauncherActivity = this
super.onCreate(savedInstanceState)
val packageName = CustomTabsClient.getPackageName(this, null)
CustomTabsClient.bindCustomTabsServicePreservePriority(
this,
packageName,
customTabsServiceConnection
)
// Setting an orientation crashes the app due to the transparent background on Android 8.0
// Oreo and below. We only set the orientation on Oreo and above. This only affects the
// splash screen and Chrome will still respect the orientation.
// See https://github.com/GoogleChromeLabs/bubblewrap/issues/496 for details.
requestedOrientation = if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
override fun onDestroy() {
mSession = null
mLauncherActivity = null
baseContext.unbindService(customTabsServiceConnection)
super.onDestroy()
}
private val customTabsServiceConnection = object : CustomTabsServiceConnection() {
override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
client.warmup(0L)
}
override fun onServiceDisconnected(name: ComponentName) {}
}
private val customTabsCallback: CustomTabsCallback = object : QualityEnforcer() {
override fun onPostMessage(message: String, extras: Bundle?) {
super.onPostMessage(message, extras)
Log.d(logTag, "Got message: $message")
}
override fun onRelationshipValidationResult(
relation: Int, requestedOrigin: Uri,
result: Boolean, extras: Bundle?
) {
mValidated = result
}
// Listens for navigation, requests the postMessage channel when one completes.
override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
// Waiting for navigation finished _should_ mean that the pwa is running before we
// continue.
if (navigationEvent != NAVIGATION_FINISHED) {
return
}
//
// Here we use java reflection to get the mSession from mTwaLauncher, as set in the
// parent LauncherActivity class.
//
// This is very much beyond being a bad idea. If you're using reflection,
// you're probably doing it wrong.
//
if (mLauncherActivity != null) {
val twaLauncherField =
mLauncherActivity?.javaClass?.superclass?.getDeclaredField("mTwaLauncher")
twaLauncherField?.isAccessible = true
val mTwaLauncher = twaLauncherField?.get(mLauncherActivity) as? TwaLauncher
if (mTwaLauncher != null) {
val sessionField = mTwaLauncher?.javaClass?.getDeclaredField("mSession")
sessionField?.isAccessible = true
mSession = sessionField?.get(mTwaLauncher) as? CustomTabsSession
}
}
if (mSession == null) {
return
}
//
// End reflection nonsense.
//
val result = mSession!!.requestPostMessageChannel(getLaunchingUrl())
Log.d(logTag, "Requested Post Message Channel: $result")
}
override fun onMessageChannelReady(extras: Bundle?) {
Log.d(logTag, "Message channel ready.")
if (mSession == null) {
return
}
mSession.postMessage("hello", null)
}
}
override fun getCustomTabsCallback(): CustomTabsCallback {
return customTabsCallback
}
}
customTabsCallback
inherits from QualityEnforcer
in order to retain existing behaviour and getCustomCallback
is overridden to return our custom tabs callback object.
On it's own, this should be enough as warmup is already called on the client object in TwaLauncher
however if we leave it at that we never get navigation events. To receive those we also have to call CustomTabsClient.bindCustomTabsService(customTabsServiceConnection)
which calls warmup again and absolutely nothing else. With that in place we receive navigation events and are able to send and receive post messages.
Among the many problems, and they are many, are that it requires reflection to pull the mSession
value from TwaLauncher
and that it also errors out with the following whenever the app is dismissed:
2025-05-06 13:42:02.117 4383-4394 IPCThreadState com.example.app E Binder transaction failure. id: 124609, BR_*: 29189, error: -22 (Invalid argument)
2025-05-06 13:42:02.118 4383-4394 JavaBinder com.example.app E *** Uncaught remote exception! Exceptions are not yet supported across processes. Client PID 2511 UID 10146. (Ask Gemini)
java.lang.RuntimeException: android.os.DeadObjectException
at android.os.Parcel.writeException(Parcel.java:3012)
at android.os.Binder.execTransactInternal(Binder.java:1524)
at android.os.Binder.execTransact(Binder.java:1444)
Caused by: android.os.DeadObjectException
at android.os.BinderProxy.transactNative(Native Method)
at android.os.BinderProxy.transact(BinderProxy.java:586)
at android.support.customtabs.ICustomTabsCallback$Stub$Proxy.onMessageChannelReady(ICustomTabsCallback.java:259)
at androidx.browser.customtabs.PostMessageService$1.onMessageChannelReady(PostMessageService.java:40)
at android.support.customtabs.IPostMessageService$Stub.onTransact(IPostMessageService.java:70)
at android.os.Binder.execTransactInternal(Binder.java:1500)
at android.os.Binder.execTransact(Binder.java:1444)
onDestroy
on LauncherActivity
is called and then, sometime later, a TAB_HIDDEN
navigation event is passed to CustomTabsCallback.onNavigationEvent
and then the preceding error is thrown. Once thrown, the app is unable to send or receive post messages on any future launch.
I have not been able to find any way to unbind services or callbacks to avoid this happening.
Solutions
Ideally, someone who knows better will look at this and point out the obvious thing I'm missing that stops DeadObjectException
from being thrown.
Failing that, perhaps someone can write a demo showing how to integrate post messages with Bubblewrap/LauncherActivity
.