TWA 中一致的 Google Play 结算错误:clientAppUnavailable(Android 13、API 33 及更高版本)

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

提问人:monstermac77 提问时间:6/22/2023 更新时间:8/26/2023 访问量:154

问:

我们最近向客户发布了 TWA(我们的应用),在第 1 天,Google Play 结算服务就遇到了非常一致的问题。当我们尝试调用 SKU 以及调用时,我们会收到“DOMException: clientAppUnavailable”,并且承诺失败。以下是回溯:getDetails()listPurchases()

image (4) image (3)

不过,我们确信 Play 服务正在初始化:

image (2)

经过大量调试,我们目前的线索是问题可能出在我们的委托服务上。在 Android 11 上,委派服务会运行,并且额外的命令处理程序已成功注册。在 Android 13 上,委派服务无法运行,并引发 clientAppUnavailable DOM 异常。以下是我们认为相关的所有文件:

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(:应用)

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'

代表团服务.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(在我们的服务器上)

{
  
  
  "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"}]
}

购买.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)
})

以下是我们的设备信息:

  • 设备:Galaxy A03s(工作)

    • 操作系统: Android 11
    • 已安装浏览器:Chrome
    • 浏览器版本: Chrome 114.0.5735.131
    • android-browser-helper 库版本:2.4.0
  • 设备:Galaxy S22 Ultra(不工作)

    • 操作系统: Android 13
    • 已安装浏览器:Chrome
    • 浏览器版本: Chrome 114.0.5735.130
    • android-browser-helper 库版本:2.4.0

以下是我们迄今为止尝试过的所有内容的完整列表:

  • 清除 Google Play 商店缓存
  • 将 build.gradle 中的 targetSdkVersion 从 33 递增到 34。
  • 确保将com.android.vending.BILLING权限添加到AndroidManifest.xml
  • 确保 Google Play 服务是最新的。
  • 与 PWA 计费指南进行了逐行比较 (https://chromeos.dev/en/publish/pwa-play-billing)

似乎其他人也遇到了这个问题,尽管他们发现的任何修复都对我们不起作用,而且他们通常针对较旧的 SDK 版本:

非常感谢您提供的任何帮助。我们对新的 PWA 感到非常兴奋,这是我们在从本机转换过程中遇到的唯一主要问题。

渐进式网络应用 play-billing-library trusted-web-activity google-play-billing bubblewrap

评论


答:

-3赞 Alexander 8/26/2023 #1

我找到了解决方案。 问题出在我网站的机器人.txt文件中。它被设置为禁止来自 Google 机器人的所有页面。更改后,计费在 2 小时内开始工作。