Skip to content

Bubblewrap/LauncherActivity will not work with post messages #510

@morgangrubb

Description

@morgangrubb

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions