Reputation: 330
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:
{
"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"}]
}
<?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>
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'
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()))
}
}
{
"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"}]
}
// 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)
Device: Galaxy S22 Ultra (not working)
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.
Upvotes: 2
Views: 469
Reputation: 83
I faced this problem and found out that in my case the problem was linked to Deep Link intent-filter. I removed android:autoVerify and android:sspPattern options, replacing the last one with android:pathPrefix. Also, check that android:host is specified correctly.
I did a LOT of testing before finding the cause of the error.
UPD: It turned out that after I removed the autoVerify option, this error began to occur on other phones. Therefore, I duplicated the same Deep Link with the autoVerify option. Result:
<!-- Wrong deep links can throw OperationError: clientAppUnavailable -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="@string/hostName"
android:pathPrefix="@string/browserIntentPathPrefix"/>
</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:scheme="https"
android:host="@string/hostName"
android:pathPrefix="@string/browserIntentPathPrefix"/>
</intent-filter>
Upvotes: 1
Reputation: 11
I found the solution. The problem was in robots.txt file of my website. It was set up to dissallow all the pages from Google bot. After I changed it, the billing started to work within 2 hours.
Upvotes: -3