Skip to content
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

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

Open
monstermac77 opened this issue Jun 21, 2023 · 3 comments

Comments

@monstermac77
Copy link

monstermac77 commented Jun 21, 2023

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:

  • Device: Galaxy A03s (working)
    - OS: Android 11
    - Browsers Installed: Chrome
    - Browser Versions: Chrome 114.0.5735.131
    - android-browser-helper library version: 2.4.0

  • Device: Galaxy S22 Ultra (not working)
    - OS: Android 13
    - Browsers Installed: Chrome
    - Browser Versions: Chrome 114.0.5735.130
    - android-browser-helper library version: 2.4.0

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

  • Clearing the Google Play Store Cache
  • Incrementing the targetSdkVersion in build.gradle from 33 to 34.
  • Ensuring the com.android.vending.BILLING permission is added to AndroidManifest.xml
  • Ensuring that Google Play Services is up to date.
  • Done a line by line comparison with the PWA billing guide (https://chromeos.dev/en/publish/pwa-play-billing)

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.

@crosschainer
Copy link

try updating dependencies


    implementation 'com.google.androidbrowserhelper:billing:1.0.0-alpha10'
    implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0'

@ramakula
Copy link

We are on the above specified versions and we continue to get clientAppUnavailable error. @monstermac77 are you able to fix it?

@monstermac77
Copy link
Author

@ramakula we have not been able to fix it. At this point, we're looking now at switching to native push notifications for our TWA (now that postMessage support has been added, allowing easy communication between the Android wrapper and web app), and once we've done that I think we'll revive our native Android billing inside the wrapper, so that we can just use something we know works, since the support for billing in a TWA is pretty scant right now. We're probably losing 10% of Android revenue do to this.

That said, we've posted this issue all over the place, so maybe as more people adopt TWA and struggle with billing, Google will handle this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants