android-browser-helper
android-browser-helper copied to clipboard
Consistent Google Play Billing error in TWA: clientAppUnavailable (Android 13, API 33 and above)
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:
We are confident though that Play Services are being initialized:
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:
- https://stackoverflow.com/questions/74154396/why-does-the-digital-goods-apis-getservice-method-reject-with-clientappunavaila
- https://github.com/GoogleChromeLabs/bubblewrap/issues/640#issue-1105007499
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.
try updating dependencies
implementation 'com.google.androidbrowserhelper:billing:1.0.0-alpha10'
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0'
We are on the above specified versions and we continue to get clientAppUnavailable error. @monstermac77 are you able to fix it?
@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.