GoogleChromeLabs / bubblewrap

Bubblewrap is a Command Line Interface (CLI) that helps developers to create a Project for an Android application that launches an existing Progressive Web App (PWAs) using a Trusted Web Activity.
Apache License 2.0
2.29k stars 152 forks source link

Consistent Google Play Billing error in TWA: clientAppUnavailable (Android 13, API 33 and above) #805

Open monstermac77 opened 1 year ago

monstermac77 commented 1 year ago

We recently released our TWA (our app) to customers and on day 1 are experiencing a very consistent issue with Google Play Billing. When we try to call getDetails() on a SKU as well as when we call listPurchases(), we receive a "DOMException: clientAppUnavailable", and the promise fails. Here are the tracebacks:

image (4) image (3)

We are confident though that Play Services are being initialized:

image (2)

After a lot of debugging, our current lead is that the issue may be with our Delegation Service. On Android 11, the Delegation Service runs and the extra command handler is registered successfully. On Android 13, the Delegation Service fails to run and a clientAppUnavailable DOM exception is raised. Below are all the files we believe are relevant:

web_app_manifest.json

{
  "packageId": "com.coursicle.coursicle",
  "host": "daniel.coursicle.com",
  "short_name":"Coursicle",
  "enableNotifications": true,
  "features": {
    "playBilling": {
      "enabled": true
    }
  },
  "alphaDependencies": {
    "enabled": true
  },
  "name":"Coursicle | Plan your schedule and get into classes",
  "start_url":"/?pwa=true",
  "background_color":"#ffffff",
  "display":"standalone",
  "theme_color":"#ffffff",
  "icons":[{"src":"/homepage/img/coursicleCLogo512.png",
    "sizes":"512x512",
    "type":"image/png",
    "purpose":"any"}],
  "screenshots":[{"src":"/homepage/img/screenshot1.png","type":"image/png"},
    {"src":"/homepage/img/screenshot2.png","type":"image/png"},
    {"src":"/homepage/img/screenshot3.png","type":"image/png"},
    {"src":"/homepage/img/screenshot4.png","type":"image/png"},
    {"src":"/homepage/img/screenshot5.png","type":"image/png"}]
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <!--package="com.coursicle.coursicle" >-->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="com.android.vending.BILLING" />
    <uses-permission android:name="android.permission.VIBRATE" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="com.google.android.gms.permission.AD_ID" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

    <application
        android:name="CoursicleApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher"
        android:supportsRtl="true"
        android:theme="@style/AppTheme"
        android:manageSpaceActivity="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
        android:backupAgent=".MyBackupAgent">

        <meta-data android:name="com.google.android.backup.api_key"
                   android:value="[redacted]" />

        <!-- PWA Stuff -->
        <meta-data
            android:name="asset_statements"
            android:resource="@string/assetStatements" />

        <meta-data
            android:name="web_manifest_url"
            android:value="@string/webManifestUrl" />

        <meta-data
            android:name="twa_generator"
            android:value="@string/generatorApp" />

        <activity android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity">
            <meta-data
                android:name="android.support.customtabs.trusted.MANAGE_SPACE_URL"
                android:value="@string/launchUrl" />
        </activity>

        <!--android:alwaysRetainTaskState="true"-->
        <activity android:name="LauncherActivity"
            android:label="@string/launcherName"
            android:exported="true"
            android:supportsRtl="true">

            <meta-data android:name="android.support.customtabs.trusted.DEFAULT_URL"
                android:value="@string/launchUrl" />
            <meta-data android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
                android:resource="@color/navigationColor" />
            <meta-data android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
                android:resource="@color/navigationColor" />
            <meta-data android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
                android:resource="@color/navigationColorDark" />
            <meta-data android:name="androix.browser.trusted.NAVIGATION_BAR_DIVIDER_COLOR"
                android:resource="@color/navigationDividerColor" />
            <meta-data android:name="androix.browser.trusted.NAVIGATION_BAR_DIVIDER_COLOR_DARK"
                android:resource="@color/navigationDividerColorDark" />
            <meta-data android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
                android:resource="@mipmap/ic_launcher"/>
            <meta-data android:name="android.support.customtabs.trusted.SPLASH_SCREEN_BACKGROUND_COLOR"
                android:resource="@color/backgroundColor"/>
            <meta-data android:name="android.support.customtabs.trusted.SPLASH_SCREEN_FADE_OUT_DURATION"
                android:value="@integer/splashScreenFadeOutDuration"/>
            <meta-data android:name="android.support.customtabs.trusted.FILE_PROVIDER_AUTHORITY"
                android:value="@string/providerAuthority"/>
            <!--meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" /-->
            <meta-data android:name="android.support.customtabs.trusted.FALLBACK_STRATEGY"
                android:value="@string/fallbackType" />
            <meta-data android:name="android.support.customtabs.trusted.SCREEN_ORIENTATION"
                android:value="@string/orientation"/>

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW"/>
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE"/>
                <data android:host="daniel.coursicle.com"
                android:scheme="https" />
            </intent-filter>
        </activity>

        <activity android:name="com.google.androidbrowserhelper.trusted.FocusActivity" />

        <activity android:name="com.google.androidbrowserhelper.trusted.WebViewFallbackActivity"
            android:configChanges="orientation|screenSize" />

        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="@string/providerAuthority"
            android:grantUriPermissions="true"
            android:exported="false">

            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/filepaths" />
        </provider>

        <service
            android:name=".DelegationService"
            android:enabled="true"
            android:exported="true">

            <meta-data
                android:name="android.support.customtabs.trusted.SMALL_ICON"
                android:resource="@mipmap/ic_launcher" />

            <intent-filter>
                <action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE"/>
                <category android:name="android.intent.category.DEFAULT"/>
            </intent-filter>

            <!--
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            -->
        </service>
        <activity
            android:name="com.google.androidbrowserhelper.playbilling.provider.PaymentActivity"
            android:theme="@android:style/Theme.Translucent.NoTitleBar"
            android:configChanges="keyboardHidden|keyboard|orientation|screenLayout|screenSize"
            android:exported="true">
            <intent-filter>
                <action android:name="org.chromium.intent.action.PAY" />
            </intent-filter>
            <meta-data
                android:name="org.chromium.default_payment_method_name"
                android:value="https://play.google.com/billing" />
        </activity>
        <!-- This service checks who calls it at runtime. -->
        <service
            android:name="com.google.androidbrowserhelper.playbilling.provider.PaymentService"
            android:exported="true" >
            <intent-filter>
                <action android:name="org.chromium.intent.action.IS_READY_TO_PAY" />
            </intent-filter>
        </service>
    </application>
</manifest>

build.gradle(:app)

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

android {
    namespace 'com.coursicle.coursicle'
    signingConfigs {
        debug {
            storeFile file('Coursicle.jks')
            storePassword '[redacted]'
            keyAlias '[redacted]'
            keyPassword '[redacted]'
        }
    }
    compileSdkVersion 33
    defaultConfig {
        applicationId "com.coursicle.coursicle"
        multiDexEnabled true
        minSdkVersion 21
        targetSdkVersion 33
        versionCode 58 // TODO [push]: increment this before generating the APK
        versionName "3.1" // TODO [push]: increment this before generating the APK
        multiDexEnabled true
        testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.debug
        }
        debug {
            signingConfig signingConfigs.debug
        }
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    buildFeatures {
        viewBinding true
    }

    dataBinding{
        enabled = true
    }
}

dependencies {
    implementation 'com.google.androidbrowserhelper:billing:1.0.0-alpha09'
    implementation 'com.google.android.material:material:1.3.0' // needed for app theme
    implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.4.0'

    // why?
    implementation 'com.android.support:multidex:1.0.1'
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    // Which of these do we really need now?
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
    implementation 'androidx.appcompat:appcompat:1.5.1'
    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
    //testImplementation 'junit:junit:4.12'
    //androidTestImplementation 'androidx.test.ext:junit:1.1.1'
    //androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
    def fuel_version = "2.3.1"
    implementation "com.github.kittinunf.fuel:fuel:$fuel_version"
    implementation "com.github.kittinunf.fuel:fuel-android:$fuel_version"
}

apply plugin: 'com.google.gms.google-services'

DelegationService.kt

package com.coursicle.coursicle
import com.google.androidbrowserhelper.playbilling.digitalgoods.DigitalGoodsRequestHandler
import com.google.androidbrowserhelper.trusted.DelegationService
class DelegationService : DelegationService() {
    override fun onCreate() {
        super.onCreate()
        Log.d("delegationService",getApplicationContext().toString())
        registerExtraCommandHandler(DigitalGoodsRequestHandler(getApplicationContext()))
    }
}

manifest.json (on our server)

{

  "packageId": "com.coursicle.coursicle",
  "host": "daniel.coursicle.com",
  "short_name":"Coursicle",
  "enableNotifications": true,
  "features": {
    "playBilling": {
      "enabled": true
    }
  },
  "alphaDependencies": {
    "enabled": true
  },
  "name":"Coursicle",
  "start_url":"/?pwa=true", 
  "background_color":"#ffffff",
  "display":"standalone",
  "orientation": "portrait",
  "theme_color":"#ffffff",
  "icons":[{"src":"/homepage/img/coursicleCLogoLarge.png",
      "sizes":"512x512",
      "type":"image/png",
      "purpose":"any"}]
}

purchase.js

// https://developer.chrome.com/docs/android/trusted-web-activity/receive-payments-play-billing/
window.initBilling = function(){
    window.billingService
    window.hostSite = window.location.host.split(".")[0];
    $(document).ready(function(){
        window.billingSemester = $('#semesterSelect').val();
    });

    // Confirms that google billing is available
    // Should only be enabled if user has logged into their google play account
    // Gets details for current semester product
    // Updates UI to reflect details
    var googleBilling = async function(){
        if ('getDigitalGoodsService' in window) {
            // Digital Goods API is supported!
            try {
                window.billingService = await window.getDigitalGoodsService('https://play.google.com/billing');

                // Get details for most relevant product
                var skuDetailFun = async function(){

                    var prodToShow = ""

                    if (window.hostSite == "www"){
                        prodToShow = "com.coursicle.coursicle."+window.billingSemester+"premium"
                    } else {
                        prodToShow = "dev.coursicle.coursicle."+window.billingSemester+"premium"
                    }           

                    console.log(prodToShow);
                    var skuDetails = await window.billingService.getDetails([prodToShow]);

                    // There should only be one product in the return object
                    if (!hasPurchasedPremium()){
                        for (var index in skuDetails) {
                            var item = skuDetails[index]
                            // Format the price according to the user locale.
                            const localizedPrice = new Intl.NumberFormat(
                                navigator.language,
                                {style: 'currency', currency: item.price.currency}
                            ).format(item.price.value);
                            $("#premiumButton").data('price', localizedPrice)
                            $("#premiumButton").text(localizedPrice)
                        }   
                    }
                }

                skuDetailFun();

                // Check and redeem purchases
                // TODO-Miguel check and acknowledge in local storage
                const existingPurchases = await window.billingService.listPurchases();

                const userData = store.get('userData')

                const premium = userData["premium"]
                var relevantPremium = ""
                for (const sem in premium){
                    if (sem == window.billingSemester){
                        relevantPremium = sem
                    }
                }

                //hasPurchasedPremium()

                if (existingPurchases.length != 0 && relevantPremium != "" ) {
                    for (const p in existingPurchases) {
                        // TODO-Miguel comment out consume for prod
                        if (window.hostSite=="miguel") { 
                            //window.billingService.consume(existingPurchases[p].purchaseToken)
                            //break;
                        }

                        // Update the UI with items the user is already entitled to.
                        var prodToShow = ""

                        if (window.hostSite == "www"){
                            prodToShow = "com.coursicle.coursicle."+window.billingSemester+"premium"
                        } else {
                            prodToShow = "dev.coursicle.coursicle."+window.billingSemester+"premium"
                        }           

                        if (existingPurchases[p].itemId == prodToShow) {
                            // TODO-Miguel Add expiration date to settings screen
                            //$('#premiumButton').text("Purchased")
                            //$('#premiumButton').css("background-color","green")
                            var term = window.billingSemester.substring(0,window.billingSemester.length-4)
                            var year = window.billingSemester.substring(window.billingSemester.length-4)
                            var expirationDate = ""
                            if (term=="fall"){
                                expirationDate = "October"
                            } else if (term=="spring"){
                                expirationDate = "March"
                            } else if (term=="winter"){
                                expirationDate = "February"
                            }
                            $("#premiumSetting").find(".settingsValue").text("Expires " + expirationDate + " " + year)
                        }
                    }
                }

            } catch (error) {
                console.log("Google Play Billing is not available. Use another payment flow.", error);
                return;
            }
        }
    }

    // Execute google billing to get product details and accept payment
    googleBilling();

}

// MAKE SURE you go to "chrome://flags/" and enabling billing for test devices
// This function is used to process payments for premium using the google billing API
async function makePurchase(sku) {
    // Define the preferred payment method and item ID
    const paymentMethods = [{
       supportedMethods: ["https://play.google.com/billing"],
       data: {
           sku: sku,
       },
    }];

    var request = new PaymentRequest(paymentMethods);

    // launch purchase pop-up
    try {   
        const paymentResponse = await request.show();
        const {purchaseToken} = paymentResponse.details;
        const paymentComplete = await paymentResponse.complete('success');
        var currentSemesterPurchased = true
    } catch (error) {
        console.log(error)
        if (error.message.includes('was cancelled')) {
            // User dismissed native dialog
            logWarning('User chose not to subscribe:', error);
        } else {
            // Report unexpected error
            reportError(error, 'PaymentRequest.show() failed');

            $('#premiumButton').text($('#premiumButton').data('price'))
            $('#premiumSpinner').hide()
        }
        var currentSemesterPurchased = false
    }

    // Check and redeem purchases
    try {
        const existingPurchases = await window.billingService.listPurchases();
        for (purchase in existingPurchases) {   // TODO-Miguel check against storage and user data
            if (purchase.itemId == sku) {
                currentSemesterPurchased = true
            }
        }
    }
    catch (error) {
        console.log("billingService error", error)
    }

    if (currentSemesterPurchased) {
        $('#premiumSpinner').hide()
        $('#premiumButton').text("Purchased")
        $('#premiumButton').css("background-color","#4ea83c")       
        // Update the UI with items the user is already entitled to.
        // TODO-Miguel Add expiration date to settings screen       
        var term = window.billingSemester.substring(0,window.billingSemester.length-4)
        var year = window.billingSemester.substring(window.billingSemester.length-4)
        var expirationDate = ""

        if (term == "fall") {
            expirationDate = "October"
        } 
        else if (term == "spring") {
            expirationDate = "March"
        } 
        else if (term == "winter") {
            expirationDate = "February"
        }
        $("#premiumSetting").find(".settingsValue").text("Expires " + expirationDate + " " + year)

        var userData = store.get('userData')

        var purchases = userData["premium"]

        if (purchases == null) {
            userData["premium"] = []
        }

        var premiumObj = {}
        var billingSemester = window.billingSemester
        premiumObj[billingSemester] = "purchased"
        userData["premium"].push(premiumObj)

        // make explicit change to server userData
        setUserData(uuid=store.get("uuid"), deviceID=null, token=null, school=null, userDataJsonString=JSON.stringify(userData))
        store.set("userData", userData)
    }

    setTimeout(function(){
        hideSlidableModal()
    },3000);
}  

$(document).on('click', '#premiumButton', function(){
    var prodToShow = ""

    if (window.hostSite == "www"){
        prodToShow = "com.coursicle.coursicle."+window.billingSemester+"premium"
    } else {
        prodToShow = "dev.coursicle.coursicle."+window.billingSemester+"premium"
    }

    $('#premiumButton').text('Confirming...')
    $('#premiumSpinner').show()
    makePurchase(prodToShow)
})

Here's our device information:

Here's a comprehensive list of everything we've tried so far:

It seems like others have encountered this issue as well, although any fix they found did not work for us, and they in general were targeting older SDK versions:

Thank you so much for any assistance you can provide. We're really excited about our new PWA and this is the only major issue we've encountered during our conversion from native.

andreban commented 11 months ago

This sounds like an issue more appropriate to file at https://github.com/GoogleChrome/android-browser-helper/ or bugs.chromium.org.

bulbigood commented 4 months ago

I found the solution for this error: https://github.com/GoogleChromeLabs/bubblewrap/issues/640#issuecomment-2020384965 Non-obvious behavior and lack of understandable logs. I don't know where the problem lies, maybe in Chrome Custom Tabs.