diff --git a/build.gradle b/build.gradle index 296bd87097..1dbe6cea7d 100644 --- a/build.gradle +++ b/build.gradle @@ -31,10 +31,11 @@ buildscript { ext.volleyVersion = '1.2.1' ext.okHttpVersion = '4.12.0' ext.ktorVersion = '2.3.12' - ext.wireVersion = '4.9.9' + ext.wireVersion = '6.2.0' ext.tinkVersion = '1.13.0' + ext.guavaVersion = '33.5.0-android' - ext.androidBuildGradleVersion = '8.2.2' + ext.androidBuildGradleVersion = '8.13.0' ext.androidBuildVersionTools = '34.0.0' @@ -64,6 +65,7 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:$androidBuildGradleVersion" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlinVersion" classpath "com.squareup.wire:wire-gradle-plugin:$wireVersion" } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 00277a3d65..70fbe7a4b3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,7 +3,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/play-services-api/build.gradle b/play-services-api/build.gradle index dc0872f7e7..56a39a328c 100644 --- a/play-services-api/build.gradle +++ b/play-services-api/build.gradle @@ -45,3 +45,28 @@ dependencies { annotationProcessor project(':safe-parcel-processor') } + +// build-tools 35.0.0 aidl.exe on Windows writes a `* Using: ` header into generated +// *.java files. For AIDL packages containing `\u` (here `\usagereporting`), javac treats +// it as an illegal Java Unicode escape per JLS 3.3 (escapes are processed even inside comments) +// and fails. Strip that line right after AIDL compilation. +// TODO: remove once build-tools is upgraded past the version with this bug. +afterEvaluate { + tasks.matching { it.name ==~ /compile.*Aidl/ }.configureEach { + doLast { task -> + task.outputs.files.each { outRoot -> + if (outRoot.exists()) { + outRoot.eachFileRecurse { javaFile -> + if (javaFile.isFile() && javaFile.name.endsWith('.java')) { + def original = javaFile.text + def stripped = original.replaceAll(/(?m)^[ \t]*\*[ \t]+Using:.*\R?/, '') + if (!original.equals(stripped)) { + javaFile.text = stripped + } + } + } + } + } + } + } +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/icing/service/IAppIndexingService.aidl b/play-services-api/src/main/aidl/com/google/android/gms/icing/service/IAppIndexingService.aidl new file mode 100644 index 0000000000..cd237f6aa8 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/icing/service/IAppIndexingService.aidl @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.icing.service; + +interface IAppIndexingService { +} \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/DeviceEnvInfo.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/deviceinfo/DeviceEnvInfo.kt similarity index 72% rename from vending-app/src/main/java/org/microg/vending/billing/core/DeviceEnvInfo.kt rename to play-services-base/core/src/main/kotlin/org/microg/gms/deviceinfo/DeviceEnvInfo.kt index 67d542926f..b23ab39944 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/DeviceEnvInfo.kt +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/deviceinfo/DeviceEnvInfo.kt @@ -1,4 +1,9 @@ -package org.microg.vending.billing.core +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.deviceinfo import java.util.Locale @@ -33,7 +38,15 @@ data class DeviceEnvInfo( val installNonMarketApps: Boolean, val uptimeMillis: Long, val timeZoneDisplayName: String, - val googleAccounts: List + val googleAccounts: List, + + val sdkVersion: String? = null, + val gmsPackageName: String? = null, + val cameraPermissionState: Int = -1, + val isInCallOrRingMode: Boolean = false, + val isUsbConnected: Boolean = false, + val isCharging: Boolean = false, + val screenBrightness: Int = -1 ) data class DisplayMetrics( @@ -49,7 +62,10 @@ data class TelephonyData( val phoneDeviceId: String, val networkOperator: String, val simOperator: String, - val phoneType: Int = -1 + val phoneType: Int = -1, + val grantedPhonePermissionState: Int = -1, + val isSmsCapable: Boolean = false, + val activeSubscriptionInfoCount: Int = 0 ) data class LocationData( diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/deviceinfo/DeviceInfoCollector.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/deviceinfo/DeviceInfoCollector.kt new file mode 100644 index 0000000000..2d319f5718 --- /dev/null +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/deviceinfo/DeviceInfoCollector.kt @@ -0,0 +1,285 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.deviceinfo + +import android.Manifest +import android.accounts.AccountManager +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.hardware.usb.UsbManager +import android.icu.util.TimeZone +import android.media.AudioManager +import android.net.ConnectivityManager +import android.os.Build.VERSION.SDK_INT +import android.os.SystemClock +import android.provider.Settings +import android.util.Base64 +import android.util.Log +import android.view.WindowManager +import androidx.core.content.ContextCompat +import org.microg.gms.auth.AuthConstants +import org.microg.gms.common.Constants +import org.microg.gms.common.DeviceIdentifier +import org.microg.gms.profile.Build +import org.microg.gms.utils.digest +import org.microg.gms.utils.toBase64 +import java.util.Locale + +private const val TAG = "DeviceInfoCollector" + +@SuppressLint("MissingPermission") +fun getDeviceIdentifier(context: Context): String { + // TODO: Improve dummy data + val deviceId = DeviceIdentifier().meid /*try { + (context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager?)?.let { + it.subscriberId ?: it.deviceId + } + } catch (e: Exception) { + null + }*/ + return deviceId.toByteArray(Charsets.UTF_8).digest("SHA-1") + .toBase64(Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING) +} + +fun getDisplayInfo(context: Context): DisplayMetrics? { + return try { + val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager? + if (windowManager != null) { + val displayMetrics = android.util.DisplayMetrics() + windowManager.defaultDisplay.getRealMetrics(displayMetrics) + return DisplayMetrics( + displayMetrics.widthPixels, + displayMetrics.heightPixels, + displayMetrics.xdpi, + displayMetrics.ydpi, + displayMetrics.densityDpi + ) + } + return DisplayMetrics( + context.resources.displayMetrics.widthPixels, + context.resources.displayMetrics.heightPixels, + context.resources.displayMetrics.xdpi, + context.resources.displayMetrics.ydpi, + context.resources.displayMetrics.densityDpi + ) + } catch (e: Exception) { + null + } +} + +// TODO: Improve privacy +fun getBatteryLevel(context: Context): Int { + var batteryLevel = -1 + val intentFilter = IntentFilter("android.intent.action.BATTERY_CHANGED") + context.registerReceiver(null, intentFilter)?.let { + val level = it.getIntExtra("level", -1) + val scale = it.getIntExtra("scale", -1) + if (scale > 0) { + batteryLevel = level * 100 / scale + } + } + if (batteryLevel == -1 && SDK_INT >= 33) { + context.registerReceiver(null, intentFilter, Context.RECEIVER_EXPORTED)?.let { + val level = it.getIntExtra("level", -1) + val scale = it.getIntExtra("scale", -1) + if (scale > 0) { + batteryLevel = level * 100 / scale + } + } + } + return batteryLevel +} + +fun getTelephonyData(context: Context): TelephonyData? { + // TODO: Dummy data + return null /*try { + context.getSystemService(Context.TELEPHONY_SERVICE)?.let { + val telephonyManager = it as TelephonyManager + return TelephonyData( + telephonyManager.simOperatorName!!, + DeviceIdentifier.meid, + telephonyManager.networkOperator!!, + telephonyManager.simOperator!!, + telephonyManager.phoneType + ) + } + } catch (e: Exception) { + if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "getTelephonyData", e) + null + }*/ +} + +@SuppressLint("MissingPermission") +fun getLocationData(context: Context): LocationData? { + // TODO: Dummy data + return null /*try { + (context.getSystemService(Context.LOCATION_SERVICE) as LocationManager?)?.let { locationManager -> + locationManager.getLastKnownLocation("network")?.let { location -> + return LocationData( + location.altitude, + location.latitude, + location.longitude, + location.accuracy, + location.time.toDouble() + ) + } + } + } catch (e: Exception) { + if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "getLocationData", e) + null + }*/ +} + +@SuppressLint("MissingPermission") +fun getNetworkData(context: Context): NetworkData { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? + val linkDownstreamBandwidth: Long = 0 + val linkUpstreamBandwidth: Long = 0 + // TODO: Dummy data — populate bandwidth via NetworkCapabilities when permission available + val isActiveNetworkMetered = connectivityManager?.isActiveNetworkMetered ?: false + val netAddressList = mutableListOf() + // TODO: Dummy data — enumerate NetworkInterface inet addresses + return NetworkData( + linkDownstreamBandwidth, + linkUpstreamBandwidth, + isActiveNetworkMetered, + netAddressList + ) +} + +@SuppressLint("HardwareIds") +fun getAndroidId(context: Context): String = + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "" + +fun isCharging(context: Context): Boolean { + val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val intent = if (Build.VERSION.SDK_INT < 33) { + context.registerReceiver(null, intentFilter) + } else { + context.registerReceiver(null, intentFilter, null, null) + } + return intent?.let { + val status = it.getIntExtra("status", -1) + status == 2 || status == 5 + } ?: false +} + +fun isInCallOrRingMode(context: Context): Boolean { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + return audioManager?.let { + when (it.mode) { + AudioManager.MODE_IN_CALL, AudioManager.MODE_RINGTONE -> true + else -> false + } + } ?: false +} + +fun isUsbConnected(context: Context): Boolean { + val usbManager = context.getSystemService(Context.USB_SERVICE) as? UsbManager + val packageManager = context.packageManager + return if (usbManager != null && + (packageManager.hasSystemFeature("android.hardware.usb.host") || + packageManager.hasSystemFeature("android.hardware.usb.accessory")) + ) { + try { + val accessoryList = usbManager.accessoryList + val deviceList = usbManager.deviceList + !(accessoryList == null && deviceList.isEmpty()) + } catch (e: NullPointerException) { + false + } + } else { + false + } +} + +fun getScreenBrightness(context: Context): Int { + return try { + Settings.System.getInt(context.contentResolver, "screen_brightness") + } catch (e: Settings.SettingNotFoundException) { + -1 + } +} + +private fun Context.hasPermission(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + +/** + * Build a [DeviceEnvInfo] for a payments / vending session. + * + * Module-specific parameters (gpVersionCode / gpVersionName / gpPkgName / userAgent) + * must be supplied because each module identifies itself with its own version string. + * + * All payments-protocol extension fields (sdkVersion / gmsPackageName / camera permission / + * battery / USB / call mode / screen brightness) are always collected and populated — + * vending downstream ignores fields it doesn't send. + * + * Static device info (DEVICE / PRODUCT / SERIAL / …) is read through the profile-aware + * [Build] wrapper, so test profiles override what the system would expose. + * + * @return null if the package info lookup fails or any inner collector throws + */ +@SuppressLint("MissingPermission") +fun createDeviceEnvInfo( + context: Context, + gpVersionCode: Long, + gpVersionName: String, + gpPkgName: String, + userAgent: String = "", +): DeviceEnvInfo? { + return try { + val packageInfo = context.packageManager.getPackageInfo(Constants.VENDING_PACKAGE_NAME, 0) + Log.d(TAG, "createDeviceEnvInfo: pkg=${packageInfo.packageName} ver=${packageInfo.versionName}/${packageInfo.versionCode}") + DeviceEnvInfo( + gpVersionCode = gpVersionCode, + gpVersionName = gpVersionName, + gpPkgName = gpPkgName, + gpLastUpdateTime = packageInfo.lastUpdateTime, + gpFirstInstallTime = packageInfo.firstInstallTime, + gpSourceDir = packageInfo.applicationInfo!!.sourceDir!!, + androidId = getAndroidId(context), + biometricSupport = true, + biometricSupportCDD = true, + deviceId = getDeviceIdentifier(context), + serialNo = Build.SERIAL ?: "", + locale = Locale.getDefault(), + userAgent = userAgent, + device = Build.DEVICE ?: "", + displayMetrics = getDisplayInfo(context), + telephonyData = getTelephonyData(context), + locationData = getLocationData(context), + networkData = getNetworkData(context), + product = Build.PRODUCT ?: "", + model = Build.MODEL ?: "", + manufacturer = Build.MANUFACTURER ?: "", + fingerprint = Build.FINGERPRINT ?: "", + release = Build.VERSION.RELEASE ?: "", + brand = Build.BRAND ?: "", + batteryLevel = getBatteryLevel(context), + timeZoneOffset = if (SDK_INT >= 24) TimeZone.getDefault().rawOffset.toLong() else 0, + isAdbEnabled = false, + installNonMarketApps = true, + uptimeMillis = SystemClock.uptimeMillis(), + timeZoneDisplayName = if (SDK_INT >= 24) TimeZone.getDefault().displayName!! else "", + googleAccounts = AccountManager.get(context) + .getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE).map { it.name }, + sdkVersion = SDK_INT.toString(), + gmsPackageName = Constants.GMS_PACKAGE_NAME, + cameraPermissionState = if (context.hasPermission(Manifest.permission.CAMERA)) 1 else 2, + isInCallOrRingMode = isInCallOrRingMode(context), + isUsbConnected = isUsbConnected(context), + isCharging = isCharging(context), + screenBrightness = getScreenBrightness(context), + ) + } catch (e: Exception) { + Log.w(TAG, "createDeviceEnvInfo", e) + null + } +} diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/profile/ProfileManager.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/profile/ProfileManager.kt index 272faac835..1b96d383d0 100644 --- a/play-services-base/core/src/main/kotlin/org/microg/gms/profile/ProfileManager.kt +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/profile/ProfileManager.kt @@ -34,7 +34,7 @@ object ProfileManager { private fun getUserProfileFile(context: Context): File = File(context.filesDir, "device_profile.xml") private fun getSystemProfileFile(context: Context): File = File("/system/etc/microg_device_profile.xml") - private fun getProfileResId(context: Context, profile: String) = context.resources.getIdentifier("${context.packageName}:xml/profile_$profile".toLowerCase(Locale.US), null, null) + private fun getProfileResId(context: Context, profile: String) = context.resources.getIdentifier("${context.packageName}:xml/profile_$profile".lowercase(Locale.US), null, null) fun getConfiguredProfile(context: Context): String = SettingsContract.getSettings(context, Profile.getContentUri(context), arrayOf(Profile.PROFILE)) { it.getString(0) } ?: PROFILE_AUTO diff --git a/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/WalletCustomTheme.aidl b/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/WalletCustomTheme.aidl new file mode 100644 index 0000000000..6c5c32727b --- /dev/null +++ b/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/WalletCustomTheme.aidl @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.firstparty; + +parcelable WalletCustomTheme; \ No newline at end of file diff --git a/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsData.aidl b/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsData.aidl new file mode 100644 index 0000000000..11d3f45a3e --- /dev/null +++ b/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsData.aidl @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.firstparty.pm; + +parcelable SecurePaymentsData; \ No newline at end of file diff --git a/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsPayload.aidl b/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsPayload.aidl new file mode 100644 index 0000000000..3a7ebf196a --- /dev/null +++ b/play-services-base/src/main/aidl/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsPayload.aidl @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.firstparty.pm; + +parcelable SecurePaymentsPayload; \ No newline at end of file diff --git a/play-services-base/src/main/aidl/com/google/android/gms/wallet/shared/ApplicationParameters.aidl b/play-services-base/src/main/aidl/com/google/android/gms/wallet/shared/ApplicationParameters.aidl new file mode 100644 index 0000000000..c7da3adcac --- /dev/null +++ b/play-services-base/src/main/aidl/com/google/android/gms/wallet/shared/ApplicationParameters.aidl @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.shared; + +parcelable ApplicationParameters; \ No newline at end of file diff --git a/play-services-base/src/main/aidl/com/google/android/gms/wallet/shared/BuyFlowConfig.aidl b/play-services-base/src/main/aidl/com/google/android/gms/wallet/shared/BuyFlowConfig.aidl new file mode 100644 index 0000000000..9a0d4a939d --- /dev/null +++ b/play-services-base/src/main/aidl/com/google/android/gms/wallet/shared/BuyFlowConfig.aidl @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.shared; + +parcelable BuyFlowConfig; \ No newline at end of file diff --git a/play-services-base/src/main/java/com/google/android/gms/wallet/bender3/Bender3RedirectExtras.java b/play-services-base/src/main/java/com/google/android/gms/wallet/bender3/Bender3RedirectExtras.java new file mode 100644 index 0000000000..97fd657622 --- /dev/null +++ b/play-services-base/src/main/java/com/google/android/gms/wallet/bender3/Bender3RedirectExtras.java @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.bender3; + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +public class Bender3RedirectExtras implements Parcelable { + public final String o1SessionId; + public final int billableService; + public final int regionCode; + + + public Bender3RedirectExtras(Parcel source) { + Bundle bundle = source.readBundle(this.getClass().getClassLoader()); + this.o1SessionId = bundle.getString("session_id"); + this.billableService = bundle.getInt("billable_service", -1); + this.regionCode = bundle.getInt("region_code", -1); + } + + public Bender3RedirectExtras(String o1SessionId, int billableService, int regionCode) { + this.o1SessionId = o1SessionId; + this.billableService = billableService; + this.regionCode = regionCode; + } + + public static final Creator CREATOR = new Creator() { + @Override + public Bender3RedirectExtras createFromParcel(Parcel in) { + return new Bender3RedirectExtras(in); + } + + @Override + public Bender3RedirectExtras[] newArray(int size) { + return new Bender3RedirectExtras[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + Bundle bundle = new Bundle(); + if (this.o1SessionId != null) { + bundle.putString("session_id", this.o1SessionId); + } + + if (this.billableService != -1) { + bundle.putInt("billable_service", this.billableService); + } + + if (this.regionCode != -1) { + bundle.putInt("region_code", this.regionCode); + } + bundle.writeToParcel(dest, flags); + } + + @NonNull + @Override + public String toString() { + return "Bender3RedirectExtras{" + + "o1SessionId=" + o1SessionId + + ", billableService=" + this.billableService + + ", regionCode=" + this.regionCode + + '}'; + } +} diff --git a/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/WalletCustomTheme.java b/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/WalletCustomTheme.java new file mode 100644 index 0000000000..32c9210715 --- /dev/null +++ b/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/WalletCustomTheme.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.firstparty; + +import android.os.Bundle; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class WalletCustomTheme extends AbstractSafeParcelable { + @Field(2) + int mainThemeStyle; + @Field(5) + int startTransitionStyle; + @Field(6) + int endTransitionStyle; + @Field(3) + Bundle extraThemeParams; + @Field(4) + final String themeDescription; + + @Constructor + public WalletCustomTheme() { + this.mainThemeStyle = 0; + this.startTransitionStyle = 0; + this.endTransitionStyle = 0; + this.extraThemeParams = new Bundle(); + this.themeDescription = ""; + } + + @Constructor + public WalletCustomTheme(@Param(2) int mainThemeStyle, @Param(5) int startTransitionStyle, @Param(6) int endTransitionStyle, + @Param(3) Bundle extraThemeParams, @Param(4) String themeDescription) { + this.mainThemeStyle = mainThemeStyle; + this.startTransitionStyle = startTransitionStyle; + this.endTransitionStyle = endTransitionStyle; + this.extraThemeParams = extraThemeParams; + this.themeDescription = themeDescription; + } + + @Override + public String toString() { + return "WalletCustomTheme{" + + "mainThemeStyle=" + mainThemeStyle + + ", startTransitionStyle=" + startTransitionStyle + + ", endTransitionStyle=" + endTransitionStyle + + ", extraThemeParams=" + extraThemeParams + + ", themeDescription='" + themeDescription + '\'' + + '}'; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(WalletCustomTheme.class); +} diff --git a/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsData.java b/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsData.java new file mode 100644 index 0000000000..67fff62616 --- /dev/null +++ b/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsData.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.firstparty.pm; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class SecurePaymentsData extends AbstractSafeParcelable { + @Field(2) + public final int key; + @Field(3) + public final String value; + + @Constructor + public SecurePaymentsData(@Param(2) int key, @Param(3) String value) { + this.key = key; + this.value = value; + if (key <= 0) { + throw new IllegalArgumentException("SecurePaymentsData.key must be > 0"); + } + if (value == null) { + throw new IllegalArgumentException("SecurePaymentsData.value must not be null"); + } + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SecurePaymentsData.class); +} diff --git a/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsPayload.java b/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsPayload.java new file mode 100644 index 0000000000..2d2488f646 --- /dev/null +++ b/play-services-base/src/main/java/com/google/android/gms/wallet/firstparty/pm/SecurePaymentsPayload.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.firstparty.pm; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import java.util.Arrays; + +@SafeParcelable.Class +public class SecurePaymentsPayload extends AbstractSafeParcelable { + @Field(2) + public final byte[] securePayload; + @Field(3) + public final SecurePaymentsData[] securePayments; + + @Constructor + public SecurePaymentsPayload(@Param(2) byte[] securePayload, @Param(3) SecurePaymentsData[] securePayments) { + this.securePayload = securePayload; + this.securePayments = securePayments; + } + + @Override + public String toString() { + return "SecurePaymentsPayload{" + + "payload=" + Arrays.toString(securePayload) + + ", securePayments=" + Arrays.toString(securePayments) + + '}'; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SecurePaymentsPayload.class); +} diff --git a/play-services-base/src/main/java/com/google/android/gms/wallet/shared/ApplicationParameters.java b/play-services-base/src/main/java/com/google/android/gms/wallet/shared/ApplicationParameters.java new file mode 100644 index 0000000000..2071a3a0e8 --- /dev/null +++ b/play-services-base/src/main/java/com/google/android/gms/wallet/shared/ApplicationParameters.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.shared; + +import android.accounts.Account; +import android.os.Bundle; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; +import com.google.android.gms.wallet.firstparty.WalletCustomTheme; + +@SafeParcelable.Class +public class ApplicationParameters extends AbstractSafeParcelable { + @Field(2) + public int environment; + @Field(3) + public Account buyerAccount; + @Field(4) + public Bundle extraParams; + @Field(5) + public boolean isPurchaseManagerFlow; + @Field(6) + public int themeMode; + @Field(7) + public WalletCustomTheme walletCustomTheme; + @Field(8) + final int uiVariant; + @Field(9) + double popupWidth; + @Field(10) + double popupHeight; + @Field(11) + final int forceFullScreen; + @Field(12) + final int integratorType; + + @Constructor + public ApplicationParameters() { + this.isPurchaseManagerFlow = false; + this.environment = 1; + this.themeMode = 1; + this.uiVariant = 0; + this.forceFullScreen = 0; + this.integratorType = -1; + } + + @Constructor + public ApplicationParameters(@Param(8) int uiVariant, @Param(11) int forceFullScreen, @Param(12) int integratorType, @Param(10) double popupHeight, @Param(9) double popupWidth, + @Param(7) WalletCustomTheme walletCustomTheme, @Param(6) int themeMode, @Param(5) boolean isPurchaseManagerFlow, + @Param(4) Bundle extraParams, @Param(3) Account buyerAccount, @Param(2) int environment) { + this.uiVariant = uiVariant; + this.forceFullScreen = forceFullScreen; + this.integratorType = integratorType; + this.popupHeight = popupHeight; + this.popupWidth = popupWidth; + this.walletCustomTheme = walletCustomTheme; + this.themeMode = themeMode; + this.isPurchaseManagerFlow = isPurchaseManagerFlow; + this.extraParams = extraParams; + this.buyerAccount = buyerAccount; + this.environment = environment; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(ApplicationParameters.class); +} diff --git a/play-services-base/src/main/java/com/google/android/gms/wallet/shared/BuyFlowConfig.java b/play-services-base/src/main/java/com/google/android/gms/wallet/shared/BuyFlowConfig.java new file mode 100644 index 0000000000..fb9b5d7c03 --- /dev/null +++ b/play-services-base/src/main/java/com/google/android/gms/wallet/shared/BuyFlowConfig.java @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.shared; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class BuyFlowConfig extends AbstractSafeParcelable { + @Field(2) + public String googleTransactionId; + @Field(3) + public ApplicationParameters applicationParameters; + @Field(4) + public String callerPackage; + @Field(5) + public String buyFlowName; + @Field(6) + public String androidPackageName; + @Field(7) + public String sessionId; + @Field(8) + public int sessionRestoreOption; + + public BuyFlowConfig() { + } + + @Constructor + public BuyFlowConfig(@Param(2) String googleTransactionId, @Param(3) ApplicationParameters applicationParameters, @Param(4) String callerPackage, + @Param(5) String buyFlowName, @Param(6) String androidPackageName, @Param(7) String sessionId, @Param(8) int sessionRestoreOption) { + this.googleTransactionId = googleTransactionId; + this.applicationParameters = applicationParameters; + this.callerPackage = callerPackage; + this.buyFlowName = buyFlowName; + this.androidPackageName = androidPackageName; + this.sessionId = sessionId; + this.sessionRestoreOption = sessionRestoreOption; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(BuyFlowConfig.class); +} diff --git a/play-services-base/src/main/java/com/google/android/wallet/bender3/framework/client/ParcelableKeyValue.java b/play-services-base/src/main/java/com/google/android/wallet/bender3/framework/client/ParcelableKeyValue.java new file mode 100644 index 0000000000..4ad59b8f6c --- /dev/null +++ b/play-services-base/src/main/java/com/google/android/wallet/bender3/framework/client/ParcelableKeyValue.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.wallet.bender3.framework.client; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class ParcelableKeyValue extends AbstractSafeParcelable { + @Field(2) + public final int key; + @Field(3) + public final String value; + + @Constructor + public ParcelableKeyValue(@Param(2) int key, @Param(3) String value) { + this.key = key; + this.value = value; + if (key <= 0) { + throw new IllegalArgumentException("ParcelableKeyValue.key must be > 0"); + } + if (value == null) { + throw new IllegalArgumentException("ParcelableKeyValue.value must not be null"); + } + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(ParcelableKeyValue.class); +} diff --git a/play-services-core-proto/src/main/proto/Purchase.proto b/play-services-core-proto/src/main/proto/Purchase.proto new file mode 100644 index 0000000000..ac2ff13f74 --- /dev/null +++ b/play-services-core-proto/src/main/proto/Purchase.proto @@ -0,0 +1,1460 @@ +/* + * SPDX-FileCopyrightText: 2025 microG project team + * SPDX-License-Identifier: Apache-2.0 + */ + +syntax = "proto2"; + +option java_package = "org.microg.vending.billing.proto"; +option java_multiple_files = true; + +import "Timestamp.proto"; + +enum PurchaseManagerUrlType { + INITIALIZE = 0; + SUBMIT = 1; + INITIALIZE_TEMPLATE = 2; + ADDRESS_SUGGESTION = 3; + ADDRESS_METADATA = 4; + PURCHASE_MANAGER_URL_TYPE_UNKNOWN = 5; +} + +enum CompressionType { + COMPRESSION_TYPE_UNKNOWN = 0; + COMPRESSION_TYPE_IDENTITY = 1; + COMPRESSION_TYPE_ZSTD = 2; + COMPRESSION_TYPE_BROTLI = 3; + UNRECOGNIZED = -1; +} + +enum BasicDeviceFeature { + UNKNOWN_FEATURE = 0; + ANDROID_LEANBACK = 1; + CAMERA_DOCUMENT_CAPTURE = 2; + ANDROID_WEAR = 3; + ANDROID_VIRTUAL_REALITY_SETUP = 4; + ANDROID_VIRTUAL_REALITY = 5; + ANDROID_FINGERPRINT = 6; + NFC_DEVICE_SUPPORT = 7; + FELICA_SUPPORT = 8; + TOKENIZATION_SUPPORT = 9; +} + +enum FidoDeviceFeature { + UNKNOWN_AUTH_TYPE = 0; + FINGERPRINT = 2; + PIN_PASSWORD_OR_PATTERN = 3; + BIOMETRIC = 4; + FACE_ID = 5; +} + +enum SecureElementState { + SECURE_ELEMENT_STATE_UNKNOWN = 0; + SECURE_ELEMENT_STATE_NOT_SUPPORTED = 1; + SECURE_ELEMENT_STATE_SUPPORTED = 2; +} + +enum DeviceBasedInputType { + DEVICE_BASED_INPUT_TYPE_UNKNOWN = 0; + DEVICE_BASED_INPUT_TYPE_CARD_OCR = 1; + DEVICE_BASED_INPUT_TYPE_NFC = 2; +} + +enum ValidityState { + VALIDITY_UNKNOWN = 0; + VALIDITY_VALID = 1; + VALIDITY_INVALID = 2; + VALIDITY_PENDING = 3; + VALIDITY_NO_VALUE = 4; +} + +enum FunctionalDataExecutionState { + FUNCTIONAL_DATA_EXECUTION_STATE_UNKNOWN = 0; + FUNCTIONAL_DATA_EXECUTION_STATE_NOT_STARTED = 1; + FUNCTIONAL_DATA_EXECUTION_STATE_RUNNING = 2; + FUNCTIONAL_DATA_EXECUTION_STATE_CANCELLED = 3; + FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED = 4; + FUNCTIONAL_DATA_EXECUTION_STATE_FAILED = 5; +} + +enum ResultingActionType { + RESULTING_ACTION_TYPE_UNKNOWN = 0; + RESULTING_ACTION_TYPE_SUBMIT = 1; + RESULTING_ACTION_TYPE_FINISH = 2; + RESULTING_ACTION_TYPE_LOAD_URL = 3; + RESULTING_ACTION_TYPE_TRIGGER_FULL_SCREEN_SPINNER = 4; + RESULTING_ACTION_TYPE_FALLBACK_TO_O1_WIDGET = 5; + RESULTING_ACTION_TYPE_ANNOUNCE_FOR_ACCESSIBILITY = 6; + RESULTING_ACTION_TYPE_UPDATE_FOCUS = 7; + RESULTING_ACTION_TYPE_REINITIALIZE = 8; + RESULTING_ACTION_TYPE_DIAL_NUMBER = 9; + RESULTING_ACTION_TYPE_PRINT_PAGE = 10; + RESULTING_ACTION_TYPE_COPY_TO_CLIPBOARD = 11; + RESULTING_ACTION_TYPE_SEND_PAYMENT_EVENT_CALLBACK_DATA = 12; + RESULTING_ACTION_TYPE_SYSTEM_SHARE = 13; + RESULTING_ACTION_TYPE_FINISH_WITH_REDIRECT = 14; +} + +enum EmbeddedImageType { + EMBEDDED_IMAGE_UNKNOWN = 0; + EMBEDDED_IMAGE_CREDIT_CARD_VISA_LOGO = 1; + EMBEDDED_IMAGE_CREDIT_CARD_CVC_HINT_FRONT = 2; + EMBEDDED_IMAGE_CREDIT_CARD_CVC_HINT_BACK = 3; + EMBEDDED_IMAGE_CLOSE_ICON = 4; + EMBEDDED_IMAGE_BACK_ICON = 5; + EMBEDDED_IMAGE_PAYPAL_LOGO_FULL = 6; + EMBEDDED_IMAGE_CREDIT_CARD_AMEX_LOGO = 7; + EMBEDDED_IMAGE_CREDIT_CARD_MASTERCARD_LOGO = 8; + EMBEDDED_IMAGE_CREDIT_CARD_DISCOVER_LOGO = 9; + EMBEDDED_IMAGE_CREDIT_CARD_GENERIC_LOGO = 10; + EMBEDDED_IMAGE_RADIO_BUTTON_CHECKED = 11; + EMBEDDED_IMAGE_RADIO_BUTTON_UNCHECKED = 12; + EMBEDDED_IMAGE_KEY = 13; + EMBEDDED_CHECK_BOX_CHECKED = 14; + EMBEDDED_CHECK_BOX_UNCHECKED = 15; + EMBEDDED_CHECK_BOX_INDETERMINATE = 16; + EMBEDDED_IMAGE_GPAY_LOGO_LIGHT = 17; + EMBEDDED_IMAGE_GPAY_LOGO_DARK = 18; + EMBEDDED_IMAGE_FLASH_AUTO = 19; + EMBEDDED_IMAGE_FLASH_ON = 20; + EMBEDDED_IMAGE_FLASH_OFF = 21; + EMBEDDED_IMAGE_GPAY_LOGO_DYNAMIC_COLOR = 22; + EMBEDDED_IMAGE_TAP_TO_ADD_CARD = 23; + EMBEDDED_IMAGE_LENS_CAMERA = 24; + EMBEDDED_IMAGE_KEYBOARD = 25; + EMBEDDED_IMAGE_CONTACTLESS = 26; +} + +message DocId { + optional string backendDocId = 1; + optional int32 type = 2; + optional int32 backend = 3; +} + +message SkuParam { + optional string name = 1; + optional string sv = 2; + optional bool bv = 3; + optional int64 i64v = 4; + repeated string svList = 5; +} + +message FailedResponse { + optional int32 statusCode = 1; + optional string msg = 2; +} + +message DisplayText { + optional string text = 2; +} + +message SkuDetailsRequest { + optional int32 apiVersion = 1; + optional string package = 2; + optional string type = 3; + repeated string skuId = 4; + repeated DynamicSku dynamicSku = 5; + optional bool isWifi = 6; + optional SkuDetailsExtra skuDetailsExtra = 7; + optional string skuPackage = 8; + repeated OfferSku offerSkus = 9; + repeated MultiOfferSkuDetail multiOfferSkuDetail = 10; +} + +message DynamicSku { + optional string unknown1 = 1; + optional string unknown2 = 2; + optional string unknown3 = 3; +} + +message SkuDetailsExtra { + optional string version = 1; +} + +message OfferSku { + optional string unknown1 = 1; + optional string unknown2 = 2; +} + +message MultiOfferSkuDetail { + optional string key = 1; + oneof value { + string sv = 2; + bool bv = 3; + int64 iv = 4; + SkuSerializedDocIds skuSerializedDocIds = 5; + } +} + +message SkuSerializedDocIds { + repeated string docIds = 1; +} + +message SkuDetailsResponse { + repeated SkuDetails details = 1; + optional bool unknown2 = 2; + optional FailedResponse failedResponse = 4; + repeated SkuInfo skuInfo = 6; +} + +message SkuDetails { + optional string skuDetails = 1; + optional SkuInfo skuInfo = 4; +} + +message SkuInfo { + repeated SkuItem skuItem = 1; +} + +message SkuItem { + optional DocId docId = 1; + optional string unknown2 = 2; + optional string token = 3; +} + +message DocumentInfo { + optional DocId docId = 1; + optional int32 unknown2 = 2; + oneof SkuOfferIdToken { + string token3 = 3; + string token14 = 14; + } +} + +message AcquireRequest { + optional DocumentInfo documentInfo = 1; + optional ClientInfo clientInfo = 2; + optional bytes serverContextToken = 3; + repeated bytes actionContext = 4; + optional string clientTokenB64 = 5; + optional DeviceAuthInfo deviceAuthInfo = 8; + map authTokens = 9; + optional SecurePayloadData securePayload = 10; + optional UnkMessage5 unknown12 = 12; + optional string deviceIDBase64 = 19; + optional string newAcquireCacheKey = 20; + optional string nonce = 22; + optional int32 theme = 25; + optional Timestamp createTimestamp = 31; +} + +message CKDocument { + optional DocId docId = 1; + oneof SkuOfferIdToken { + string token3 = 2; + string token14 = 4; + } + optional int32 unknown3 = 3; +} + +message ClientInfo { + optional int32 apiVersion = 1; + optional string package = 2; + optional int32 versionCode = 3; + optional string signatureMD5 = 4; + repeated SkuParam skuParamList = 7; + optional int32 unknown8 = 8; + optional string installerPackage = 9; + optional int32 unknown10 = 10; + optional int32 unknown11 = 11; + optional UnkMessage1 unknown15 = 15; + oneof OldSkuPurchase { + string oldSkuPurchaseToken = 16; + string oldSkuPurchaseId = 17; + } + optional int32 versionCode1 = 18; + optional UnKnownMessage21 unKnownMessage21 = 21; + optional string skuPackageSignatureSha256 = 23; + optional AccountNameMessage secondaryAccount = 24; +} + +message UnKnownMessage21 { + optional int32 unknown1 = 1; +} + +message UnkMessage1 { + oneof Type { + UnkMessage2 unknown1 = 1; + UnkMessage3 unknown2 = 2; + UnkMessage4 unknown3 = 3; + } +} + +message UnkMessage2 { + optional int32 unknown1 = 1; +} + +message UnkMessage3 { + optional int32 unknown1 = 1; +} + +message UnkMessage4 { + optional int32 unknown1 = 1; +} + +message DeviceAuthInfo { + optional bool canAuthenticate = 1; + optional bool isBiometricStrong = 2; + optional bool fingerprintValid = 3; + optional int32 desiredAuthMethod = 4; + optional int32 unknown5 = 5; + optional int32 authFrequency = 6; + optional int64 lastGaiaAuthTimestamp = 8; + optional bool unknown9 = 9; + optional bool isAcquireCachingDisabled = 11; + optional bool userHasFop = 16; + map authParams = 18; + optional bool unknown20 = 20; + optional ItemColor itemColor = 26; + optional string droidGuardPayload = 30; + optional int32 verificationMethodSelectionMode = 36; + repeated AccountNameMessage allowedGoogleAccounts = 37; + optional bool isAccessibilityServiceEnabled = 41; + optional bool isAccessibilityEnabledInConfig = 42; + optional bool hasSeenPurchaseSessionAuthRequirementPrompt = 43; + optional bool isAuthRationalizationFinished = 44; +} + +message UnkMessage5 { + optional int32 unknown1 = 1; +} + +message ItemColor { + optional int32 androidAppsColor = 1; + optional int32 booksColor = 2; + optional int32 musicColor = 3; + optional int32 moviesColor = 4; + optional int32 newsStandColor = 5; +} + +message AccountNameMessage { + optional string accountName = 1; +} + +message AcquireResponse { + map screen = 1; + optional AcquireResult acquireResult = 3; + optional bytes serverContextToken = 4; + optional Action action = 8; + optional bool needClear = 11; +} + +message AcquireResult { + repeated PurchaseItem purchaseItem = 3; + optional OwnedPurchase ownedPurchase = 8; + optional string signature = 9; + optional PurchaseResponse purchaseResponse = 10; +} + +message AcknowledgePurchaseRequest { + optional string purchaseToken = 1; + optional string developerPayload = 2; +} + +message AcknowledgePurchaseResponse { + optional PurchaseItem purchaseItem = 1; + optional FailedResponse failedResponse = 2; +} + +message ConsumePurchaseResponse { + optional PurchaseItem purchaseItem = 1; + optional FailedResponse failedResponse = 3; +} + +message PurchaseResponse { + optional bool isSuccessful = 1; + optional ResponseBundle responseBundle = 2; +} + +message ResponseBundle { + repeated BundleItem bundleItem = 1; +} + +message BundleItem { + optional string key = 1; + oneof value { + string sv = 2; + bool bv = 3; + int64 i64v = 4; + int32 i32v = 5; + BundleStringList sList = 6; + } +} + +message BundleStringList { + repeated string value = 1; +} + +message OwnedPurchase { + repeated PurchaseItem purchaseItem = 1; +} + +message PurchaseItem { + repeated PurchaseItemData purchaseItemData = 4; +} + +message PurchaseItemData { + optional DocId docId = 1; + optional SubsPurchase subsPurchase = 6; + optional InAppPurchase inAppPurchase = 7; +} + +message InAppPurchase { + optional string jsonData = 1; + optional string signature = 2; +} + +message SubsPurchase { + optional int64 startAt = 1; + optional int64 expireAt = 2; + optional string jsonData = 5; + optional string signature = 6; +} + +message PurchaseHistoryResponse { + repeated string productId = 1; + repeated string purchaseJson = 2; + repeated string signature = 3; + optional string continuationToken = 4; + optional FailedResponse failedResponse = 5; +} + +message DetailsResponse { + optional Item item = 4; +} + +message BuyResponse { + optional string deliveryToken = 55; +} + +message Item { + optional Offer offer = 8; + repeated Item subItem = 11; + optional DocumentDetails details = 13; +} + +message DocumentDetails { + optional AppDetails appDetails = 1; +} + +message AppDetails { + optional int32 versionCode = 3; + optional string packageName = 14; +} + +message Offer { + optional int64 micros = 1; + optional int32 offerType = 8; + optional string offerId = 19; +} + +message Screen { + optional UIInfo uiInfo = 1; + optional ScreenActionState actionState = 2; + optional Action action = 5; + optional SecurePaymentWrapper securePayment = 7; + optional UiComponents uiComponents = 175996169; +} + +message ScreenActionState { + optional PurchaseAction action = 1; +} + +message PurchaseAction { + optional ScreenFinishAction finish = 3; +} + +message ScreenFinishAction { + optional PurchaseResult purchaseResult = 1; + optional bool successFlag = 2; + optional int32 delayMs = 3; +} + +message PurchaseResult { + repeated PurchaseResultItem items = 1; +} + +message PurchaseResultItem { + optional string key = 1; + oneof value { + string stringValue = 2; + bool boolValue = 3; + int64 intValue = 5; + } +} + +message SecurePaymentWrapper { + optional SecurePaymentSelector selector = 25; +} + +message SecurePaymentSelector { + oneof config { + SecurePaymentSimple simpleConfig = 10; + SecurePaymentFull fullConfig = 11; + } + optional int32 paymentType = 3; +} + +message SecureDataEntry { + optional int32 key = 1; + optional string value = 2; +} + +message SecurePayloadData { + optional bytes securePayload = 1; + repeated SecureDataEntry entries = 2; +} + +message SecurePaymentFull { + optional SecurePayloadData payloadData = 1; + optional ServerEndpoint serverEndpoint = 2; + optional PaymentMethodParams methodParams = 3; +} + +message ServerEndpoint { + optional string url = 1; + optional string basePath = 2; +} + +message PaymentMethodParams { + optional int32 methodType = 1; + optional bytes encryptedData = 2; + optional int32 version = 3; + optional bool requiresAuth = 4; + optional int32 authType = 5; + optional int32 priority = 6; + repeated int32 supportedTypes = 7; + optional int32 status = 9; +} + +message SecurePaymentSimple { + optional bytes payload = 1; + optional PaymentMethodParams methodParams = 2; +} + +message UiComponents { + repeated ContentComponent contentComponent1 = 1; + repeated ContentComponent contentComponent2 = 2; + repeated FooterComponent footerComponent = 3; +} + +message ContentComponent { + optional UIInfo uiInfo = 1; + optional ViewInfo viewInfo = 2; + optional string tag = 4; + oneof UiComponent { + ClickableTextView clickableTextView = 20; + ViewGroup viewGroup = 21; + DividerView dividerView = 23; + InstrumentItemView instrumentItemView = 26; + ModuloImageView moduloImageView = 27; + IconTextCombinationView iconTextCombinationView = 37; + ButtonGroupView buttonGroupView = 57; + } +} + +message InstrumentItemView { + optional ImageView icon = 1; + optional PlayTextView text = 2; + optional PlayTextView tips = 3; + optional ImageView state = 5; + optional Action action = 6; + optional PlayTextView extraInfo = 7; +} + +message ModuloImageView { + optional ImageView imageView = 1; + optional Action action = 2; +} + +message DividerView { +} + +message ClickableTextView { + optional PlayTextView playTextView = 1; + optional Action action = 2; +} + +message IconView { + optional int32 type = 1; + optional string text = 2; +} + +message ImageInfo { + oneof ColorFilter { + int32 value = 1; + int32 valueType = 3; + } + optional int32 modeType = 2; + optional int32 scaleType = 5; +} + +message ImageView { + optional ThumbnailImageView thumbnailImageView = 1; + optional ViewInfo viewInfo = 2; + optional ImageInfo imageInfo = 4; + optional IconView iconView = 5; + oneof AnimationType { + Animation animation = 6; + } +} + +message Animation { + optional int32 type = 1; + optional int32 repeatCount = 2; +} + +message ViewGroup { + optional ImageView imageView1 = 1; + optional ImageView imageView2 = 2; + optional ImageView imageView3 = 3; + optional ImageView imageView4 = 4; + optional PlayTextView playTextView = 5; +} + +message ViewInfo { + optional string tag = 1; + optional float widthValue = 2; + optional float heightValue = 3; + optional float startMargin = 4; + optional float topMargin = 5; + optional float endMargin = 6; + optional float bottomMargin = 7; + optional float startPadding = 8; + optional float topPadding = 9; + optional float endPadding = 10; + optional float bottomPadding = 11; + optional int32 backgroundColor = 12; + optional int32 backgroundColorType = 37; + optional string contentDescription = 14; + optional Action action = 20; + repeated int32 gravity = 22; + optional int32 widthTypedValue = 23; + optional int32 heightTypedValue = 24; + optional int32 visibilityType = 29; + optional int32 borderColorType = 30; + optional int32 startMarginType = 41; + optional int32 topMarginType = 42; + optional int32 endMarginType = 43; + optional int32 bottomMarginType = 44; + optional int32 startPaddingType = 45; + optional int32 topPaddingType = 46; + optional int32 endPaddingType = 47; + optional int32 bottomPaddingType = 48; +} + +message ThumbnailImageView { + optional string lightUrl = 5; + optional string darkUrl = 28; +} + +message ImageGroup { + repeated ImageView imageView = 1; + optional ViewInfo viewInfo = 2; +} + +message IconTextCombinationView { + optional ImageView headerImageView = 1; + optional PlayTextView playTextView = 2; + repeated SingleLineTextView singleLineTextView = 5; + optional ViewInfo viewInfo = 6; + optional PlayTextView badgeTextView = 9; + optional ImageGroup footerImageGroup = 12; +} + +message SingleLineTextView { + optional PlayTextView playTextView1 = 1; + optional PlayTextView playTextView2 = 2; +} + +message Dimension { + optional int32 unitType = 1; + optional float unitValue = 2; +} + +message BulletSpan { + optional Dimension gapWidth = 1; +} + +message TextSpan { + oneof Span { + BulletSpan bulletSpan = 4; + } +} + +message PlayTextView { + oneof TextData { + string text = 1; + int32 textType = 10; + } + optional bool isHtml = 2; + optional ViewInfo viewInfo = 3; + optional TextInfo textInfo = 4; + repeated TextSpan textSpan = 7; +} + +message TextInfo { + oneof TextColor { + int32 textColorValue = 2; + int32 textColorType = 39; + } + optional int32 maxLines = 6; + repeated int32 gravity = 17; + optional int32 textAlignmentType = 36; + optional int32 styleType = 41; +} + +message FooterComponent { + optional UIInfo uiInfo = 1; + optional ViewInfo viewInfo = 2; + optional string tag = 5; + oneof UiComponent { + ButtonGroupView buttonGroupView = 22; + DividerView dividerView = 24; + IconTextCombinationView iconTextCombinationView = 25; + } +} + +message ButtonGroupView { + optional NewButtonView newButtonView = 6; +} + +message NewButtonView { + optional ButtonView buttonView = 1; + optional ButtonView buttonView2 = 2; +} + +message ButtonView { + oneof TextData { + string text = 1; + int32 fixedTextType = 9; + } + optional Action action = 2; + optional ViewInfo viewInfo = 4; +} + +message UIInfo { + optional int32 uiType = 1; + optional bytes context = 2; + optional int32 classType = 5; +} + +message Action { + optional TimerAction timerAction = 3; + optional ShowAction showAction = 4; + optional bytes actionContext = 7; + optional NavigateToPage navigateToPage = 8; + optional ViewClickAction viewClickAction = 10; + optional OptionalAction optionalAction = 19; + optional ActionExt actionExt = 148814548; +} + +message NavigateToPage { + optional string id = 1; + optional string from = 2; + optional Action action = 3; +} + +message OptionalAction { + repeated int32 unknown1 = 1; + optional Action action1 = 2; + optional Action action2 = 3; +} + +message ViewClickAction { + optional UIInfo uiInfo = 2; + optional Action action = 3; +} + +message TimerAction { + optional ResponseBundle responseBundle = 1; + optional bool isSuccessful = 2; + optional int32 delay = 3; + optional string url = 4; +} + +message ShowAction { + optional string screenId = 1; + optional Action action = 7; + optional Action action1 = 8; +} + +message ActionExt { + optional ExtAction extAction = 1; +} + +message ExtAction { + optional Action action = 1; + optional DroidGuardMap droidGuardMap = 20; +} + +message DroidGuardMap { + map map = 1; + optional int32 type = 2; +} + +message IABX { + repeated SkuParam skuParam = 1; +} + +message PaymentManagerConfig { + optional bytes o2ActionToken = 7; +} + +message InitializeRequest { + optional ClientToken.Info1 clientToken = 1; + optional bytes encryptedPayload = 2; + optional bytes unencryptedPayload = 3; + optional bytes uiTheme = 4; +} + +message SubmitRequest { + optional ClientToken.Info1 clientToken = 1; + repeated DataValue dataValue = 2; + optional ComponentTreeNode heqc = 3; +} + +message ResponseContext { +} + +message IapResponseWrapper { + optional IapCommonResponse iapResponse = 1; + optional bytes encryptedData = 2; +} + +message IapCommonResponse { + optional ClientToken.Info1 clientToken = 1; + oneof InitializeResponseUnknownOneof { + InitializeResponseUnknown2 responseBody = 2; + } + optional SecureDataHeader secureDataHeader = 3; +} + +message SecureDataHeader { + repeated string tokenization = 1; +} + +message InitializeResponseUnknown2 { + oneof PartialPageProtoWrapper { + InitializePartialPageProtoWrapper initializePartialPageProtoWrapper = 2; + UpdatePartialPageProtoWrapper updatePartialPageProtoWrapper = 3; + } +} + +message InitializePartialPageProtoWrapper { + optional InitializePartialPageProto partialPage = 1; +} + +message InitializePartialPageProto { + optional ComponentTreeNode componentTree = 1; + repeated PageElement pageElements = 3; +} + +message UpdatePartialPageProtoWrapper { + optional UpdatePartialPageProto updatePartialPageProto = 1; +} + +message UpdatePartialPageProto { + repeated ComponentTreeNode treeFragments = 1; + repeated int64 toRemove = 4; + repeated PageElement toAddOrReplaceData = 5; + repeated DataValue toReplaceDataValue = 6; + repeated DataValue toReplaceDataValuePreservingExtension = 11; +} + +message PageElement { + optional DataValue dataValue = 1; + optional string errorText = 3; + repeated TriggerRule triggerList = 4; + repeated ConditionRule conditionList = 5; + repeated ResultingActionRule resultingActionList = 6; + optional int32 extensionFieldNumber = 7; + repeated bytes loggingConfigurationList = 9; + + message ImageDataExtension { + optional ImageData imageData = 1; + } + message ImageData { + optional string title = 1; + oneof Data { + string imageUrl = 5; + } + repeated ImageDataValue values = 7; + optional bool isAutoMirrored = 9; + } + message ImageDataValue { + oneof Data { + string imageUrl = 4; + } + } + optional ImageDataExtension imageDataExtension = 217440216; + + message TextInfoDataExtension { + optional DisplayText displayText = 1; + optional string text = 3; + } + optional TextInfoDataExtension textInfoDataExtension = 223344552; + + message MessageExtension { + optional string unknown1 = 1; + optional bool unknown9 = 9; + } + message Message232057536 { + optional MessageExtension messageExtension = 1; + optional string content = 2; + } + optional Message232057536 message232057536 = 232057536; + + optional InfrastructureAction infrastructureDataExtension2 = 233780160; + + message InfrastructureDataExtension { + repeated ResultingActionRule extension = 1; + } + optional InfrastructureDataExtension infrastructureDataExtension = 233780159; + + message AnimatedImageDataExtension { + optional ConditionalContentSource conditionalContentSource = 1; + } + message ConditionalContentSource { + optional string contentDescription = 1; + optional ContentType contentType = 2; + } + enum ContentType { + TYPE_UNKNOWN = 0; + TYPE_PROGRESS_INDICATOR = 1; + TYPE_MEDIA_URL = 2; + TYPE_LOTTIE = 3; + TYPE_EMBEDDED = 4; + } + optional AnimatedImageDataExtension animatedImageDataExtension = 265527174; + + message EcdhKeyAgreementContext { + optional string ecdhPublicKey = 1; + optional string agreementPartyVInfo = 2; + optional int64 ephemeralPrivateKey = 3; + } + optional EcdhKeyAgreementContext ecdhKeyAgreementContext = 290848974; + + message EncryptionActionExtension { + optional string keyId = 1; + optional string initializationVector = 2; + oneof UnknownMessage { + string displayText = 3; + int64 fieldNumber = 4; + } + optional int64 id = 5; + } + optional EncryptionActionExtension encryptionActionExtension = 290848975; + + // GroupingDataExtension for container children + message GroupingDataExtension { + repeated int64 groupedDataReferenceList = 1; + } + optional GroupingDataExtension groupingDataExtension = 223344555; + + // VerticalContainerExtension + message ConditionOption { + optional int32 conditionValue = 1; + repeated int64 children = 2; + } + message VerticalContainerExtension { + repeated ConditionOption options = 1; + } + optional VerticalContainerExtension verticalContainerExtension = 223344553; + + message SmsAutoReaderExtension { + optional string senderPattern = 1; + optional string otpRegex = 2; + } + optional SmsAutoReaderExtension smsAutoReaderExtension = 232946268; + + message TextInputFieldExtension { + oneof Label { + string labelPrefix = 1; + string labelCaption = 3; + } + optional string hint = 2; + } + optional TextInputFieldExtension textInputFieldExtension = 217437962; + + message TemplateTextExtension { + repeated int64 childComponentIds = 1; + optional string formatTemplate = 2; + } + optional TemplateTextExtension templateTextExtension = 228971049; +} + +message DataValue { + optional int64 componentId = 1; + optional bytes unknown2 = 2; + optional DataStateInfo dataState = 3; + optional string description = 4; + optional int32 unknown5 = 5; + optional bool unknown6 = 6; + repeated int32 childComponentIds = 7 [packed = true]; + optional int32 submitConfig = 8; + repeated DataFieldReference fieldRefs = 9; + optional bytes unknown11 = 11; + optional int32 unknown12 = 12; + optional int32 unknown13 = 13; + optional int64 spacerHeight = 14; + + message Message204201689 { + optional string text = 1; + optional int32 unknown2 = 2; + } + optional Message204201689 message204201689 = 204201689; + + message Message239872231 { + optional int32 unknown2 = 2; + } + optional Message239872231 unknown1 = 239872231; + + message ConditionValueExtension { + optional int32 conditionValue = 1; + } + optional ConditionValueExtension conditionValueExt = 244241556; + + message AnimatedImageDataValueExtension { + optional AnimatedImageState state = 1; + optional int32 progress = 2; + } + enum AnimatedImageState { + ANIMATED_IMAGE_STATE_UNKNOWN = 0; + ANIMATED_IMAGE_STATE_NOT_STARTED = 1; + ANIMATED_IMAGE_STATE_RUNNING = 2; + ANIMATED_IMAGE_STATE_COMPLETED = 3; + } + optional AnimatedImageDataValueExtension animatedImageDataValueExtension = 265521645; + + message EphemeralECPublicKey { + optional string ecdhPublicKey = 1; + } + optional EphemeralECPublicKey ephemeralECPublicKey = 290848973; + + message SessionKeyExtension { + optional string reqSessionKey = 1; + } + optional SessionKeyExtension sessionKeyExtension = 290848974; + + message EncryptionActionExtension { + optional string encryptionValue = 1; + } + optional EncryptionActionExtension encryptionActionExtension = 290848975; + + message TextInputDataValueExtension { + optional string text = 1; + } + optional TextInputDataValueExtension textInputDataValueExtension = 235650857; +} + +message DataStateInfo { + optional int32 dataRole = 1; + optional ValidityState validityState = 3; + optional FunctionalDataExecutionState executionState = 4; + optional int32 enablementState = 5; + optional int32 errorCode = 6; + repeated ClientStateEntry clientStates = 7; +} + +message ClientStateEntry { + optional int32 stateKey = 1; + optional int32 stateValue = 2; +} + +message DataFieldReference { + optional int64 targetComponentId = 1; + optional FieldPath fieldPath = 2; +} + +message FieldPath { + repeated int32 fieldNumbers = 1 [packed = true]; + optional string value = 2; +} + +message ComponentTreeNode { + optional int64 nodeId = 1; + optional int64 conditionRef = 3; + repeated TriggerRule nodeDataFieldRefs = 4; + optional int32 nodeTypeId = 5; + optional ContainerNodeExtension containerExt = 214299793; + optional ConditionalNodeExtension conditionalExt = 231420908; + optional FullSheetNodeExtension fullSheetExt = 264434503; + optional ScrollNodeExtension scrollExt = 229613734; +} + +enum LayoutModeProto { + LAYOUT_MODE_UNKNOWN = 0; + LAYOUT_MODE_RELATIVE = 1; + LAYOUT_MODE_FLEX = 2; + LAYOUT_MODE_GRID = 3; + LAYOUT_MODE_FLOAT = 4; + LAYOUT_MODE_FLEX_DEPRECATED = 5; + LAYOUT_MODE_GLIF_ICON_CONTAINER = 6; + LAYOUT_MODE_GLIF_HEADER_CONTAINER = 7; +} + +message ContainerNodeExtension { + repeated ComponentTreeNode children = 1; + optional LayoutModeProto layoutMode = 2; + optional ComponentTreeNode footer = 3; +} + +message ConditionalNodeExtension { + optional int32 conditionValue = 1; + repeated ComponentTreeNode children = 2; +} + +message FullSheetNodeExtension { + optional ComponentTreeNode child = 1; +} + +message ScrollNodeExtension { + optional ComponentTreeNode child = 1; +} + +message TriggerRule { + repeated int64 dataIds = 1; + optional int32 triggerId = 2; + optional int32 extensionFieldNumber = 3; + optional TriggerTypeExtension triggerTypeExt = 232946268; + optional NodeTriggerExtension nodeTriggerExt = 234156385; +} + +message TriggerTypeExtension { + optional int32 triggerType = 1; +} + +message NodeTriggerExtension { + optional int32 interactionType = 1; // 1=INITIALIZE, 4=CLICK, ... +} + +message ConditionRule { + repeated int64 dataIds = 1; + optional int32 extensionFieldNumber = 2; + optional int32 conditionId = 3; + optional bool negated = 4; + optional int32 unknown5 = 5; + optional DataStateCondition dataStateCondition = 232946268; +} + +message DataStateCondition { + optional int32 matchType = 1; // 1=VALUE_MATCH, 2=ERROR_CODE, 3=CANCEL_CODE, 4=DATA_ROLE, 6=CLIENT_STATE + optional int32 matchValue = 2; +} + +message ResultingActionRule { + repeated int64 dataIds = 1; + optional int32 extensionFieldNumber = 2; + optional int32 resultingActionId = 3; + optional TriggerRule followUpTrigger = 5; + optional InfrastructureAction infrastructureAction = 233780160; + optional DataResultingAction dataResultingAction = 233806715; + optional ConditionValueAction conditionValueAction = 238549017; + optional AnimatedImageAction animatedImageAction = 265929774; +} + +message ConditionValueAction { + optional int32 flags = 1; + optional int32 conditionValue = 2; +} + +message AnimatedImageAction { + optional int32 resultingActionType = 1; + optional int32 animatedImageState = 2; // 0=UNKNOWN, 1=NOT_STARTED, 2=RUNNING, 3=COMPLETED +} + +message InfrastructureAction { + optional ResultingActionType resultActionType = 1; + oneof actionParam { + SubmitActionParams submitParams = 2; + FinishActionParams finishParams = 3; + URLWrapper urlWrapper = 4; + AccessibilityPaneData accessibilityPane = 7; + CopyToClipboard copyToClipboard = 12; + } +} + +message SubmitActionParams { + repeated int64 componentIds = 1; + optional int32 mode = 3; + optional SubmitActionScope scope = 5; + optional int32 statusMark = 7; +} + +message SubmitActionScope { + optional int64 scopeComponentId = 1; + optional int32 scopeType = 2; +} + +message FinishActionParams { + optional ResultCode resultCode = 1; // 0=UNKNOWN, 1=SUCCESS, 2=CANCEL, 3=ERROR + optional IntegratorCallbackData integratorCallbackData = 2; // Purchase receipt token + optional ClientCallbackData clientCallbackData = 3; //Client callback + optional ApiErrorData apiErrorData = 4; //apiErrorReason + debugMessage + optional SecurePaymentsData securePaymentsData = 5; //securePayload + SecureFieldReferences + optional AdditionalData additionalData = 6; // EXTRA_INTERNAL_CLIENT_CALLBACK_DATA +} + +enum ResultCode { + RESULT_CODE_UNKNOWN = 0; + RESULT_CODE_SUCCESS = 1; + RESULT_CODE_CANCEL = 2; + RESULT_CODE_ERROR = 3; +} + +message IntegratorCallbackData { + oneof payload { + bytes primaryData = 1; + bytes secondaryData = 2; + } +} + +message ClientCallbackData { + optional bytes dataBytes = 1; +} + +message ApiErrorData { + optional int32 apiErrorReason = 1; + optional string debugMessage = 2; +} + +message SecurePaymentsData { + optional bytes securePayload = 1; // hebl proto bytes + repeated SecureFieldReference secureFieldRefs = 3; + repeated bytes secureDataEntries = 4; // heyq +} + +message SecureFieldReference { + optional string key = 1; // ParcelableKeyValue.key + optional int64 componentId = 2; + repeated int32 nestedFieldList = 3; + repeated int32 fieldReferenceList = 4; +} + +message AdditionalData { + optional int32 type = 1; +} + +message URLWrapper { + optional URL url = 1; +} +message URL { + optional string url = 3; +} +message AccessibilityPaneData { + optional string textContext = 1; +} +message CopyToClipboard { + optional string text = 1; +} + +message DataResultingAction { + enum ResultingActionType { + RESULTING_ACTION_TYPE_UNKNOWN = 0; + RESULTING_ACTION_TYPE_ENABLEMENT_STATE_CHANGE = 1; + RESULTING_ACTION_TYPE_SCROLL_TO = 2; + RESULTING_ACTION_TYPE_FUNCTIONAL_DATA_EXECUTION_STATE_CHANGE = 3; + RESULTING_ACTION_TYPE_SET_VALUE_BY_REFERENCE = 4; + RESULTING_ACTION_TYPE_RUN_VALIDATION = 5; + RESULTING_ACTION_TYPE_NEW_ACTIVE_VALIDATION_ID = 6; + RESULTING_ACTION_TYPE_SAVE_SNAPSHOT = 7; + RESULTING_ACTION_TYPE_RESTORE_SNAPSHOT = 8; + } + optional ResultingActionType resultActionType = 1; // 1=ENABLEMENT_STATE_CHANGE, 3=FUNCTIONAL_DATA_EXECUTION_STATE_CHANGE + oneof actionParam { + int32 enablementState = 2; + FunctionalDataExecutionState executionState = 3; // resultActionType=3 → 2=RUNNING + } +} + +message ClientToken { + optional Info1 info1 = 1; + optional Info2 info2 = 2; + + message Info1 { + optional bytes unknown2 = 2; + optional string locale = 7; + optional int32 unknown8 = 8; + optional int64 gpVersionCode = 9; + optional DeviceInfo deviceInfo = 10; + optional int64 leastSignificantBits = 11; + optional bool unknown_bool_1 = 13; + optional int32 unknown_int_1 = 18; //unknow enum + repeated string googleAccounts = 19; + optional UserVerifying userVerifying = 21; + optional int64 sessionId = 22; + optional ThemeConfig themeConfig = 23; + optional int32 google_account_count = 25; + optional int32 current_account_index = 26; + repeated CompressionType compressed_types = 30 [packed = true]; + } + + message Info2 { + optional string unknown1 = 1; + optional int32 unknown3 = 3; + repeated int32 unknown4 = 4; + optional int32 unknown5 = 5; + } + + message DeviceInfo { + optional string sdkVersion = 3; + optional string device = 4; + optional int32 widthPixels = 5; + optional int32 heightPixels = 6; + optional float xdpi = 7; + optional float ydpi = 8; + optional string gpPackage = 9; + optional string gpVersionCode = 10; + optional string gpVersionName = 11; + optional EnvInfo envInfo = 12; + optional string callingPackage = 13; + optional string marketClientId = 14; + optional int32 unknown15 = 15; + optional int32 grantedPhonePermissionState = 16; + optional string simOperatorName = 17; + optional string groupIdLevel1 = 18; + optional int64 subscriberId = 19; + optional int64 moduleVersion = 20; + repeated BasicDeviceFeature curAuthContext = 21 [packed = true]; + optional int32 cameraPermissionState = 22; + optional int64 linkDownstreamBandwidth = 23; + optional int64 linkUpstreamBandwidth = 24; + optional bool isActiveNetworkMetered = 25; + repeated FidoDeviceFeature supportedAuthTypes = 26; + optional int32 densityDpi = 28; + optional bool isSmsCapable = 31; + optional int32 activeSubscriptionInfoCount = 32; + repeated string phenotypeServerToken = 33; + optional int32 unknown34 = 34; + optional int64 uptimeMillis = 35; + optional string timeZoneDisplayName = 36; + optional int64 androidId = 39; + optional SecureElementState secureElementState = 40; + optional string gpLongVersionCode = 41; + repeated DeviceBasedInputType inputTypeList = 43; + optional bool ocrServiceAvailability = 44; + optional string longVersionCode = 45; + optional string modelName = 52; + } + + message EnvInfo { + optional DeviceData deviceData = 1; + optional OtherInfo otherInfo = 2; + } + + message DeviceData { + optional int32 unknown1 = 1; + optional string simOperatorName = 2; + optional string phoneDeviceId = 3; + optional string phoneDeviceId1 = 5; + optional string line1Number = 6; + optional int64 gsfId = 7; + optional string device = 9; + optional string product = 10; + optional string model = 11; + optional string manufacturer = 12; + optional string fingerprint = 13; + optional string release = 15; + optional string brand = 21; + optional string serial = 22; + optional bool isEmulator = 24; + } + + message OtherInfo { + repeated GPInfo packageInfoList = 1; + optional int32 batteryLevel = 3; + optional int64 timeZoneOffset = 4; + optional Location location = 6; + optional bool isAdbEnabled = 7; + optional bool installNonMarketApps = 8; + optional string iso3Language = 9; + repeated string netAddress = 10; + optional string locale = 11; + optional string networkOperator = 14; + optional string simOperator = 15; + optional string language = 18; + optional string country = 19; + optional int32 phoneType = 20; + optional int64 uptimeMillis = 21; + optional string timeZoneDisplayName = 22; + optional int32 googleAccountCount = 23; + optional bool isUserAMonkey = 24; + optional bool isInCallOrRingMode = 25; + optional bool isUsbConnected = 26; + optional bool isCharging = 27; + optional int32 screenBrightness = 28; + optional DisplayMetrics displayMetrics = 29; + } + + message DisplayMetrics { + optional int32 widthPixels = 1; + optional int32 heightPixels = 2; + } + + message GPInfo { + optional string package = 1; + optional string versionCode = 2; + optional int64 lastUpdateTime = 3; + optional int64 firstInstallTime = 4; + optional string sourceDir = 5; + } + + message Location { + optional double altitude = 1; + optional double latitude = 2; + optional double longitude = 3; + optional float accuracy = 4; + optional double time = 5; + optional bool isMock = 6; + } +} + +//User Verifying Platform Authenticator Available +message UserVerifying { + repeated string unknown1 = 1; + optional bool unknown2 = 2; + optional bytes unknown3 = 3; +} + +message ThemeConfig { + oneof c { + string themeString = 1; + DefaultTheme defaultTheme = 3; + EmptyTheme emptyTheme = 4; + ThemeColors customColors = 5; + ColorScheme colorScheme = 6; + int32 themeStyle = 7; + } +} + +message DefaultTheme { + optional ColorPair backgroundColors = 1; + optional ColorPair accentColors = 2; + optional ColorPair textColors = 3; +} + +message ColorPair { + optional uint32 primaryColor = 1; + optional uint32 secondaryColor = 2; +} + +message EmptyTheme { +} + +message ThemeColors { + optional ColorPair backgroundColors = 1; + optional ColorPair textColors = 2; +} + +message ColorScheme { + optional int32 scheme = 2; +} + +enum SessionRestoreOption { + SESSION_RESTORE_OPTION_UNKNOWN = 0; + SESSION_RESTORE_OPTION_NONE = 1; + SESSION_RESTORE_OPTION_REQUIRE = 2; + SESSION_RESTORE_OPTION_TRY = 3; + SESSION_RESTORE_OPTION_STORE = 4; +} \ No newline at end of file diff --git a/vending-app/src/main/proto/Timestamp.proto b/play-services-core-proto/src/main/proto/Timestamp.proto similarity index 100% rename from vending-app/src/main/proto/Timestamp.proto rename to play-services-core-proto/src/main/proto/Timestamp.proto diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle index a348337ce1..461baa980e 100644 --- a/play-services-core/build.gradle +++ b/play-services-core/build.gradle @@ -5,6 +5,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' configurations { mapboxRuntimeOnly @@ -105,6 +106,11 @@ dependencies { implementation "androidx.credentials:credentials:$credentialsVersion" implementation "androidx.work:work-runtime-ktx:$workVersion" + + implementation project(":play-services-payments") + + implementation "com.google.guava:guava:$guavaVersion" + implementation "com.google.crypto.tink:tink-android:$tinkVersion" } android { @@ -144,10 +150,6 @@ android { } } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.10" - } - sourceSets { main { java.srcDirs += 'src/main/kotlin' diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index a17d7f03ef..9ebd2a887f 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -183,28 +183,6 @@ android:name="android.hardware.camera" android:required="false" /> - pair.first.order = idx pair diff --git a/play-services-fido/core/build.gradle b/play-services-fido/core/build.gradle index f54573dd1a..433c91c522 100644 --- a/play-services-fido/core/build.gradle +++ b/play-services-fido/core/build.gradle @@ -31,7 +31,7 @@ dependencies { implementation "com.android.volley:volley:$volleyVersion" implementation 'com.upokecenter:cbor:4.5.2' - implementation 'com.google.guava:guava:31.1-android' + implementation "com.google.guava:guava:$guavaVersion" implementation 'com.google.zxing:core:3.5.2' implementation 'com.squareup.okhttp3:okhttp:4.12.0' diff --git a/play-services-location/core/src/main/kotlin/org/microg/gms/location/ui/LocationAllAppsFragment.kt b/play-services-location/core/src/main/kotlin/org/microg/gms/location/ui/LocationAllAppsFragment.kt index c47192ea2a..6f2679fd4d 100644 --- a/play-services-location/core/src/main/kotlin/org/microg/gms/location/ui/LocationAllAppsFragment.kt +++ b/play-services-location/core/src/main/kotlin/org/microg/gms/location/ui/LocationAllAppsFragment.kt @@ -67,7 +67,7 @@ class LocationAllAppsFragment : PreferenceFragmentCompat() { pref.key = "pref_location_app_" + app.first pref }.sortedBy { - it.title.toString().toLowerCase() + it.title.toString().lowercase() }.mapIndexed { idx, pair -> pair.order = idx pair diff --git a/play-services-payments/build.gradle b/play-services-payments/build.gradle new file mode 100644 index 0000000000..38681225c3 --- /dev/null +++ b/play-services-payments/build.gradle @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' + +android { + namespace "com.google.android.gms.payments" + + compileSdkVersion androidCompileSdk + buildToolsVersion "$androidBuildVersionTools" + + buildFeatures { + aidl = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.10' + } + + defaultConfig { + versionName version + minSdkVersion androidMinSdk + targetSdkVersion androidTargetSdk + } + + compileOptions { + sourceCompatibility = 1.8 + targetCompatibility = 1.8 + } + + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + api project(':play-services-base') + api project(':play-services-basement') + implementation project(':play-services-base-core') + implementation project(':play-services-core-proto') + + implementation "com.squareup.wire:wire-runtime:$wireVersion" + implementation "com.squareup.wire:wire-grpc-client:$wireVersion" + // Compose + def composeBom = platform('androidx.compose:compose-bom:2024.04.00') + implementation composeBom + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" + implementation "io.coil-kt:coil-compose:2.4.0" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" + + + implementation "com.google.guava:guava:$guavaVersion" + implementation "com.google.crypto.tink:tink-android:$tinkVersion" + annotationProcessor project(':safe-parcel-processor') +} diff --git a/play-services-payments/src/main/AndroidManifest.xml b/play-services-payments/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0d3515605f --- /dev/null +++ b/play-services-payments/src/main/AndroidManifest.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/ComponentTreeManager.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/ComponentTreeManager.kt new file mode 100644 index 0000000000..38da1707f8 --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/ComponentTreeManager.kt @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import android.util.Log +import org.microg.vending.billing.proto.ComponentTreeNode +import org.microg.vending.billing.proto.ContainerNodeExtension +import org.microg.vending.billing.proto.ConditionalNodeExtension +import org.microg.vending.billing.proto.FullSheetNodeExtension +import org.microg.vending.billing.proto.LayoutModeProto +import org.microg.vending.billing.proto.ScrollNodeExtension + +class ComponentTreeManager { + + companion object { + private const val TAG = "ComponentTreeManager" + + private const val EXT_CONTAINER = 214299793 + private const val EXT_CONDITIONAL = 231420908 + private const val EXT_FULL_SHEET = 264434503 + private const val EXT_SCROLL = 229613734 + private const val EXT_TERMINAL = 265707483 + private const val EXT_EMPTY = 236049775 + private const val EXT_BUTTON = 232057537 + private const val EXT_TEXT_INPUT = 213678542 + private const val EXT_FORMATTED_TEXT = 213712846 + private const val EXT_SCROLL_VARIANT = 229613736 + + private val LEAF_NODE_TYPES = setOf( + EXT_TERMINAL, EXT_EMPTY, EXT_BUTTON, + EXT_TEXT_INPUT, EXT_FORMATTED_TEXT, EXT_SCROLL_VARIANT + ) + } + + private var currentTree: ComponentTreeNode? = null + + private var nodeMap = mutableMapOf() + + private var initialConditionValues = mutableMapOf() + + fun initTree(tree: ComponentTreeNode?) { + currentTree = tree + nodeMap.clear() + initialConditionValues.clear() + if (tree != null) { + flattenTree(tree, nodeMap, overwrite = true) + extractInitialConditionValues(tree) + } + Log.d(TAG, "initTree: root nodeId=${tree?.nodeId}, nodeTypeId=${tree?.nodeTypeId}, " + + "nodeMap=${nodeMap.size}, initCondVals=$initialConditionValues") + } + + fun getInitialConditionValues(): Map = initialConditionValues.toMap() + + private fun extractInitialConditionValues(node: ComponentTreeNode) { + if (node.nodeTypeId == EXT_CONDITIONAL) { + val condRef = node.conditionRef + val condValue = node.conditionalExt?.conditionValue + if (condRef != null && condValue != null) { + initialConditionValues[condRef] = condValue + } + } + for (child in getChildren(node)) { + extractInitialConditionValues(child) + } + } + + fun mergeFragments(fragments: List) { + val root = currentTree ?: return + if (fragments.isEmpty()) { + Log.d(TAG, "mergeFragments: no fragments, skip") + return + } + + Log.d(TAG, "mergeFragments: ${fragments.size} fragments, nodeIds=${fragments.map { it.nodeId }}") + + for (fragment in fragments) { + val nodeId = fragment.nodeId ?: continue + if (nodeMap.containsKey(nodeId)) { + nodeMap[nodeId] = fragment + for (child in getChildren(fragment)) { + flattenTree(child, nodeMap, overwrite = false) + } + Log.d(TAG, "mergeFragments: replaced nodeId=$nodeId") + } else { + Log.w(TAG, "mergeFragments: nodeId=$nodeId not found in nodeMap, skip") + } + } + + Log.d(TAG, "mergeFragments: nodeMap=${nodeMap.size} nodes after merge") + + currentTree = rebuildTree(root, nodeMap) + Log.d(TAG, "mergeFragments: rebuild complete") + } + + fun getTree(): ComponentTreeNode? = currentTree + + // 1=RELATIVE(Box), 2=FLEX(Column) + fun getLayoutModes(): Map { + val result = mutableMapOf() + for ((_, node) in nodeMap) { + if (node.nodeTypeId != EXT_CONTAINER) continue + val condRef = node.conditionRef ?: continue + val layoutMode = node.containerExt?.layoutMode ?: LayoutModeProto.LAYOUT_MODE_FLEX + result[condRef] = layoutMode + } + return result + } + + fun reset() { + currentTree = null + nodeMap.clear() + initialConditionValues.clear() + } + + private fun flattenTree(node: ComponentTreeNode, map: MutableMap, overwrite: Boolean) { + val nodeId = node.nodeId ?: return + if (overwrite || !map.containsKey(nodeId)) { + map[nodeId] = node + } + for (child in getChildren(node)) { + flattenTree(child, map, overwrite) + } + } + + private fun getChildren(node: ComponentTreeNode): List { + return when (node.nodeTypeId) { + EXT_CONTAINER -> { + val ext = node.containerExt ?: return emptyList() + val result = mutableListOf() + result.addAll(ext.children) + ext.footer?.let { result.add(it) } + result + } + EXT_CONDITIONAL -> { + node.conditionalExt?.children ?: emptyList() + } + EXT_FULL_SHEET -> { + listOfNotNull(node.fullSheetExt?.child) + } + EXT_SCROLL -> { + listOfNotNull(node.scrollExt?.child) + } + else -> { + if (node.nodeTypeId !in LEAF_NODE_TYPES) { + Log.w(TAG, "Unhandled tree nodeTypeId=${node.nodeTypeId}, nodeId=${node.nodeId}") + } + emptyList() + } + } + } + + /** + * Recursively rebuild the component tree from the root. + * If a node or any of its descendants has been replaced in the nodeMap, + * the child node references in the extension data need to be rebuilt. + */ + private fun rebuildTree( + originalNode: ComponentTreeNode, + nodeMap: Map + ): ComponentTreeNode { + val nodeId = originalNode.nodeId ?: return originalNode + val currentNode = nodeMap[nodeId] ?: return originalNode + + // Get the current child nodes and recursively rebuild each one + val currentChildren = getChildren(currentNode) + if (currentChildren.isEmpty()) { + return currentNode // Leaf node — return directly + } + + val rebuiltChildren = currentChildren.map { child -> + val childId = child.nodeId + if (childId != null && nodeMap.containsKey(childId)) { + rebuildTree(child, nodeMap) + } else { + child + } + } + + // Rebuild child node references by nodeTypeId + return setChildren(currentNode, rebuiltChildren) + } + + private fun setChildren( + node: ComponentTreeNode, + children: List + ): ComponentTreeNode { + return when (node.nodeTypeId) { + EXT_CONTAINER -> { + val ext = node.containerExt ?: ContainerNodeExtension() + // Keep the original footer if it was the last item in the children list and a footer existed originally + val originalFooter = node.containerExt?.footer + val newChildren: List + val newFooter: ComponentTreeNode? + if (originalFooter != null && children.isNotEmpty()) { + // The last one may be a footer + val footerNodeId = originalFooter.nodeId + if (children.last().nodeId == footerNodeId) { + newChildren = children.dropLast(1) + newFooter = children.last() + } else { + newChildren = children + newFooter = null + } + } else { + newChildren = children + newFooter = null + } + node.copy( + containerExt = ext.copy( + children = newChildren, + footer = newFooter + ) + ) + } + EXT_CONDITIONAL -> { + val ext = node.conditionalExt ?: ConditionalNodeExtension() + node.copy( + conditionalExt = ext.copy(children = children) + ) + } + EXT_FULL_SHEET -> { + node.copy( + fullSheetExt = (node.fullSheetExt ?: FullSheetNodeExtension()).copy( + child = children.firstOrNull() + ) + ) + } + EXT_SCROLL -> { + node.copy( + scrollExt = (node.scrollExt ?: ScrollNodeExtension()).copy( + child = children.firstOrNull() + ) + ) + } + else -> node + } + } +} diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/EventEngine.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/EventEngine.kt new file mode 100644 index 0000000000..b25eb2045d --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/EventEngine.kt @@ -0,0 +1,283 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import android.util.Log +import org.microg.vending.billing.proto.* + +/** + * Event engine: trigger/condition/resultingAction indexing + broadcast + * + * Builds conditionGraph / resultingActionGraph index tables + * Broadcast: per-target independent condition evaluation + * Condition evaluation: VALUE_MATCH (type=1) + * Action execution: + * 233806715 DataResultingAction — STATE_CHANGE / ENABLEMENT_CHANGE + * 233780160 InfrastructureAction — SUBMIT / FINISH + * 265929774 AnimatedImageAction — animation state (NOT_STARTED/RUNNING/COMPLETED) + * 238549017 ConditionValueAction — condValue change → UI branch switch + */ +class EventEngine { + + companion object { + private const val TAG = "EventEngine" + } + + private var conditionGraph = mutableMapOf>() + private var resultingActionGraph = mutableMapOf>() + + private var components = mutableMapOf() + + private var executionStates = mutableMapOf() + + // 0=UNKNOWN, 1=NOT_STARTED, 2=RUNNING, 3=COMPLETED + private var animatedImageStates = mutableMapOf() + + /** + * Build conditionGraph / resultingActionGraph index tables from all PageElements + */ + fun rebuildGraphs(componentMap: Map) { + conditionGraph.clear() + resultingActionGraph.clear() + components.clear() + executionStates.clear() + + for ((componentId, pe) in componentMap) { + components[componentId] = pe + + // Record the initial execution state + val state = pe.dataValue?.dataState?.executionState ?: FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_UNKNOWN + executionStates[componentId] = state + + for (condition in pe.conditionList) { + for (dataId in condition.dataIds) { + conditionGraph.getOrPut(dataId) { mutableSetOf() }.add(componentId) + } + } + + for (action in pe.resultingActionList) { + for (dataId in action.dataIds) { + resultingActionGraph.getOrPut(dataId) { mutableSetOf() }.add(componentId) + } + } + } + + Log.d(TAG, "rebuildGraphs: ${components.size} components, condGraph=${conditionGraph.keys}, actionGraph=${resultingActionGraph.keys}") + } + + fun onComponentCompleted(completedComponentId: Long): List { + executionStates[completedComponentId] = FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED + + val pe = components[completedComponentId] ?: return emptyList() + + val trigger = pe.triggerList.firstOrNull() ?: return emptyList() + val dataIds = trigger.dataIds + if (dataIds.isEmpty()) return emptyList() + + Log.d(TAG, "onComponentCompleted: id=$completedComponentId, broadcasting dataIds=$dataIds") + return broadcast(dataIds) + } + + fun onButtonClick(dataIds: List): List { + Log.d(TAG, "onButtonClick: dataIds=$dataIds") + return broadcast(dataIds) + } + + private fun broadcast(dataIds: List): List { + val results = mutableListOf() + + for (dataId in dataIds) { + // Process each resultingAction target subscribed to this dataId independently + val actionTargets = resultingActionGraph[dataId] ?: continue + for (targetCid in actionTargets) { + val targetPe = components[targetCid] ?: continue + + // Per-target condition check: only check the conditions that this target itself has subscribed to for this dataId + val relevantConditions = targetPe.conditionList.filter { dataId in it.dataIds } + var targetSatisfied = true + for (condition in relevantConditions) { + val satisfied = evaluateCondition(targetCid, condition) + val negated = condition.negated ?: false + val finalResult = if (negated) !satisfied else satisfied + if (!finalResult) { + Log.d(TAG, "broadcast: dataId=$dataId target=$targetCid blocked by condition") + targetSatisfied = false + break + } + } + + if (!targetSatisfied) continue + + // Condition satisfied → execute the resultingActions that this target has subscribed to for this dataId + val matchingActions = targetPe.resultingActionList.filter { dataId in it.dataIds } + for (action in matchingActions) { + val result = executeAction(targetCid, action) + if (result != null) { + results.add(result) + // After RUN_VALIDATION passes, chain into followUpTrigger.dataIds + // so the downstream AES components (subscribed to followUp dataIds) + // get notified to start encryption. + if (result is ActionResult.ValidationPassed) { + val followUpIds = action.followUpTrigger?.dataIds.orEmpty() + if (followUpIds.isNotEmpty()) { + Log.d(TAG, "broadcast: followUpTrigger from cid=$targetCid → dataIds=$followUpIds") + results.addAll(broadcast(followUpIds)) + } + } + } + } + } + } + + return results + } + + private fun evaluateCondition(componentId: Long, condition: ConditionRule): Boolean { + val dsc = condition.dataStateCondition ?: return true + val matchType = dsc.matchType ?: return true + + return when (matchType) { + 1 -> { + val targetState = FunctionalDataExecutionState.fromValue(dsc.matchValue ?: 0) + ?: FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_UNKNOWN + val currentState = executionStates[componentId] + ?: FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_UNKNOWN + val result = currentState == targetState + Log.d(TAG, "evaluateCondition: id=$componentId, current=$currentState, target=$targetState, result=$result") + result + } + else -> { + Log.d(TAG, "evaluateCondition: unsupported matchType=$matchType, returning true") + true + } + } + } + + /** + * Execute action: dispatch based on the resultingAction extension type + */ + private fun executeAction(targetCid: Long, action: ResultingActionRule): ActionResult? { + // Check for DataResultingAction (233806715): change state + action.dataResultingAction?.let { dra -> + when (dra.resultActionType) { + DataResultingAction.ResultingActionType.RESULTING_ACTION_TYPE_ENABLEMENT_STATE_CHANGE -> { + val newEnablement = dra.enablementState ?: 0 + Log.d(TAG, "executeAction: id=$targetCid ENABLEMENT_CHANGE → $newEnablement") + // enablementState: 1=ENABLED, 2=DISABLED + return ActionResult.EnablementChange(targetCid, newEnablement) + } + DataResultingAction.ResultingActionType.RESULTING_ACTION_TYPE_FUNCTIONAL_DATA_EXECUTION_STATE_CHANGE -> { + val newState = dra.executionState + ?: return null + Log.d(TAG, "executeAction: id=$targetCid STATE_CHANGE → $newState") + executionStates[targetCid] = newState + return ActionResult.StateChange(targetCid, newState) + } + DataResultingAction.ResultingActionType.RESULTING_ACTION_TYPE_RUN_VALIDATION -> { + // Input-field validation. We always pass (the field has text; + // a real GMS impl would also evaluate regex etc.). Returning + // ValidationPassed lets broadcast() chain into followUpTrigger. + Log.d(TAG, "executeAction: id=$targetCid RUN_VALIDATION → pass") + return ActionResult.ValidationPassed(targetCid) + } + + else -> { + Log.d(TAG, "executeAction: id=$targetCid unhandled dataResultingAction type=${dra.resultActionType}") + } + } + } + + // Check for AnimatedImageAction (265929774): change AnimatedImage state + action.animatedImageAction?.let { aia -> + val newState = aia.animatedImageState ?: 0 + Log.d(TAG, "executeAction: id=$targetCid ANIMATED_IMAGE_STATE → $newState (0=UNKNOWN,1=NOT_STARTED,2=RUNNING,3=COMPLETED)") + animatedImageStates[targetCid] = newState + return ActionResult.AnimatedImageStateChange(targetCid, newState) + } + + // Check for ConditionValueAction (238549017): change condValue → UI switch + // Changes the condValue of the condition container after a button click + action.conditionValueAction?.let { cva -> + val newValue = cva.conditionValue ?: 0 + Log.d(TAG, "executeAction: id=$targetCid CONDITION_VALUE_CHANGE → $newValue") + return ActionResult.ConditionValueChange(targetCid, newValue) + } + + //SUBMIT/FINISH + action.infrastructureAction?.let { ia -> + return when (ia.resultActionType) { + ResultingActionType.RESULTING_ACTION_TYPE_SUBMIT -> { + Log.d(TAG, "executeAction: id=$targetCid SUBMIT") + ActionResult.Submit + } + ResultingActionType.RESULTING_ACTION_TYPE_FINISH -> { + val resultCode = ia.finishParams?.resultCode ?: ResultCode.RESULT_CODE_UNKNOWN + val finishParams = ia.finishParams + Log.d(TAG, "executeAction: id=$targetCid FINISH code=$resultCode") + ActionResult.Finish(resultCode, finishParams) + } + else -> { + Log.d(TAG, "executeAction: id=$targetCid unknown type=${ia.resultActionType}") + null + } + } + } + + return null + } + + fun getExecutionState(componentId: Long): FunctionalDataExecutionState { + return executionStates[componentId] + ?: FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_UNKNOWN + } + + fun setExecutionState(componentId: Long, state: FunctionalDataExecutionState) { + executionStates[componentId] = state + } + + /** + * Get the current state of the AnimatedImage + * 0=UNKNOWN, 1=NOT_STARTED, 2=RUNNING, 3=COMPLETED + */ + fun getAnimatedImageState(componentId: Long): Int { + return animatedImageStates[componentId] ?: 1 // default NOT_STARTED + } + + fun reset() { + conditionGraph.clear() + resultingActionGraph.clear() + components.clear() + executionStates.clear() + animatedImageStates.clear() + } +} + +sealed class ActionResult { + object Submit : ActionResult() + data class Finish( + val resultCode: ResultCode, + val finishParams: FinishActionParams? = null + ) : ActionResult() + data class StateChange( + val componentId: Long, + val newState: FunctionalDataExecutionState + ) : ActionResult() + data class AnimatedImageStateChange( + val componentId: Long, + val newState: Int // 0=UNKNOWN, 1=NOT_STARTED, 2=RUNNING, 3=COMPLETED + ) : ActionResult() + data class EnablementChange( + val componentId: Long, + val enablementState: Int // 1=ENABLED, 2=DISABLED + ) : ActionResult() + data class ConditionValueChange( + val componentId: Long, + val newConditionValue: Int + ) : ActionResult() + data class ValidationPassed( + val componentId: Long + ) : ActionResult() +} diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/GenericDelegatorChimeraActivityX.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/GenericDelegatorChimeraActivityX.kt new file mode 100644 index 0000000000..5d2cf584ea --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/GenericDelegatorChimeraActivityX.kt @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.addCallback +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.wallet.ACTION_BENDER3 +import com.google.android.gms.wallet.EXTRA_BENDER3_BUYFLOW_CONFIG +import com.google.android.gms.wallet.EXTRA_BENDER3_ENCRYPTED_PARAMS +import com.google.android.gms.wallet.EXTRA_BENDER3_O2_ACTION_TOKEN +import com.google.android.gms.wallet.EXTRA_BENDER3_UNENCRYPTED_PARAMS +import com.google.android.gms.wallet.shared.BuyFlowConfig +import com.google.android.gms.wallet.activity.PaymentState.* +import kotlinx.coroutines.launch +import org.microg.vending.billing.proto.FinishActionParams +import org.microg.vending.billing.proto.IapResponseWrapper +import org.microg.vending.billing.proto.ResultCode + +/** + * Bender3 Payment Activity — handles the ACTION_BENDER3 Intent and carries the IAP 3DS2 verification flow. + */ +class GenericDelegatorChimeraActivityX : ComponentActivity() { + + companion object { + private const val TAG = "GenericDelegatorX" + } + + private lateinit var paymentContext: PaymentContext + private lateinit var paymentController: PaymentController + private lateinit var resultBuilder: WidgetResultIntentBuilder + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val action = requireNotNull(intent.action) { "Intent action must not be null" } + if (action != ACTION_BENDER3) { + throw SecurityException("Unsupported intent action: $action") + } + + paymentContext = PaymentContext(this) + paymentController = PaymentController(paymentContext) + resultBuilder = WidgetResultIntentBuilder(callingPackage = callingPackage) + + onBackPressedDispatcher.addCallback(this) { + Log.d(TAG, "back pressed — triggering CANCEL path") + paymentContext.updateState(Cancelled) + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + + handleIntent() + } + + private fun handleIntent() { + val o2ActionToken = intent.getByteArrayExtra(EXTRA_BENDER3_O2_ACTION_TOKEN) + val encryptedParams = intent.getByteArrayExtra(EXTRA_BENDER3_ENCRYPTED_PARAMS) + val unencryptedParams = intent.getByteArrayExtra(EXTRA_BENDER3_UNENCRYPTED_PARAMS) + + Log.d(TAG, "o2ActionToken: ${o2ActionToken?.size ?: "null"} bytes") + + if (o2ActionToken != null) { + val encryptedData = try { + val wrapper = IapResponseWrapper.ADAPTER.decode(o2ActionToken) + wrapper.encryptedData?.toByteArray() + } catch (e: Exception) { + Log.w(TAG, "unable to decode o2ActionToken", e) + null + } + + if (encryptedData == null) { + Log.e(TAG, "Unable to initialize bender3 widget with token") + setResult(RESULT_FIRST_USER, Intent()) + finish() + return + } + + lifecycleScope.launch { + startPaymentFlow(encryptedData, unencryptedParams) + } + return + } + + if (encryptedParams == null && unencryptedParams == null) { + Log.w(TAG, "unable to initialize widget: both encryptedParams and unencryptedParams are null") + setResult(RESULT_FIRST_USER, Intent()) + finish() + return + } + + lifecycleScope.launch { + startPaymentFlow(encryptedParams, unencryptedParams) + } + } + + private fun startPaymentFlow(encryptedParams: ByteArray?, unencryptedParams: ByteArray?) { + val buyFlowConfig = intent.getParcelableExtra(EXTRA_BENDER3_BUYFLOW_CONFIG) + if (buyFlowConfig == null) { + Log.w(TAG, "BuyFlowConfig is null, returning ERROR") + setResult(RESULT_FIRST_USER, Intent()) + finish() + return + } + + setContent { + val paymentState by paymentContext.paymentState.collectAsState() + + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.fillMaxSize()) { + when (val state = paymentState) { + is Idle, is Initializing, + is Initialized, is Submitted, is Submitting -> { + val (condManaged, condVisible) = paymentContext.getComponentVisibility() + val inputTextState by paymentContext.inputTextStateFlow.collectAsState() + PaymentsScreen( + pageElements = paymentContext.currentPageElements, + elementMap = paymentContext.componentElementMap, + layoutModes = paymentContext.getLayoutModes(), + condManaged = condManaged, + condVisible = condVisible, + animatedImageStateProvider = { cid -> + paymentContext.getAnimatedImageState(cid) + }, + inputTextProvider = { cid -> + inputTextState[cid] ?: "" + }, + onTextChange = { cid, text -> + paymentContext.updateInputText(cid, text) + }, + onButtonClick = { componentId -> + handleButtonClick(componentId) + } + ) + } + + is Completed -> { + LaunchedEffect(Unit) { + resultBuilder.serverAnalyticsToken = paymentContext.serverAnalyticsToken + val (resultCode, resultIntent) = when (state.resultCode) { + ResultCode.RESULT_CODE_SUCCESS -> { + val fp = state.finishParams ?: run { + Log.w(TAG, "SUCCESS but finishParams is null, using default-empty finishParams") + FinishActionParams() + } + RESULT_OK to resultBuilder.buildSuccessIntent(fp) + } + ResultCode.RESULT_CODE_CANCEL -> { + RESULT_CANCELED to resultBuilder.buildCancelIntent(state.finishParams) + } + ResultCode.RESULT_CODE_UNKNOWN -> { + RESULT_FIRST_USER to resultBuilder.buildErrorIntent(null) + } + ResultCode.RESULT_CODE_ERROR -> { + RESULT_FIRST_USER to resultBuilder.buildErrorIntent(state.finishParams) + } + } + setResult(resultCode, resultIntent) + finish() + } + } + + is Error -> { + LaunchedEffect(Unit) { + setResult(RESULT_FIRST_USER, resultBuilder.buildErrorIntent()) + finish() + } + } + + is Cancelled -> { + LaunchedEffect(Unit) { + setResult(RESULT_CANCELED, resultBuilder.buildCancelIntent()) + finish() + } + } + } + + IconButton( + onClick = { onBackPressedDispatcher.onBackPressed() }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + } + } + } + + lifecycleScope.launch { + try { + paymentController.startPaymentFlow(buyFlowConfig, encryptedParams, unencryptedParams) + } catch (e: Exception) { + paymentContext.updateState(Error("Payment flow failed: ${e.message}", e)) + } + } + } + + private fun handleButtonClick(componentId: Long) { + lifecycleScope.launch { + paymentController.onUserAction(componentId) + } + } + + override fun onDestroy() { + if (::paymentController.isInitialized) { + paymentController.destroy() + } + super.onDestroy() + } +} + + diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentContext.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentContext.kt new file mode 100644 index 0000000000..faeeca0cf4 --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentContext.kt @@ -0,0 +1,1038 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import android.accounts.AccountManager +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.util.Base64 +import android.util.Log +import androidx.core.net.toUri +import com.google.android.gms.wallet.OAUTH_SCOPE_SIERRA +import com.google.android.gms.wallet.shared.BuyFlowConfig +import com.google.common.io.BaseEncoding +import com.google.crypto.tink.subtle.EllipticCurves +import com.google.crypto.tink.subtle.EllipticCurves.CurveType +import com.google.crypto.tink.subtle.EllipticCurves.PointFormatType +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.json.JSONException +import org.json.JSONObject +import org.microg.gms.checkin.LastCheckinInfo.Companion.read +import org.microg.gms.common.Constants +import org.microg.gms.profile.Build +import org.microg.vending.billing.proto.ClientToken +import org.microg.vending.billing.proto.DataStateInfo +import org.microg.vending.billing.proto.DataValue +import org.microg.vending.billing.proto.FinishActionParams +import org.microg.vending.billing.proto.FunctionalDataExecutionState +import org.microg.vending.billing.proto.IapCommonResponse +import org.microg.vending.billing.proto.InitializeRequest +import org.microg.vending.billing.proto.LayoutModeProto +import org.microg.vending.billing.proto.PageElement +import org.microg.vending.billing.proto.ResultingActionType +import org.microg.vending.billing.proto.SubmitRequest +import org.microg.vending.billing.proto.ValidityState +import java.security.GeneralSecurityException +import java.security.PrivateKey +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class PaymentContext(private val context: Context) { + + companion object { + private const val TAG = "PaymentContext" + private const val UI_THEME_BASE64 = "CogCCP3//////////wESHBoKGggSBgj/////DxoOCgIIAhoIEgYIlKbM+A8ox+aLfrq03vAH0wEK0AEI/v//////////ARIKGggaBgoEMgIIAhj+//////////8BKJHpl2aKyb6xBqIBCp0BCP///////////wESdho3GjUKIBoLCAESBwgBFQAAQEIyCwgBEgcIARUAAEBCcgQgAygDIhEKDwjGjpH6DxIHCAEVAACAQBo7CgIIAho1CiAaCwgBEgcIARUAAEBCMgsIARIHCAEVAABAQnIEIAMoAyIRCg8IxY+T/g8SBwgBFQAAgEAY////////////ASjbv9l+2v3L9QcECgIIARABInMIARD9//////////8BIP7//////////wEqIwoLCP7//////////wE4q++/atr6/tMGCwj///////////8BMP///////////wE6KQoYCP///////////wEo7ZPOfuqe8fQHAggCOIa/zn6y+PP0BwQKAhAB" + + private fun dumpBase64(marker: String, bytes: ByteArray) { + Log.d(TAG, "===== $marker raw base64 (${bytes.size} bytes) BEGIN =====") + Base64.encodeToString(bytes, Base64.NO_WRAP) + .chunked(200) + .forEach { Log.d(TAG, "[$marker] $it") } + Log.d(TAG, "===== $marker END =====") + } + } + + private val _paymentState = MutableStateFlow(PaymentState.Idle) + val paymentState: StateFlow = _paymentState.asStateFlow() + + private var _oauthToken: String = "" + val oauthToken: String get() = _oauthToken + + private var _clientToken: ClientToken.Info1? = null + val clientToken: ClientToken.Info1? get() = _clientToken + + private var _ephemeralPrivateKey: ByteArray = ByteArray(0) + val ephemeralPrivateKey: ByteArray get() = _ephemeralPrivateKey + + // field 290848975 for encryption + private var _mcReqSessionKey: ByteArray? = null + val mcReqSessionKey: ByteArray? get() = _mcReqSessionKey + + private var _serverAnalyticsToken: ByteArray = ByteArray(0) + val serverAnalyticsToken: ByteArray get() = _serverAnalyticsToken + + private val _treeManager = ComponentTreeManager() + val componentTreeManager: ComponentTreeManager get() = _treeManager + + private val _eventEngine = EventEngine() + val eventEngine: EventEngine get() = _eventEngine + + // Full snapshot — visibility controlled by the condition tree + private var _currentPageElements: List = emptyList() + val currentPageElements: List get() = _currentPageElements + + private var _componentElementMap = mutableMapOf() + val componentElementMap: Map get() = _componentElementMap + + private val _inputStateFlow = MutableStateFlow>(emptyMap()) + val inputTextStateFlow: StateFlow> get() = _inputStateFlow + + fun getInputText(cid: Long): String { + val text = _inputStateFlow.value[cid] ?: "" + Log.d(TAG, "getInputText: cid=$cid, text.len=${text.length}, text=${if (text.length > 20) text.take(20) + "..." else text}") + return text + } + + fun updateInputText(cid: Long, text: String) { + _inputStateFlow.value = _inputStateFlow.value.toMutableMap().apply { put(cid, text) } + Log.d(TAG, "updateInputText: cid=$cid, text.len=${text.length}") + } + + private fun refreshCurrentPageElements() { + _currentPageElements = _componentElementMap.values.toList() + Log.d(TAG, "refreshCurrentPageElements: ${_currentPageElements.size} elements") + } + + private val httpClient by lazy { OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .callTimeout(150, TimeUnit.SECONDS) + .build() } + + fun updateState(newState: PaymentState) { + val detail = when (newState) { + is PaymentState.Error -> " (${newState.message})" + is PaymentState.Completed -> " (resultCode=${newState.resultCode})" + is PaymentState.Submitting -> " Submitting" + is PaymentState.Submitted -> " Submitted" + else -> "" + } + Log.d(TAG, "State transition: ${_paymentState.value::class.simpleName} -> ${newState::class.simpleName}$detail") + if (newState is PaymentState.Error && newState.exception != null) { + Log.w(TAG, " Error cause:", newState.exception) + } + _paymentState.value = newState + } + + /** + * Initialize the payment flow + * 1. Get OAuth token + * 2. Create device env info and client token + * 3. Send initialize request + * 4. Return initialize response + */ + suspend fun initialize(buyFlowConfig: BuyFlowConfig, encryptedParams: ByteArray?, unencryptedParams: ByteArray?): IapCommonResponse? { + updateState(PaymentState.Initializing) + try { + val account = buyFlowConfig.applicationParameters?.buyerAccount + requireNotNull(account) { + "unable to initialize widget. buyFlowConfig:$buyFlowConfig account:$account" + } + + _oauthToken = withContext(Dispatchers.IO) { + runCatching { + AccountManager.get(context).blockingGetAuthToken(account, OAUTH_SCOPE_SIERRA, false) + } + .onFailure { Log.w(TAG, "Failed to acquire OAuth token for ${account.name}", it) } + .getOrNull() ?: "" + } + if (_oauthToken.isEmpty()) { + updateState(PaymentState.Error("Failed to get OAuth token")) + return null + } + val deviceEnvInfo = createDeviceEnvInfo(context) + if (deviceEnvInfo == null) { + updateState(PaymentState.Error("Failed to create deviceEnvInfo")) + return null + } + + val gsfId = try { read(context).androidId.toString(16) } catch (e: Exception) { "1" } + _clientToken = createClientTokenInfo1(context, deviceEnvInfo, gsfId, false) + + // Build initialize request + val uiThemeBytes = Base64.decode(UI_THEME_BASE64, Base64.DEFAULT).toByteString() + + val bodyBytes = InitializeRequest.Builder() + .clientToken(clientToken) + .uiTheme(uiThemeBytes) + .encryptedPayload(encryptedParams?.toByteString()) + .unencryptedPayload(unencryptedParams?.toByteString()) + .build() + .encode() + + dumpBase64("initializeRequest", bodyBytes) + + // Send initialize request + val response = initializeNetwork(_oauthToken, bodyBytes) + + val responseBytes = response?.body?.bytes() + + if (responseBytes == null) { + updateState(PaymentState.Error("Initialize request failed - no response")) + return null + } + + dumpBase64("initializeResponse", responseBytes) + + // Decode response + val initializeResponse = try { + IapCommonResponse.ADAPTER.decode(responseBytes) + } catch (e: Exception) { + Log.w(TAG, "Failed to decode response", e) + updateState(PaymentState.Error("Failed to decode response", e)) + return null + } + + val oldUnknown2 = clientToken?.unknown2 + val responseClientToken = initializeResponse.clientToken + val responseUnknown2 = responseClientToken?.unknown2 + if (responseUnknown2 != null) { + _clientToken = createClientTokenInfo1(context, deviceEnvInfo, gsfId, true).newBuilder() + .unknown2(responseUnknown2).build() + } else { + Log.w(TAG, "response.clientToken.unknown2 is null - keeping original") + } + + // Store component tree + updateComponentTree(initializeResponse, isInitial = true) + Log.d(TAG, "Component tree root nodeId=${_treeManager.getTree()?.nodeId}") + + updatePartialPageManagers(initializeResponse, isInitial = true) + refreshCurrentPageElements() + Log.d(TAG, "componentElementMap initialized with ${_componentElementMap.size} entries") + + return initializeResponse + } catch (e: Exception) { + updateState(PaymentState.Error("Initialize failed: ${e.message}", e)) + return null + } + } + + private suspend fun initializeNetwork(oauthToken: String, bodyBytes: ByteArray): Response? = withContext(Dispatchers.IO) { + Log.d(TAG, "initializeNetwork: sending request...") + Log.d(TAG, "Request body size: ${bodyBytes.size} bytes") + + val mediaType = "application/x-protobuf".toMediaType() + val requestBody = bodyBytes.toRequestBody(mediaType) + + val request = Request.Builder() + .url("https://payments-pa.googleapis.com/payments/apis-secure/ui2/purchasemanagerservice/initialize") + .post(requestBody) + .header("Content-Type", "application/x-protobuf") + .header("Authorization", "Bearer $oauthToken") + .header("X-Modality", "ANDROID_NATIVE") + .header( + "User-Agent", + "${Constants.GMS_PACKAGE_NAME}/${Constants.GMS_VERSION_CODE} " + + "(Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/${Build.ID})" + ) + .build() + + runCatching { + val response = httpClient.newCall(request).execute() + if (!response.isSuccessful) { + Log.w(TAG, "HTTP response not successful: ${response.code}, message: ${response.message}") + throw RuntimeException("HTTP ${response.code}") + } + response + }.onFailure { e -> + Log.w(TAG, "initializeNetwork failed", e) + }.getOrNull() + } + + /** + * Submit data to Google API + * @param pageElements The page elements to process + * @param heqc Deprecated: heqc is now managed internally via _heqcTree + * @param tokenization Tokenization header data + * @return The response from Google API + */ + suspend fun submit(tokenization: List): IapCommonResponse? = withContext(Dispatchers.IO) { + try { + val token = _oauthToken + + if (token.isEmpty()) { + return@withContext null + } + if (clientToken == null) { + return@withContext null + } + + val processedDataValues = _componentElementMap.values.mapNotNull { it.dataValue } + Log.d(TAG, "Using componentElementMap with ${_componentElementMap.size} entries, ${processedDataValues.size} dataValues") + val submitRequest = SubmitRequest.Builder() + .clientToken(clientToken) + .dataValue(processedDataValues.sortedWith(compareBy { it.componentId ?: 0 })) + .heqc(_treeManager.getTree()) + .build() + + val requestBytes = submitRequest.encode() + dumpBase64("submitRequest", requestBytes) + + // Send submit request + val response = submitNetwork(token, requestBytes, tokenization) + + val responseBytes = response?.body?.bytes() + if (responseBytes == null) { + Log.w(TAG, "responseBytes is null") + return@withContext null + } + + dumpBase64("submitResponse", responseBytes) + + // Decode response + val submitResponse = try { + IapCommonResponse.ADAPTER.decode(responseBytes) + } catch (e: Exception) { + Log.w(TAG, "Failed to decode response", e) + return@withContext null + } + + val responseClientToken = submitResponse.clientToken + val responseUnknown2 = responseClientToken?.unknown2 + if (responseUnknown2 != null) { + _clientToken = clientToken?.newBuilder()?.unknown2(responseUnknown2)?.build() + } + // Update heqc from submit response + updateComponentTree(submitResponse, isInitial = false) + + updatePartialPageManagers(submitResponse, isInitial = false) + refreshCurrentPageElements() + _eventEngine.rebuildGraphs(_componentElementMap) + + submitResponse + + } catch (e: Exception) { + Log.w(TAG, "EXCEPTION: ${e::class.simpleName}: ${e.message}") + null + } + } + + private suspend fun submitNetwork( + oauthToken: String, + bodyBytes: ByteArray, + tokenization: List + ): Response? = withContext(Dispatchers.IO) { + val mediaType = "application/x-protobuf".toMediaType() + val requestBody = bodyBytes.toRequestBody(mediaType) + val requestHeader = Request.Builder() + .url("https://payments-pa.googleapis.com/payments/apis-secure/ui2/purchasemanagerservice/submit") + .post(requestBody) + .header("Content-Type", "application/x-protobuf") + .header("Authorization", "Bearer $oauthToken") + .header("X-Modality", "ANDROID_NATIVE") + .header("User-Agent", "${Constants.GMS_PACKAGE_NAME}/${Constants.GMS_VERSION_CODE} (Linux; U; Android ${Build.VERSION.RELEASE}; ${localeToString(Locale.getDefault())}; ${Build.MODEL}; Build/${Build.ID})") + .header("Priority", "u=1, i") + + if (tokenization.isNotEmpty()) { + requestHeader.header("ees-s7e-mode", "proto") + if (tokenization.size % 2 == 0) { + for (i in tokenization.indices step 2) { + requestHeader.header(tokenization[i], tokenization[i + 1]) + } + } + } else { + Log.d(TAG, "[Header] tokenization is empty - no extra headers") + } + + runCatching { + val response = httpClient.newCall(requestHeader.build()).execute() + if (!response.isSuccessful) { + val errorBody = response.body.string() + Log.w(TAG, "[Response] HTTP error body = $errorBody") + null // body has been consumed; the response is no longer returned to the caller + } else { + response + } + }.onFailure { e -> + Log.w(TAG, "submitNetwork failed", e) + }.getOrNull() + } + + /** + * Process PageElement list - handle ECDH key generation, encryption, etc. + */ + private fun processDataValueList(list: List): List { + val result = ArrayList() + + for ((index, item) in list.withIndex()) { + val extensionValue = item.extensionFieldNumber ?: continue + val oldDataValue = item.dataValue ?: continue + + var dataValueBuilder = oldDataValue.newBuilder() + val dataStateInfoBuilder = oldDataValue.dataState?.newBuilder() ?: DataStateInfo.Builder() + + Log.d(TAG, ">>> processDataValueList extensionValue:${extensionValue}") + when (extensionValue) { + 217440216 -> { + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + } + + 223344552 -> { + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + } + + 223344553 -> { + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_NOT_STARTED) + + val existingCondValueExt = oldDataValue.conditionValueExt + if (existingCondValueExt != null) { + dataValueBuilder.conditionValueExt(existingCondValueExt) + } + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + Log.d(TAG, " [$index] 223344553 (Conditional Container) - id=${oldDataValue.componentId}, conditionValueExt.conditionValue=${existingCondValueExt?.conditionValue}") + } + + 223344555 -> { + dataStateInfoBuilder.dataRole(1).validityState(ValidityState.VALIDITY_VALID) + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + Log.d(TAG, " [$index] 223344555 (Generic Container)") + } + + 232057536 -> { + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_NOT_STARTED) + val existMessage = dataValueBuilder.message204201689 ?: DataValue.Message204201689() + val existMessageText = existMessage.text ?: "" + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + .message204201689(existMessage.newBuilder().text(existMessageText).unknown2(0).build()) + } + + 233780159 -> { + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_RUNNING) + + dataValueBuilder + .dataState(dataStateInfoBuilder.build()) + .unknown1(DataValue.Message239872231.Builder().unknown2(3).build()) + Log.d(TAG, " [$index] 233780159 (Card Container)") + } + + 264984587 -> { + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_NOT_STARTED) + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + Log.d(TAG, " [$index] 264984587 (Spacing)") + } + + 265527174 -> { + dataStateInfoBuilder.executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_NOT_STARTED) + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + + val animated = DataValue.AnimatedImageDataValueExtension.Builder() + .state(DataValue.AnimatedImageState.ANIMATED_IMAGE_STATE_NOT_STARTED) + .progress(0) + .build() + dataValueBuilder.animatedImageDataValueExtension(animated) + Log.d(TAG, " [$index] 265527174 (Animated Image)") + } + + 217437962 -> { + val cid = oldDataValue.componentId ?: 0L + val currentText = _inputStateFlow.value[cid] ?: "" + val submitConfig = oldDataValue.submitConfig ?: 0 + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState( + if (currentText.isEmpty()) + FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_NOT_STARTED + else + FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED + ) + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + + if (submitConfig == 1) { + dataValueBuilder.textInputDataValueExtension( + DataValue.TextInputDataValueExtension.Builder().text(currentText).build() + ) + } + Log.d(TAG, " [$index] 217437962 (TextInput) - cid=$cid, submitConfig=$submitConfig, text.len=${currentText.length}") + } + + 228971049 -> { + dataStateInfoBuilder.dataRole(1).validityState(ValidityState.VALIDITY_VALID) + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + Log.d(TAG, " [$index] 228971049 (TemplateText) - cid=${oldDataValue.componentId}") + } + + 232946268 -> { + // SMS auto-reader — functionality not yet implemented + dataStateInfoBuilder.dataRole(1).validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_NOT_STARTED) + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + Log.d(TAG, " [$index] 232946268 (SmsAutoReader) - cid=${oldDataValue.componentId}, not wired") + } + + 290848973 -> { + try { + val keyPair = EllipticCurves.generateKeyPair(CurveType.NIST_P256) + val publicKey = keyPair.public as ECPublicKey + val publicKeyHex = EllipticCurves.pointEncode(publicKey.params.curve, PointFormatType.UNCOMPRESSED, publicKey.w) + + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED) + + val ecPub = DataValue.EphemeralECPublicKey.Builder() + .ecdhPublicKey(BaseEncoding.base64().encode(publicKeyHex)) + .build() + + dataValueBuilder = DataValue.Builder() + .componentId(oldDataValue.componentId) + .unknown2(ByteString.EMPTY) + .dataState(dataStateInfoBuilder.build()) + .unknown5(oldDataValue.unknown5) + .ephemeralECPublicKey(ecPub) + + _ephemeralPrivateKey = (keyPair.private as ECPrivateKey).s.toByteArray() + + Log.d(TAG, " [$index] 290848973 (ECDH Key Gen) - publicKeyHex length=${publicKeyHex.size}") + + val cid = oldDataValue.componentId ?: 0L + _eventEngine.onComponentCompleted(cid) + } catch (e: GeneralSecurityException) { + throw IllegalStateException("Error generating ephemeral key pair.", e) + } + } + + 290848974 -> { + Log.d(TAG, " [$index] 290848974 (ECDH Key Agreement)") + val context = item.ecdhKeyAgreementContext + if (!ephemeralPrivateKey.isEmpty() && context != null) { + try { + val decodePublicKeyBytes = BaseEncoding.base64().decode(context.ecdhPublicKey!!) + + val publicKeyEcPoint = EllipticCurves.pointDecode(CurveType.NIST_P256, PointFormatType.UNCOMPRESSED, decodePublicKeyBytes) + val decodePrivateKey: PrivateKey = EllipticCurves.getEcPrivateKey(CurveType.NIST_P256, ephemeralPrivateKey) + val sharedSecret = EllipticCurves.computeSharedSecret(decodePrivateKey as ECPrivateKey, publicKeyEcPoint) + + val agreementPartyVInfo = BaseEncoding.base64().decode(context.agreementPartyVInfo!!) + val a = concatenateByteArrays( + byteArrayOf(0, 0, 0, 1), sharedSecret, + byteArrayOf(0, 0, 0, 0), + byteArrayOf(0, 0, 0, 0), intTo4BytesBE(agreementPartyVInfo.size), agreementPartyVInfo, + byteArrayOf(0, 0, 1, 0) + ).toByteString().sha256().toByteArray() + + val cReqSessionKey = ByteArray(16) + val cResSessionKey = ByteArray(16) + System.arraycopy(a, 0, cReqSessionKey, 0, 16) + System.arraycopy(a, 16, cResSessionKey, 0, 16) + + _mcReqSessionKey = cReqSessionKey + + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED) + + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + .sessionKeyExtension(DataValue.SessionKeyExtension.Builder() + .reqSessionKey(BaseEncoding.base64().encode(cResSessionKey)).build() + ) + Log.d(TAG, " [$index] 290848974 (ECDH Key Agreement) - sessionKey generated") + + val cid = oldDataValue.componentId ?: 0L + val actions = _eventEngine.onComponentCompleted(cid) + Log.d(TAG, " [$index] 290848974 event engine results: $actions") + } catch (e: GeneralSecurityException) { + Log.w(TAG, " ERROR - GeneralSecurityException: ${e.message}") + throw IllegalStateException("Error computing shared keys.", e) + } + } + } + + 290848975 -> { + val componentId = oldDataValue.componentId ?: 0L + val engineState = _eventEngine.getExecutionState(componentId) + val shouldEncrypt = engineState == FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_RUNNING || + engineState == FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED + Log.d(TAG, " [$index] 290848975 (AES) - id=$componentId, engineState=$engineState, shouldEncrypt=$shouldEncrypt") + + if (shouldEncrypt) { + val ext = item.encryptionActionExtension ?: PageElement.EncryptionActionExtension() + + val plainText = resolvePlainText(ext) + when { + ext.fieldNumber != null -> + Log.d(TAG, " [$index] AES plainText from cid=${ext.fieldNumber} reference, text.len=${plainText.size}") + ext.displayText == null -> + Log.w(TAG, " [$index] AES plainText source missing — encrypting empty") + } + val iv = BaseEncoding.base64().decode(ext.initializationVector ?: "") + val encryptionValue = try { + encryptJwe(ext.keyId, iv, plainText) + } catch (e: GeneralSecurityException) { + throw IllegalArgumentException("Error performing A128GCM encryption.", e) + } catch (e: JSONException) { + throw IllegalArgumentException("Error creating JWE protected header.", e) + } + + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED) + + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + .encryptionActionExtension(DataValue.EncryptionActionExtension.Builder() + .encryptionValue(encryptionValue).build() + ) + + _eventEngine.setExecutionState(componentId, FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED) + val broadcastResults = _eventEngine.onComponentCompleted(componentId) + Log.d(TAG, " [$index] 290848975 completed, broadcast results: $broadcastResults") + } else { + dataStateInfoBuilder.dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_NOT_STARTED) + + dataValueBuilder.dataState(dataStateInfoBuilder.build()) + .encryptionActionExtension(DataValue.EncryptionActionExtension.Builder() + .encryptionValue("").build() + ) + } + } + + else -> { + Log.w(TAG, " [$index] Unhandled extensionFieldNumber=$extensionValue, componentId=${oldDataValue.componentId}") + } + } + + result.add(dataValueBuilder.build()) + } + + return result + } + + /** + * Updates PageManager data (initialization or incremental update) + * init response: extract PageElement → processDataValueList → store into map + * submit response: toRemove → delete; toAddOrReplaceData → add → processDataValueList → store into map + * Performs AES encryption on the 290848975 component for the specified componentId, updating componentElementMap + * Called by PaymentController.triggerButtonAction() after the event engine has been triggered + */ + fun executeEncryptionForComponent(componentId: Long) { + val pe = _componentElementMap[componentId] + if (pe == null || pe.extensionFieldNumber != 290848975) { + Log.w(TAG, "executeEncryptionForComponent: id=$componentId not found or not 290848975") + return + } + val oldDv = pe.dataValue ?: return + + // Perform AES encryption + val ext = pe.encryptionActionExtension ?: PageElement.EncryptionActionExtension() + + val plainText = resolvePlainText(ext) + ext.fieldNumber?.let { sourceCid -> + val text = String(plainText) + Log.d(TAG, "executeEncryptionForComponent: id=$componentId fieldNumber=$sourceCid, inputText.len=${plainText.size}, inputText=${if (text.length > 50) text.take(50) + "..." else text}") + + if (sourceCid == 31L && text.isNotEmpty()) { + Log.d(TAG, "executeEncryptionForComponent: id=$componentId CReq template will be formatted with inputText") + } + } + + if (plainText.isEmpty()) { + Log.w(TAG, "executeEncryptionForComponent: id=$componentId skipped — plainText empty") + return + } + if (_mcReqSessionKey?.size != 16) { + Log.w(TAG, "executeEncryptionForComponent: invalid session key") + return + } + + val iv = BaseEncoding.base64().decode(ext.initializationVector ?: "") + val encryptionValue = encryptJwe(ext.keyId, iv, plainText) + + val dataStateInfoBuilder = (oldDv.dataState?.newBuilder() ?: DataStateInfo.Builder()) + .dataRole(1) + .validityState(ValidityState.VALIDITY_VALID) + .executionState(FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED) + + val updatedDv = oldDv.newBuilder() + .dataState(dataStateInfoBuilder.build()) + .encryptionActionExtension(DataValue.EncryptionActionExtension.Builder() + .encryptionValue(encryptionValue).build() + ) + .build() + + _componentElementMap[componentId] = pe.newBuilder().dataValue(updatedDv).build() + + _eventEngine.setExecutionState(componentId, FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_COMPLETED) + val broadcastResults = _eventEngine.onComponentCompleted(componentId) + + Log.d(TAG, "executeEncryptionForComponent: id=$componentId encrypted, JWE length=${encryptionValue.length}, broadcast=$broadcastResults") + } + + private fun updatePartialPageManagers( + response: IapCommonResponse, + isInitial: Boolean + ): List { + if (isInitial) { + + val pageElements = response.responseBody?.initializePartialPageProtoWrapper?.partialPage?.pageElements ?: emptyList() + + // Temporarily write into the map first, then build the event engine index, to ensure the event chain in processDataValueList can work + pageElements.forEach { pe -> + val cid = pe.dataValue?.componentId ?: return@forEach + _componentElementMap[cid] = pe + } + _eventEngine.rebuildGraphs(_componentElementMap) + + val processedDataValues = processDataValueList(pageElements) + + processedDataValues.forEach { processedDataValue -> + val componentId = processedDataValue.componentId ?: return@forEach + val originalPageElement = pageElements.find { it.dataValue?.componentId == componentId } ?: return@forEach + _componentElementMap[componentId] = originalPageElement.newBuilder() + .dataValue(processedDataValue) + .build() + } + _eventEngine.rebuildGraphs(_componentElementMap) + + return processedDataValues + } else { + // submit response + val updateProto = response.responseBody?.updatePartialPageProtoWrapper?.updatePartialPageProto + + val toRemoveIds = updateProto?.toRemove ?: emptyList() + toRemoveIds.forEach { componentId -> + _componentElementMap.remove(componentId) + } + + // toAddOrReplaceData + val toAddOrReplace = updateProto?.toAddOrReplaceData ?: emptyList() + + toAddOrReplace.forEach { pe -> + val cid = pe.dataValue?.componentId ?: return@forEach + _componentElementMap[cid] = pe + } + _eventEngine.rebuildGraphs(_componentElementMap) + + val processedDataValues = processDataValueList(toAddOrReplace) + processedDataValues.forEach { processedDataValue -> + val componentId = processedDataValue.componentId ?: return@forEach + val originalPageElement = toAddOrReplace.find { it.dataValue?.componentId == componentId } ?: return@forEach + _componentElementMap[componentId] = originalPageElement.newBuilder() + .dataValue(processedDataValue) + .build() + } + + val toReplace = updateProto?.toReplaceDataValue ?: emptyList() + toReplace.forEach { newDataValue -> + val componentId = newDataValue.componentId ?: return@forEach + val existing = _componentElementMap[componentId] + if (existing != null) { + _componentElementMap[componentId] = existing.newBuilder() + .dataValue(newDataValue).build() + } + } + + val toReplacePreserving = updateProto?.toReplaceDataValuePreservingExtension ?: emptyList() + toReplacePreserving.forEach { newDataValue -> + val componentId = newDataValue.componentId ?: return@forEach + val existing = _componentElementMap[componentId] + if (existing != null) { + val oldDv = existing.dataValue + val mergedDataValue = newDataValue.newBuilder() + .ephemeralECPublicKey(oldDv?.ephemeralECPublicKey ?: newDataValue.ephemeralECPublicKey) + .sessionKeyExtension(oldDv?.sessionKeyExtension ?: newDataValue.sessionKeyExtension) + .encryptionActionExtension(oldDv?.encryptionActionExtension ?: newDataValue.encryptionActionExtension) + .animatedImageDataValueExtension(oldDv?.animatedImageDataValueExtension ?: newDataValue.animatedImageDataValueExtension) + .build() + _componentElementMap[componentId] = existing.newBuilder() + .dataValue(mergedDataValue).build() + } + } + + return _componentElementMap.values.mapNotNull { it.dataValue } + } + } + + /** + * - init response: uses initializePartialPageProtoWrapper.partialPage.componentTree + * - submit response: uses updatePartialPageProtoWrapper.updatePartialPageProto.treeFragments + */ + private fun updateComponentTree(response: IapCommonResponse, isInitial: Boolean) { + if (isInitial) { + // initResponse: Store the complete component tree + val tree = response.responseBody?.initializePartialPageProtoWrapper?.partialPage?.componentTree + _treeManager.initTree(tree) + } else { + // submitResponse: Merge subtree fragments into the current tree + val fragments = response.responseBody?.updatePartialPageProtoWrapper?.updatePartialPageProto?.treeFragments ?: emptyList() + _treeManager.mergeFragments(fragments) + } + } + + /** + * Concatenate multiple byte arrays + */ + private fun concatenateByteArrays(vararg arrays: ByteArray): ByteArray { + val totalLength = arrays.sumOf { it.size } + val result = ByteArray(totalLength) + var offset = 0 + for (arr in arrays) { + System.arraycopy(arr, 0, result, offset, arr.size) + offset += arr.size + } + return result + } + + /** + * Convert int to 4 bytes (big-endian) + */ + private fun intTo4BytesBE(value: Int): ByteArray { + return byteArrayOf( + ((value shr 24) and 0xFF).toByte(), + ((value shr 16) and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte(), + (value and 0xFF).toByte() + ) + } + + private fun resolvePlainText(ext: PageElement.EncryptionActionExtension): ByteArray { + if (ext.displayText != null) return ext.displayText!!.toByteArray() + val refCid = ext.fieldNumber ?: return ByteArray(0) + return resolveStringValue(refCid).toByteArray() + } + + private fun resolveStringValue(cid: Long, visited: MutableSet = mutableSetOf()): String { + if (cid in visited) { + return "" + } + visited.add(cid) + + val pe = _componentElementMap[cid] ?: return "" + return when (pe.extensionFieldNumber) { + 217437962 -> _inputStateFlow.value[cid] ?: "" + 223344552 -> pe.textInfoDataExtension?.text + ?: pe.textInfoDataExtension?.displayText?.text + ?: "" + 232057536 -> pe.dataValue?.message204201689?.text + ?: pe.message232057536?.messageExtension?.unknown1 + ?: "" + 228971049 -> { + val tpl = pe.templateTextExtension ?: return "" + val template = tpl.formatTemplate ?: return "" + val args: Array = tpl.childComponentIds + .map { resolveStringValue(it, visited) } + .toTypedArray() + String.format(template, *args) + } + 217440216 -> "" + else -> { + Log.w(TAG, "resolveStringValue: unhandled cid=$cid ext=${pe.extensionFieldNumber}") + "" + } + } + } + + /** + * AES-128-GCM + JWE Compact Serialization with the current session key. + * Format: `....` + * where header = `{"alg":"dir","kid":,"enc":"A128GCM"}`. + * + * @throws IllegalArgumentException if [_mcReqSessionKey] is null or not 128-bit + * @throws GeneralSecurityException on cipher init / doFinal failure + * @throws JSONException on header construction failure (rare) + */ + private fun encryptJwe(keyId: String?, iv: ByteArray, plainText: ByteArray): String { + val keyBytes = requireNotNull(_mcReqSessionKey) { + "AES encryption key (_mcReqSessionKey) is null - ECDH key agreement must run first" + } + require(keyBytes.size == 16) { + "Invalid key size ${keyBytes.size * 8}; only 128-bit AES keys are supported" + } + + val header = JSONObject().apply { + put("alg", "dir") + put("kid", keyId) + put("enc", "A128GCM") + } + val jweHeader = BaseEncoding.base64Url().encode(header.toString().toByteArray()) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyBytes, "AES"), IvParameterSpec(iv)) + updateAAD(jweHeader.toByteArray()) + } + val ciphertext = cipher.doFinal(plainText) + val payload = ciphertext.copyOf(ciphertext.size - 16) + val tag = ciphertext.copyOfRange(ciphertext.size - 16, ciphertext.size) + + return String.format( + Locale.US, "%s..%s.%s.%s", + jweHeader, + BaseEncoding.base64Url().encode(iv), + BaseEncoding.base64Url().encode(payload), + BaseEncoding.base64Url().encode(tag) + ) + } + + fun detectDirectFinish(): FinishActionParams? { + for ((_, pe) in _componentElementMap) { + if (pe.extensionFieldNumber != 233780159) continue + val infraDataExt = pe.infrastructureDataExtension ?: continue + for (entry in infraDataExt.extension) { + val inner = entry.infrastructureAction ?: continue + when (val actionType = inner.resultActionType) { + ResultingActionType.RESULTING_ACTION_TYPE_FINISH -> { + val finishParams = inner.finishParams + Log.d(TAG, "detectDirectFinish: FINISH in componentId=${pe.dataValue?.componentId}, resultCode=${finishParams?.resultCode}, hasIntegratorData=${finishParams?.integratorCallbackData != null}") + return finishParams + } + ResultingActionType.RESULTING_ACTION_TYPE_LOAD_URL -> { + context.startActivity(Intent(Intent.ACTION_VIEW, inner.urlWrapper?.url?.url?.toUri())) + } + ResultingActionType.RESULTING_ACTION_TYPE_SUBMIT, + ResultingActionType.RESULTING_ACTION_TYPE_ANNOUNCE_FOR_ACCESSIBILITY -> { + + } + + ResultingActionType.RESULTING_ACTION_TYPE_COPY_TO_CLIPBOARD -> { + inner.copyToClipboard?.let { + val clipData = ClipData.newPlainText("", it.text) + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + if (clipboardManager != null) { + clipboardManager.setPrimaryClip(clipData) + } + } + } + ResultingActionType.RESULTING_ACTION_TYPE_TRIGGER_FULL_SCREEN_SPINNER, + ResultingActionType.RESULTING_ACTION_TYPE_SEND_PAYMENT_EVENT_CALLBACK_DATA, + ResultingActionType.RESULTING_ACTION_TYPE_FINISH_WITH_REDIRECT -> { + Log.d(TAG, "Unsupported infrastructure resulting action type=${actionType}") + } + else -> { + Log.w(TAG, "detectDirectFinish: unhandled actionType=${actionType}, componentId=${pe.dataValue?.componentId}") + } + } + } + } + return null + } + + private val _conditionValues = mutableMapOf() + + fun getComponentVisibility(): Pair, Set> { + val managed = mutableSetOf() + val visible = mutableSetOf() + for ((_, pe) in _componentElementMap) { + if (pe.extensionFieldNumber != 223344553) continue + val options = pe.verticalContainerExtension?.options ?: continue + val currentValue = pe.dataValue?.conditionValueExt?.conditionValue ?: 0 + for (option in options) { + managed.addAll(option.children) + } + val matched = options.firstOrNull { it.conditionValue == currentValue } + if (matched != null) { + visible.addAll(matched.children) + } + } + return managed to visible + } + + /** + * Dynamically update the conditionValue of the condition container after a button click to trigger a UI branch switch + */ + fun updateConditionValue(componentId: Long, newValue: Int) { + val pe = _componentElementMap[componentId] ?: run { + Log.w(TAG, "updateConditionValue: componentId=$componentId not found") + return + } + if (pe.extensionFieldNumber != 223344553) { + Log.w(TAG, "updateConditionValue: componentId=$componentId is not a conditional container (ext=${pe.extensionFieldNumber})") + return + } + val dv = pe.dataValue ?: return + val oldCondValueExt = dv.conditionValueExt ?: DataValue.ConditionValueExtension() + val updatedDv = dv.newBuilder() + .conditionValueExt(oldCondValueExt.newBuilder().conditionValue(newValue).build()) + .build() + _componentElementMap[componentId] = pe.newBuilder().dataValue(updatedDv).build() + Log.d(TAG, "updateConditionValue: componentId=$componentId → condValue=$newValue") + refreshCurrentPageElements() + } + + /** + * 1=RELATIVE(Box), 2=FLEX(Column) + */ + fun getLayoutModes(): Map = _treeManager.getLayoutModes() + + /** + * Get the state of the AnimatedImage + * 2=RUNNING → show; otherwise → hide + */ + fun getAnimatedImageState(componentId: Long): Int = _eventEngine.getAnimatedImageState(componentId) + + fun reset() { + _paymentState.value = PaymentState.Idle + _oauthToken = "" + _clientToken = null + _ephemeralPrivateKey = ByteArray(0) + _treeManager.reset() + _eventEngine.reset() + _conditionValues.clear() + _currentPageElements = emptyList() + _componentElementMap = mutableMapOf() + } + + @Volatile private var resourcesClosed = false + + @OptIn(DelicateCoroutinesApi::class) + fun closeResources() { + if (resourcesClosed) return + resourcesClosed = true + val client = httpClient + GlobalScope.launch(Dispatchers.IO) { + try { + client.dispatcher.executorService.shutdownNow() + client.connectionPool.evictAll() + } catch (e: Exception) { + Log.w(TAG, "closeResources: ", e) + } + } + } +} diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentController.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentController.kt new file mode 100644 index 0000000000..88d2ecef42 --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentController.kt @@ -0,0 +1,299 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import android.util.Log +import com.google.android.gms.wallet.shared.BuyFlowConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.microg.vending.billing.proto.ComponentTreeNode +import org.microg.vending.billing.proto.FunctionalDataExecutionState +import org.microg.vending.billing.proto.PageElement +import org.microg.vending.billing.proto.IapCommonResponse +import org.microg.vending.billing.proto.ResultCode + +/** + * Payment Controller + * Orchestrates the entire payment flow: init → submit1 → submit2 → ... → submitN + * Manages state transitions and coordinates between PaymentContext and UI + */ +class PaymentController(private val paymentContext: PaymentContext) { + private val scopeJob = Job() + private val scope = CoroutineScope(Dispatchers.Main + scopeJob) + + companion object { + private const val TAG = "PaymentController" + } + + private var isProcessingAction = false + private var submitJob: Job? = null + + suspend fun startPaymentFlow(buyFlowConfig: BuyFlowConfig, encryptedParams: ByteArray?, unencryptedParams: ByteArray?) { + val initResponse = paymentContext.initialize( + buyFlowConfig, + encryptedParams, + unencryptedParams + ) + if (initResponse == null) { + Log.d(TAG, "startPaymentFlow, init response is null") + return + } + + paymentContext.updateState(PaymentState.Initialized) + + val firstSubmitResponse = awaitSubmit( + tokenization = initResponse.secureDataHeader?.tokenization ?: emptyList() + ) + + if (firstSubmitResponse == null) { + Log.d(TAG, "startPaymentFlow, first submit response") + paymentContext.updateState(PaymentState.Error("First submit failed")) + return + } + + val delta = getDeltaPageElements(firstSubmitResponse, isInitial = false) + handleResponseDecision(firstSubmitResponse, delta) + } + + /** + * Perform a single submit operation + */ + private suspend fun awaitSubmit( + tokenization: List + ): IapCommonResponse? { + val result = paymentContext.submit(tokenization) + return result + } + + /** + * Extract incremental PageElements from the response (used for decision-making: presence of buttons/encryption, etc.) + * Note: UI rendering uses paymentContext.currentPageElements (full set) + */ + private fun getDeltaPageElements( + response: IapCommonResponse, + isInitial: Boolean + ): List { + return if (isInitial) { + response.responseBody?.initializePartialPageProtoWrapper?.partialPage?.pageElements ?: emptyList() + } else { + response.responseBody?.updatePartialPageProtoWrapper?.updatePartialPageProto?.toAddOrReplaceData ?: emptyList() + } + } + + /** + * Called when the user clicks a button + */ + fun onUserAction(buttonComponentId: Long) { + Log.d(TAG, "onUserAction - buttonId=$buttonComponentId") + if (isProcessingAction) { + Log.d(TAG, "onUserAction - already processing, ignoring") + return + } + when (val currentState = paymentContext.paymentState.value) { + is PaymentState.Submitted -> { + isProcessingAction = true + triggerButtonAction(buttonComponentId) + // If triggerButtonAction triggered FINISH, do not continue with submit + if (paymentContext.paymentState.value is PaymentState.Completed) { + Log.d(TAG, "onUserAction - FINISH triggered by button, not continuing") + isProcessingAction = false + return + } + Log.d(TAG, "onUserAction - continuing to next submit after button trigger") + launchSubmit { + continueToNextSubmit(currentState.response) + } + } + is PaymentState.Completed -> { + Log.d(TAG, "onUserAction - already completed(resultCode=${currentState.resultCode})") + } + else -> { + Log.d(TAG, "onUserAction - unexpected state ${currentState::class.simpleName}") + } + } + } + + private fun triggerButtonAction(buttonComponentId: Long) { + val tree = paymentContext.componentTreeManager.getTree() ?: return + + val dataId = findDataIdByCondRef(tree, buttonComponentId) + if (dataId == null) { + Log.d(TAG, "triggerButtonAction: no tree node for button id=$buttonComponentId") + return + } + + Log.d(TAG, "triggerButtonAction: button=$buttonComponentId → dataId=$dataId → event engine") + val results = paymentContext.eventEngine.onButtonClick(listOf(dataId)) + Log.d(TAG, "triggerButtonAction: results=$results") + + for (result in results) { + when (result) { + is ActionResult.StateChange -> { + if (result.newState == FunctionalDataExecutionState.FUNCTIONAL_DATA_EXECUTION_STATE_RUNNING) { + Log.d(TAG, "triggerButtonAction: component ${result.componentId} → RUNNING, executing encryption now") + paymentContext.executeEncryptionForComponent(result.componentId) + } + } + is ActionResult.Finish -> { + Log.d(TAG, "triggerButtonAction: FINISH from event engine, resultCode=${result.resultCode}") + paymentContext.updateState(PaymentState.Completed(result.resultCode, result.finishParams)) + return + } + is ActionResult.Submit -> { + Log.d(TAG, "triggerButtonAction: SUBMIT from event engine") + } + is ActionResult.AnimatedImageStateChange -> { + Log.d(TAG, "triggerButtonAction: AnimatedImage ${result.componentId} → state=${result.newState}") + } + is ActionResult.EnablementChange -> { + Log.d(TAG, "triggerButtonAction: Enablement ${result.componentId} → ${result.enablementState}") + } + is ActionResult.ConditionValueChange -> { + Log.d(TAG, "triggerButtonAction: ConditionValue ${result.componentId} → ${result.newConditionValue}") + paymentContext.updateConditionValue(result.componentId, result.newConditionValue) + } + is ActionResult.ValidationPassed -> { + Log.d(TAG, "triggerButtonAction: ValidationPassed for cid=${result.componentId}") + } + } + } + } + + private fun findDataIdByCondRef(node: ComponentTreeNode, targetCondRef: Long): Long? { + if (node.conditionRef == targetCondRef) { + return node.nodeDataFieldRefs.firstOrNull()?.dataIds?.firstOrNull() + } + val children = when (node.nodeTypeId) { + 214299793 -> (node.containerExt?.children ?: emptyList()) + listOfNotNull(node.containerExt?.footer) + 231420908 -> node.conditionalExt?.children ?: emptyList() + 264434503 -> listOfNotNull(node.fullSheetExt?.child) + 229613734 -> listOfNotNull(node.scrollExt?.child) + else -> emptyList() + } + for (child in children) { + val result = findDataIdByCondRef(child, targetCondRef) + if (result != null) return result + } + return null + } + + /** + * Unified decision-making after each submit response is processed + * Priority: FINISH > button (full-set visible) > auto-continue (incremental has new data) > complete + */ + private fun handleResponseDecision( + response: IapCommonResponse, + delta: List + ) { + // 1. Check whether the server has directly sent a FINISH signal + val finishParams = paymentContext.detectDirectFinish() + if (finishParams != null) { + val resultCode = finishParams.resultCode ?: ResultCode.RESULT_CODE_UNKNOWN + Log.d(TAG, "Server sent FINISH signal, resultCode=$resultCode") + isProcessingAction = false + paymentContext.updateState(PaymentState.Completed(resultCode, finishParams)) + return + } + + // 2. A button is present in the fully visible components → wait for user interaction + if (hasVisibleButton()) { + Log.d(TAG, "Has visible button - waiting for user action") + isProcessingAction = false + paymentContext.updateState(PaymentState.Submitted(response)) + return + } + + // 3. Incremental contains new encryption/key fields → must continue automatically + if (hasCryptoFields(delta)) { + Log.d(TAG, "Has crypto fields in delta - auto submit next") + launchSubmit { + continueToNextSubmit(response) + } + return + } + + // 4. Incremental has other new data but no button → continue automatically (UI-only update) + if (delta.isNotEmpty()) { + Log.d(TAG, "Delta has new data but no button - auto submit next") + launchSubmit { + continueToNextSubmit(response) + } + return + } + + // 5. Incremental is empty with no button and no FINISH → terminate abnormally + Log.e(TAG, "No button, no new data, no FINISH - flow ended abnormally") + isProcessingAction = false + paymentContext.updateState(PaymentState.Error("Unexpected: no button, no data, no FINISH after submit ")) + } + + private fun hasVisibleButton(): Boolean { + val (managed, visible) = paymentContext.getComponentVisibility() + return paymentContext.componentElementMap.any { (cid, pe) -> + pe.extensionFieldNumber == 232057536 && + (cid !in managed || cid in visible) + } + } + + private fun hasCryptoFields(delta: List): Boolean { + return delta.any { + it.extensionFieldNumber == 290848975 || // AES-GCM encryption action + it.extensionFieldNumber == 290848973 || // P256 key generation + it.extensionFieldNumber == 290848974 // ECDH key exchange + } + } + + private suspend fun continueToNextSubmit( + stepResponse: IapCommonResponse + ) { + try { + + paymentContext.updateState(PaymentState.Submitting) + + val nextResponse = awaitSubmit( + tokenization = stepResponse.secureDataHeader?.tokenization ?: emptyList() + ) + + if (nextResponse == null) { + Log.e(TAG, "Submit failed - response is null") + paymentContext.updateState(PaymentState.Error("Submit failed")) + return + } + + val delta = getDeltaPageElements(nextResponse, isInitial = false) + + handleResponseDecision(nextResponse, delta) + } catch (e: Exception) { + Log.e(TAG, "continueToNextSubmit failed", e) + paymentContext.updateState(PaymentState.Error("Submit failed: ${e.message}", e)) + } finally { + val state = paymentContext.paymentState.value + if (state is PaymentState.Error || state is PaymentState.Completed) { + isProcessingAction = false + } + } + } + + private fun launchSubmit(block: suspend () -> Unit) { + submitJob?.cancel() + submitJob = scope.launch { block() } + } + + fun reset() { + Log.d(TAG, "reset() - clearing state") + submitJob?.cancel() + isProcessingAction = false + paymentContext.reset() + } + + fun destroy() { + Log.d(TAG, "destroy() - cancelling scope") + scopeJob.cancel() + paymentContext.closeResources() + } +} diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentState.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentState.kt new file mode 100644 index 0000000000..616238259f --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentState.kt @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import org.microg.vending.billing.proto.FinishActionParams +import org.microg.vending.billing.proto.IapCommonResponse +import org.microg.vending.billing.proto.ResultCode + +/** + * Payment flow state definitions + * Represents all possible states in the payment authentication flow + */ +sealed class PaymentState { + /** + * Initial state - no payment in progress + */ + object Idle : PaymentState() + + /** + * Starting the payment flow - initializing + */ + object Initializing : PaymentState() + + /** + * Payment initialized, ready for first submission + */ + object Initialized : PaymentState() + + /** + * Submit in progress + */ + object Submitting: PaymentState() + + /** + * Submit completed, waiting for user interaction or next submit + * @param response The response from Google API + */ + data class Submitted(val response: IapCommonResponse) : PaymentState() + + /** + * Payment flow completed with a result from the server + * @param resultCode 1=SUCCESS, 2=CANCEL, 3=ERROR (from InfrastructureAction.finishParams) + */ + data class Completed( + val resultCode: ResultCode = ResultCode.RESULT_CODE_UNKNOWN, + val finishParams: FinishActionParams? = null + ) : PaymentState() + + /** + * Payment flow failed with error + * @param message Error message + * @param exception Optional exception details + */ + data class Error(val message: String, val exception: Throwable? = null) : PaymentState() + + /** + * Payment was cancelled by user + */ + object Cancelled : PaymentState() +} diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentsScreen.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentsScreen.kt new file mode 100644 index 0000000000..a63f457803 --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/PaymentsScreen.kt @@ -0,0 +1,361 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import android.os.Build +import android.text.Html +import android.util.Log +import android.text.Spanned +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import org.microg.vending.billing.proto.LayoutModeProto +import org.microg.vending.billing.proto.PageElement + +private const val TAG = "PaymentsScreen" + +@Composable +fun PaymentsScreen( + pageElements: List, + elementMap: Map = emptyMap(), + layoutModes: Map = emptyMap(), + condManaged: Set = emptySet(), + condVisible: Set = emptySet(), + animatedImageStateProvider: (Long) -> Int = { 1 }, + inputTextProvider: (Long) -> String = { "" }, + onTextChange: ((Long, String) -> Unit)? = null, + modifier: Modifier = Modifier, + onButtonClick: ((Long) -> Unit)? = null +) { + val childIds = mutableSetOf() + for (element in pageElements) { + val kids = element.dataValue?.childComponentIds?.map { it.toLong() }?.takeIf { it.isNotEmpty() } + ?: element.verticalContainerExtension?.options?.flatMap { it.children } + ?: element.groupingDataExtension?.groupedDataReferenceList + ?: emptyList() + childIds.addAll(kids) + } + for ((_, element) in elementMap) { + val kids = element.verticalContainerExtension?.options?.flatMap { it.children } + ?: element.groupingDataExtension?.groupedDataReferenceList + ?: emptyList() + childIds.addAll(kids) + } + + val rootElements = pageElements.filter { (it.dataValue?.componentId ?: 0L) !in childIds } + Log.d(TAG, "PaymentsScreen render: total=${pageElements.size}, " + + "roots=${rootElements.map { it.dataValue?.componentId }}, " + + "condManaged=$condManaged, condVisible=$condVisible") + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + rootElements.forEach { element -> + PageElementItem( + element = element, + elementMap = elementMap, + layoutModes = layoutModes, + condManaged = condManaged, + condVisible = condVisible, + animatedImageStateProvider = animatedImageStateProvider, + inputTextProvider = inputTextProvider, + onTextChange = onTextChange, + onButtonClick = onButtonClick + ) + } + } +} + +@Composable +fun PageElementItem( + element: PageElement, + elementMap: Map = emptyMap(), + layoutModes: Map = emptyMap(), + condManaged: Set = emptySet(), + condVisible: Set = emptySet(), + animatedImageStateProvider: (Long) -> Int = { 1 }, + inputTextProvider: (Long) -> String = { "" }, + onTextChange: ((Long, String) -> Unit)? = null, + onButtonClick: ((Long) -> Unit)? = null +) { + val cid = element.dataValue?.componentId + + if (cid != null && cid in condManaged && cid !in condVisible) { + Log.d(TAG, "SKIP cid=$cid (cond filter: managed but not visible)") + return + } + + val extensionFieldNumber = element.extensionFieldNumber + if (extensionFieldNumber == null) { + Log.d(TAG, "SKIP cid=$cid (no extensionFieldNumber)") + return + } + + when (extensionFieldNumber) { + 217440216 -> ImageDataComponent(element) + 223344552 -> TextComponent(element) + 217437962 -> InputFieldComponent(element, inputTextProvider, onTextChange) + 232057536 -> ButtonComponent(element, onButtonClick) + // 233780159 (gcru/CardContainer): Infrastructure data component, NOT a UI container + // It contains InfrastructureResultingAction in field 6 (submit/finish handlers), + // NOT child PageElements. The card UI comes from parent LayoutContainer styling. + // Children are NOT in dataValue.childComponentIds (it's empty), not in GroupingDataExtension. + // Fix: Do not render as card - parent LayoutContainer applies card styling. + 233780159 -> { /* CardContainer: data component, rendered by parent as card */ } + 265527174 -> AnimatedImageComponent(element, animatedImageStateProvider(cid ?: 0L)) + 223344553 -> ConditionalContainerComponent(element, elementMap, layoutModes, condManaged, condVisible, animatedImageStateProvider, inputTextProvider, onTextChange, onButtonClick) + 223344555 -> LayoutContainerComponent(element, elementMap, layoutModes, condManaged, condVisible, animatedImageStateProvider, inputTextProvider, onTextChange, onButtonClick) + 264984587 -> SpacerElementComponent(element) + 265529774 -> FlexContainerComponent(element, elementMap, layoutModes, condManaged, condVisible, animatedImageStateProvider, inputTextProvider, onTextChange, onButtonClick) + 232946268 -> { /* SMS auto reader: not wired */ } + else -> { + Log.w(TAG, "Unhandled extensionFieldNumber=$extensionFieldNumber, componentId=$cid") + } + } +} + +@Composable +fun ImageDataComponent(element: PageElement) { + val imageData = element.imageDataExtension?.imageData ?: return + val layoutDirection = LocalLayoutDirection.current + + val imageUrl = imageData.imageUrl + ?: imageData.values.firstOrNull()?.imageUrl + ?: return + + val mirrored = imageData.isAutoMirrored == true && + layoutDirection == LayoutDirection.Rtl + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Image( + painter = rememberAsyncImagePainter(imageUrl), + contentDescription = imageData.title, + contentScale = ContentScale.Fit, + modifier = Modifier + .heightIn(max = 64.dp) + .widthIn(max = 200.dp) + .then( + if (mirrored) Modifier.graphicsLayer { scaleX = -1f } + else Modifier + ) + ) + } +} + +@Composable +fun TextComponent(element: PageElement) { + val text = element.textInfoDataExtension?.text + ?: element.textInfoDataExtension?.displayText?.text + ?: return + + val spanned = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(text, Html.FROM_HTML_MODE_COMPACT) + } else { + @Suppress("DEPRECATION") + Html.fromHtml(text) + } + + val annotated = buildHtmlAnnotatedString(spanned) + + Text( + text = annotated, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth() + ) +} + +@Composable +fun ButtonComponent(element: PageElement, onButtonClick: ((Long) -> Unit)?) { + val componentId = element.dataValue?.componentId ?: 0L + val buttonText = element.dataValue?.message204201689?.text + + if (buttonText.isNullOrEmpty()) { + return + } + + Button( + onClick = { onButtonClick?.invoke(componentId) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = buttonText) + } +} + +@Composable +fun AnimatedImageComponent(element: PageElement, animatedImageState: Int) { + val text = element.animatedImageDataExtension?.conditionalContentSource?.contentDescription ?: "" + + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (animatedImageState != 3) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp).padding(8.dp), + color = MaterialTheme.colorScheme.primary + ) + } + if (text.isNotEmpty()) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + } +} + +/** + * + * layoutMode=1 (RELATIVE): Box + * layoutMode=2 (FLEX): Column + */ +@Composable +fun LayoutContainerComponent(element: PageElement, elementMap: Map, layoutModes: Map, condManaged: Set = emptySet(), condVisible: Set = emptySet(), animatedImageStateProvider: (Long) -> Int = { 1 }, inputTextProvider: (Long) -> String = { "" }, onTextChange: ((Long, String) -> Unit)? = null, onButtonClick: ((Long) -> Unit)?) { + val cid = element.dataValue?.componentId ?: 0L + val childIds = element.dataValue?.childComponentIds?.map { it.toLong() }?.takeIf { it.isNotEmpty() } + ?: element.verticalContainerExtension?.options?.flatMap { it.children } + ?: element.groupingDataExtension?.groupedDataReferenceList + ?: emptyList() + + val layoutMode = layoutModes[cid] ?: LayoutModeProto.LAYOUT_MODE_FLEX + if (layoutMode == LayoutModeProto.LAYOUT_MODE_RELATIVE) { + Box(modifier = Modifier.fillMaxWidth()) { + childIds.forEach { childId -> + val childElement = elementMap[childId] ?: return@forEach + PageElementItem(element = childElement, elementMap = elementMap, layoutModes = layoutModes, condManaged = condManaged, condVisible = condVisible, animatedImageStateProvider = animatedImageStateProvider, inputTextProvider = inputTextProvider, onTextChange = onTextChange, onButtonClick = onButtonClick) + } + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + childIds.forEach { childId -> + val childElement = elementMap[childId] ?: return@forEach + PageElementItem(element = childElement, elementMap = elementMap, layoutModes = layoutModes, condManaged = condManaged, condVisible = condVisible, animatedImageStateProvider = animatedImageStateProvider, inputTextProvider = inputTextProvider, onTextChange = onTextChange, onButtonClick = onButtonClick) + } + } + } +} + +@Composable +fun SpacerElementComponent(element: PageElement) { + val height = element.dataValue?.spacerHeight?.toInt() ?: 8 + Spacer(modifier = Modifier.height(height.dp)) +} + +@Composable +fun ConditionalContainerComponent(element: PageElement, elementMap: Map, layoutModes: Map, condManaged: Set = emptySet(), condVisible: Set = emptySet(), animatedImageStateProvider: (Long) -> Int = { 1 }, inputTextProvider: (Long) -> String = { "" }, onTextChange: ((Long, String) -> Unit)? = null, onButtonClick: ((Long) -> Unit)?) { +} + +@Composable +fun FlexContainerComponent(element: PageElement, elementMap: Map, layoutModes: Map, condManaged: Set = emptySet(), condVisible: Set = emptySet(), animatedImageStateProvider: (Long) -> Int = { 1 }, inputTextProvider: (Long) -> String = { "" }, onTextChange: ((Long, String) -> Unit)? = null, onButtonClick: ((Long) -> Unit)?) { + val childIds = element.dataValue?.childComponentIds?.map { it.toLong() }?.takeIf { it.isNotEmpty() } + ?: element.verticalContainerExtension?.options?.flatMap { it.children } + ?: element.groupingDataExtension?.groupedDataReferenceList + ?: emptyList() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + childIds.forEach { childId -> + val childElement = elementMap[childId] ?: return@forEach + Box(modifier = Modifier.weight(1f)) { + PageElementItem(element = childElement, elementMap = elementMap, layoutModes = layoutModes, condManaged = condManaged, condVisible = condVisible, animatedImageStateProvider = animatedImageStateProvider, inputTextProvider = inputTextProvider, onTextChange = onTextChange, onButtonClick = onButtonClick) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InputFieldComponent( + element: PageElement, + inputTextProvider: (Long) -> String, + onTextChange: ((Long, String) -> Unit)? +) { + val cid = element.dataValue?.componentId ?: return + val currentText = inputTextProvider(cid) + val henc = element.textInputFieldExtension + val hint = henc?.hint?.takeIf { it.isNotEmpty() } ?: element.errorText ?: "" + val label = henc?.labelPrefix ?: henc?.labelCaption ?: "" + + OutlinedTextField( + value = currentText, + onValueChange = { newText -> + Log.d(TAG, "InputFieldComponent[cid=$cid] onValueChange: newText.len=${newText.length}") + onTextChange?.invoke(cid, newText) + }, + label = if (label.isNotEmpty()) { { Text(label) } } else null, + placeholder = if (hint.isNotEmpty()) { { Text(hint) } } else null, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier.fillMaxWidth() + ) +} + +// Spanned → Compose AnnotatedString +private fun buildHtmlAnnotatedString(spanned: Spanned): AnnotatedString { + return buildAnnotatedString { + append(spanned.toString()) + for (span in spanned.getSpans(0, spanned.length, Any::class.java)) { + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + android.graphics.Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + android.graphics.Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + android.graphics.Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end) + } + is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) + } + } + } +} diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/Utils.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/Utils.kt new file mode 100644 index 0000000000..3480e1663c --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/Utils.kt @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.nfc.NfcAdapter +import android.text.TextUtils +import android.util.Log +import org.microg.gms.common.Constants +import org.microg.gms.deviceinfo.DeviceEnvInfo +import org.microg.vending.billing.proto.BasicDeviceFeature +import org.microg.vending.billing.proto.ClientToken +import org.microg.vending.billing.proto.CompressionType +import org.microg.vending.billing.proto.DeviceBasedInputType +import org.microg.vending.billing.proto.FidoDeviceFeature +import org.microg.vending.billing.proto.SecureElementState +import org.microg.vending.billing.proto.UserVerifying +import java.util.Locale +import java.util.UUID + +private const val TAG = "WalletUtils" + +fun createDeviceEnvInfo(context: Context): DeviceEnvInfo? { + val packageInfo = tryGetPackageInfo(context, Constants.VENDING_PACKAGE_NAME) + Log.d(TAG, "createDeviceEnvInfo: pkg=${packageInfo?.packageName} ver=${packageInfo?.versionName}/${packageInfo?.versionCode}") + return org.microg.gms.deviceinfo.createDeviceEnvInfo( + context, + gpVersionCode = packageInfo?.versionCode?.toLong() ?: 0L, + gpVersionName = packageInfo?.versionName ?: "", + gpPkgName = Constants.VENDING_PACKAGE_NAME, + ) +} + +fun localeToString(locale: Locale): String { + val result = StringBuilder() + result.append(locale.language) + locale.country.let { + if (it.isNotEmpty()) + result.append("-$it") + } + locale.variant.let { + if (it.isNotEmpty()) + result.append("-$it") + } + return result.toString() +} + +fun createClientTokenInfo1( + context: Context, + deviceInfo: DeviceEnvInfo, + gsfId: String?, + shouldIncludeExtraInfo: Boolean = false, +): ClientToken.Info1 { + return ClientToken.Info1.Builder().apply { + this.locale = localeToString(deviceInfo.locale) + this.unknown8 = 2 + this.gpVersionCode = deviceInfo.gpVersionCode + this.deviceInfo = ClientToken.DeviceInfo.Builder().apply { + this.sdkVersion = deviceInfo.sdkVersion + this.device = deviceInfo.device + deviceInfo.displayMetrics?.let { + this.widthPixels = it.widthPixels + this.heightPixels = it.heightPixels + this.xdpi = it.xdpi + this.ydpi = it.ydpi + this.densityDpi = it.densityDpi + } + this.gpPackage = deviceInfo.gmsPackageName + this.gpVersionCode = deviceInfo.gpVersionCode.toString() + this.gpVersionName = deviceInfo.gpVersionName + if (shouldIncludeExtraInfo) { + this.envInfo = ClientToken.EnvInfo.Builder().apply { + this.deviceData = ClientToken.DeviceData.Builder().apply { + this.unknown1 = 0 + deviceInfo.telephonyData?.let { + this.simOperatorName = it.simOperatorName + this.phoneDeviceId = it.phoneDeviceId + this.phoneDeviceId1 = it.phoneDeviceId + } + this.gsfId = java.lang.Long.parseLong(gsfId ?: "1", 16) + this.device = deviceInfo.device + this.product = deviceInfo.product + this.model = deviceInfo.model + this.manufacturer = deviceInfo.manufacturer + this.fingerprint = deviceInfo.fingerprint + this.release = deviceInfo.release + this.brand = deviceInfo.brand + this.serial = deviceInfo.serialNo + this.isEmulator = false + }.build() + this.otherInfo = ClientToken.OtherInfo.Builder().apply { + this.packageInfoList = listOfNotNull( + tryGetPackageInfo(context, Constants.GMS_PACKAGE_NAME), + tryGetPackageInfo(context, Constants.VENDING_PACKAGE_NAME) + ).map { buildPackageInfo(it) } + this.batteryLevel = deviceInfo.batteryLevel + this.timeZoneOffset = deviceInfo.timeZoneOffset + this.isAdbEnabled = deviceInfo.isAdbEnabled + this.installNonMarketApps = deviceInfo.installNonMarketApps + this.iso3Language = deviceInfo.locale.isO3Language + this.netAddress = deviceInfo.networkData?.netAddressList ?: emptyList() + this.locale = deviceInfo.locale.toString() + this.language = deviceInfo.locale.language + this.country = deviceInfo.locale.country + this.uptimeMillis = deviceInfo.uptimeMillis + this.timeZoneDisplayName = deviceInfo.timeZoneDisplayName + this.googleAccountCount = deviceInfo.googleAccounts.size + this.isUserAMonkey = ActivityManager.isUserAMonkey() + this.isInCallOrRingMode = deviceInfo.isInCallOrRingMode + this.isUsbConnected = deviceInfo.isUsbConnected + this.isCharging = deviceInfo.isCharging + this.screenBrightness = deviceInfo.screenBrightness + this.displayMetrics = deviceInfo.displayMetrics?.let { + ClientToken.DisplayMetrics.Builder().apply { + this.widthPixels = it.widthPixels + this.heightPixels = it.heightPixels + }.build() + } + }.build() + }.build() + } + this.callingPackage = deviceInfo.gpPkgName + this.marketClientId = "am-android-att-us" + this.unknown15 = 2 + this.moduleVersion = 253534000 + this.curAuthContext = getBasicSupportedFeatures(context) + this.cameraPermissionState = deviceInfo.cameraPermissionState + deviceInfo.networkData?.let { + this.linkDownstreamBandwidth = it.linkDownstreamBandwidth + this.linkUpstreamBandwidth = it.linkUpstreamBandwidth + this.isActiveNetworkMetered = it.isActiveNetworkMetered + } + this.supportedAuthTypes = listOf( + FidoDeviceFeature.FINGERPRINT, + FidoDeviceFeature.BIOMETRIC, + FidoDeviceFeature.PIN_PASSWORD_OR_PATTERN, + ) + deviceInfo.telephonyData?.let { + this.isSmsCapable = it.isSmsCapable + this.grantedPhonePermissionState = it.grantedPhonePermissionState + this.activeSubscriptionInfoCount = it.activeSubscriptionInfoCount + } + this.phenotypeServerToken = emptyList() + this.unknown34 = 0 + this.uptimeMillis = deviceInfo.uptimeMillis + this.timeZoneDisplayName = deviceInfo.timeZoneDisplayName + this.androidId = java.lang.Long.parseLong(gsfId ?: "1", 16) + this.secureElementState = SecureElementState.SECURE_ELEMENT_STATE_UNKNOWN + this.inputTypeList = listOf( + DeviceBasedInputType.DEVICE_BASED_INPUT_TYPE_CARD_OCR, + DeviceBasedInputType.DEVICE_BASED_INPUT_TYPE_NFC, + ) + this.ocrServiceAvailability = true + this.gpLongVersionCode = deviceInfo.gpVersionCode.toString() + this.longVersionCode = Constants.GMS_VERSION_CODE.toString() + if (!TextUtils.isEmpty(deviceInfo.model)) { + this.modelName = deviceInfo.product + } + }.build() + this.leastSignificantBits = UUID.randomUUID().leastSignificantBits + this.googleAccounts = deviceInfo.googleAccounts + this.unknown_bool_1 = false + this.unknown_int_1 = 0 + this.userVerifying = UserVerifying.Builder().unknown2(true).build() + this.sessionId = UUID.randomUUID().leastSignificantBits + this.google_account_count = deviceInfo.googleAccounts.size + this.current_account_index = 1 + this.compressed_types = listOf( + CompressionType.COMPRESSION_TYPE_IDENTITY, + CompressionType.COMPRESSION_TYPE_BROTLI + ) + }.build() +} + +fun tryGetPackageInfo(context: Context, packageName: String): PackageInfo? { + return try { + context.packageManager.getPackageInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + Log.w(TAG, "tryGetPackageInfo($packageName) not installed", e) + null + } +} + +fun buildPackageInfo(packageInfo: PackageInfo): ClientToken.GPInfo { + return ClientToken.GPInfo.Builder().apply { + if (packageInfo.packageName.isNotEmpty()) { + this.package_ = packageInfo.packageName + } + this.versionCode = packageInfo.versionCode.toString() + this.lastUpdateTime = packageInfo.lastUpdateTime + this.firstInstallTime = packageInfo.firstInstallTime + packageInfo.applicationInfo?.sourceDir?.let { this.sourceDir = it } + }.build() +} + +fun Context.isPackageInstalled(packageName: String, matchAnySignatures: Boolean = false): Boolean { + return try { + packageManager.getPackageInfo( + packageName, + if (matchAnySignatures) PackageManager.GET_SIGNING_CERTIFICATES else 0 + ) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } +} + +fun getBasicSupportedFeatures(context: Context): List { + val packageManager = context.packageManager + val features = mutableListOf() + + val intent = Intent("com.google.android.gms.ocr.ACTION_CARD_CAPTURE").apply { + setPackage("com.google.android.gms") + } + if (packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) { + features.add(BasicDeviceFeature.CAMERA_DOCUMENT_CAPTURE) + } + + if (NfcAdapter.getDefaultAdapter(context) != null) { + features.add(BasicDeviceFeature.NFC_DEVICE_SUPPORT) + } + + if (context.isPackageInstalled("com.felicanetworks.mfc")) { + features.add(BasicDeviceFeature.FELICA_SUPPORT) + } + + return features +} + diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/WidgetResultIntentBuilder.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/WidgetResultIntentBuilder.kt new file mode 100644 index 0000000000..2d152fb11d --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/activity/WidgetResultIntentBuilder.kt @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.activity + +import android.content.Intent +import android.util.Base64 +import com.google.android.gms.wallet.firstparty.ExitResult +import org.microg.gms.common.Constants +import org.microg.vending.billing.proto.FinishActionParams + +class WidgetResultIntentBuilder(private val callingPackage: String? = null) { + companion object { + private const val TAG = "WidgetResultIntent" + + const val WIDGET_TYPE_SECURE_PAYMENTS = 3 + + private const val EXTRA_PREFIX = "com.google.android.gms.wallet.firstparty." + private const val EXTRA_INTEGRATOR_CALLBACK_DATA_TOKEN = EXTRA_PREFIX + "EXTRA_INTEGRATOR_CALLBACK_DATA_TOKEN" + private const val EXTRA_ORDER_ID = EXTRA_PREFIX + "EXTRA_ORDER_ID" + private const val EXTRA_CLIENT_CALLBACK_DATA_TOKEN = EXTRA_PREFIX + "EXTRA_CLIENT_CALLBACK_DATA_TOKEN" + private const val EXTRA_SERVER_ANALYTICS_TOKEN = EXTRA_PREFIX + "EXTRA_SERVER_ANALYTICS_TOKEN" + private const val EXTRA_ANALYTICS_PROTO = EXTRA_PREFIX + "EXTRA_ANALYTICS_PROTO" + private const val EXTRA_INTERNAL_CLIENT_CALLBACK_DATA = EXTRA_PREFIX + "EXTRA_INTERNAL_CLIENT_CALLBACK_DATA" + } + + var serverAnalyticsToken: ByteArray = ByteArray(0) + + fun buildSuccessIntent(finishParams: FinishActionParams): Intent { + val intent = buildBaseIntent(finishParams) + + val integratorData = finishParams.integratorCallbackData + val tokenBytes = integratorData?.primaryData?.toByteArray() + ?: integratorData?.secondaryData?.toByteArray() + ?: ByteArray(0) + if (tokenBytes.isNotEmpty()) { + intent.putExtra(EXTRA_INTEGRATOR_CALLBACK_DATA_TOKEN, tokenBytes) + if (callingPackage != Constants.GMS_PACKAGE_NAME) { + intent.putExtra(EXTRA_ORDER_ID, Base64.encodeToString(tokenBytes, Base64.NO_WRAP)) + } + } + + val clientBytes = extractClientCallbackData(finishParams) + if (clientBytes.isNotEmpty()) { + intent.putExtra(EXTRA_CLIENT_CALLBACK_DATA_TOKEN, clientBytes) + } + + intent.putExtra(EXTRA_SERVER_ANALYTICS_TOKEN, serverAnalyticsToken) + return intent + } + + /** + * CANCEL → setResult(RESULT_CANCELED) + */ + fun buildCancelIntent(finishParams: FinishActionParams? = null): Intent { + val intent = buildBaseIntent(finishParams) + + if (finishParams != null) { + val clientBytes = extractClientCallbackData(finishParams) + if (clientBytes.isNotEmpty()) { + intent.putExtra(EXTRA_CLIENT_CALLBACK_DATA_TOKEN, clientBytes) + } + } + + return intent + } + + /** + * ERROR → setResult(1) + */ + fun buildErrorIntent(finishParams: FinishActionParams? = null): Intent { + val intent = buildBaseIntent(finishParams) + + if (finishParams != null) { + val clientBytes = extractClientCallbackData(finishParams) + if (clientBytes.isNotEmpty()) { + intent.putExtra(EXTRA_CLIENT_CALLBACK_DATA_TOKEN, clientBytes) + } + + val apiErrorData = finishParams.apiErrorData + if (apiErrorData?.debugMessage != null) { + val exitResult = ExitResult() + exitResult.paymentsExitCode = 404 + exitResult.debugMessage = apiErrorData.debugMessage + exitResult.apiErrorReason = apiErrorData.apiErrorReason ?: 0 + exitResult.writeToIntent(intent) + } + } + return intent + } + + fun buildBaseIntent(finishParams: FinishActionParams? = null): Intent { + val intent = Intent() + + intent.putExtra(EXTRA_ANALYTICS_PROTO, ByteArray(0)) + + finishParams?.additionalData?.let { ad -> + intent.putExtra(EXTRA_INTERNAL_CLIENT_CALLBACK_DATA, ad.encode()) + } + + return intent + } + + private fun extractClientCallbackData(finishParams: FinishActionParams): ByteArray { + return finishParams.clientCallbackData?.dataBytes?.toByteArray() ?: ByteArray(0) + } +} diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/extensions.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/extensions.kt new file mode 100644 index 0000000000..f2b3d18cab --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/extensions.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet + +/** + * BENDER3 widget intent — wires PmRootChimeraActivity (producer) to + * GenericDelegatorChimeraActivityX (consumer). The 3DS2 / IAP secure-payments + * inner activity is launched with this action and reads the extras below. + */ +const val ACTION_BENDER3 = "com.google.android.gms.firstparty.ACTION_BENDER3" + +const val EXTRA_BENDER3_BUYFLOW_CONFIG = "buyflowConfig" +const val EXTRA_BENDER3_O2_ACTION_TOKEN = "o2ActionToken" +const val EXTRA_BENDER3_ENCRYPTED_PARAMS = "encryptedParams" +const val EXTRA_BENDER3_UNENCRYPTED_PARAMS = "unencryptedParams" + +/** + * OAuth2 scope used by the Wallet/IAP "Sierra" service to acquire a per-account + * auth token before initializing a Bender3 payment flow. + */ +const val OAUTH_SCOPE_SIERRA = "oauth2:https://www.googleapis.com/auth/sierra" diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/firstparty/ExitResult.java b/play-services-payments/src/main/java/com/google/android/gms/wallet/firstparty/ExitResult.java new file mode 100644 index 0000000000..96e1050b2e --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/firstparty/ExitResult.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.firstparty; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class ExitResult extends AbstractSafeParcelable { + + @Field(1) + public int paymentsExitCode; + @Field(2) + public String debugMessage; + @Field(3) + public int playBillingExitCode; + @Field(4) + public int apiErrorReason; + + @Constructor + public ExitResult() { + this(402, "", 0, 0); + } + + @Constructor + public ExitResult(@Param(1) int paymentsExitCode, @Param(2) String debugMessage, @Param(3) int playBillingExitCode, @Param(4) int apiErrorReason) { + this.paymentsExitCode = paymentsExitCode; + this.debugMessage = debugMessage; + this.playBillingExitCode = playBillingExitCode; + this.apiErrorReason = apiErrorReason; + } + + public void writeToIntent(Intent intent) { + Bundle bundle = new Bundle(); + bundle.putInt("paymentsExitCode", paymentsExitCode); + bundle.putString("debugMessage", debugMessage); + bundle.putInt("playBillingExitCode", playBillingExitCode); + bundle.putInt("apiErrorReason", apiErrorReason); + intent.putExtra("com.google.android.gms.wallet.firstparty.EXTRA_EXIT_RESULT_BUNDLE", bundle); + } + + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(ExitResult.class); +} diff --git a/play-services-payments/src/main/java/com/google/android/gms/wallet/pm/PmRootChimeraActivity.kt b/play-services-payments/src/main/java/com/google/android/gms/wallet/pm/PmRootChimeraActivity.kt new file mode 100644 index 0000000000..28e85889e0 --- /dev/null +++ b/play-services-payments/src/main/java/com/google/android/gms/wallet/pm/PmRootChimeraActivity.kt @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.wallet.pm + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts +import com.google.android.gms.wallet.ACTION_BENDER3 +import com.google.android.gms.wallet.EXTRA_BENDER3_BUYFLOW_CONFIG +import com.google.android.gms.wallet.EXTRA_BENDER3_ENCRYPTED_PARAMS +import com.google.android.gms.wallet.EXTRA_BENDER3_O2_ACTION_TOKEN +import com.google.android.gms.wallet.EXTRA_BENDER3_UNENCRYPTED_PARAMS +import com.google.android.gms.wallet.activity.WidgetResultIntentBuilder +import com.google.android.gms.wallet.bender3.Bender3RedirectExtras +import com.google.android.gms.wallet.firstparty.pm.SecurePaymentsPayload +import com.google.android.gms.wallet.shared.BuyFlowConfig +import com.google.android.wallet.bender3.framework.client.ParcelableKeyValue +import org.microg.vending.billing.proto.PaymentManagerConfig +import java.util.UUID + +class PmRootChimeraActivity : ComponentActivity() { + + companion object { + private const val TAG = "PmRootChimera" + + private const val EXTRA_SECURE_PAYLOAD = "com.google.android.gms.wallet.firstparty.SECURE_PAYMENTS_PAYLOAD" + private const val EXTRA_PARAMS = "com.google.android.gms.wallet.firstparty.EXTRA_PARAMS" + private const val EXTRA_UNENCRYPTED_PARAMS = "com.google.android.gms.wallet.firstparty.EXTRA_UNENCRYPTED_PARAMS" + private const val EXTRA_AUTH_TOKEN = "com.google.android.gms.wallet.firstparty.EXTRA_AUTH_TOKEN" + private const val EXTRA_BUILD_TIME = "com.google.android.gms.wallet.intentBuildTimeMs" + private const val EXTRA_SUPPORTS_PROTO = "com.google.android.gms.wallet.firstparty.SUPPORTS_SECURE_PAYMENTS_PAYLOAD_PROTO" + } + + private val bender3Launcher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + setResult(result.resultCode, result.data) + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + onBackPressedDispatcher.addCallback(this) { + setResult(RESULT_CANCELED) + finish() + } + + if (savedInstanceState != null) return + + val params = resolveParamsFromIntent() ?: run { + Log.e(TAG, "No o2ActionToken, encryptedParams, or unencryptedParams — full PM UI not supported") + setResult(RESULT_CANCELED) + finish() + return + } + + bender3Launcher.launch(buildBender3Intent(params)) + } + + private data class PmParams( + val buyFlowConfig: BuyFlowConfig?, + val o2ActionToken: ByteArray?, + val encryptedParams: ByteArray?, + val unencryptedParams: ByteArray?, + val secureDataList: List?, + ) + + private fun resolveParamsFromIntent(): PmParams? { + val buyFlowConfig = intent.getParcelableExtra("com.google.android.gms.wallet.buyFlowConfig") + buyFlowConfig?.applicationParameters?.buyerAccount = intent.getParcelableExtra("com.google.android.gms.wallet.account") + + val securePaymentsPayload = intent.getParcelableExtra(EXTRA_SECURE_PAYLOAD) + + val secureDataList = securePaymentsPayload?.securePayments?.map { + ParcelableKeyValue(it.key, it.value) + } + + val o2ActionToken = securePaymentsPayload?.securePayload?.let { decodeO2ActionToken(it) } + val encryptedParams = intent.getByteArrayExtra(EXTRA_PARAMS) + val unencryptedParams = intent.getByteArrayExtra(EXTRA_UNENCRYPTED_PARAMS) + + if (o2ActionToken == null && encryptedParams == null && unencryptedParams == null) return null + + return PmParams(buyFlowConfig, o2ActionToken, encryptedParams, unencryptedParams, secureDataList) + } + + private fun decodeO2ActionToken(payload: ByteArray): ByteArray? { + return try { + PaymentManagerConfig.ADAPTER.decode(payload).o2ActionToken?.toByteArray() + } catch (e: Exception) { + Log.w(TAG, "Failed to decode PaymentManagerConfig", e) + null + } + } + + private fun buildBender3Intent(params: PmParams): Intent { + return Intent(ACTION_BENDER3).apply { + putExtra("widgetType", WidgetResultIntentBuilder.WIDGET_TYPE_SECURE_PAYMENTS) + putExtra(EXTRA_BENDER3_BUYFLOW_CONFIG, params.buyFlowConfig) + putExtra(EXTRA_BUILD_TIME, intent.getLongExtra(EXTRA_BUILD_TIME, 0)) + + params.encryptedParams?.let { putExtra(EXTRA_BENDER3_ENCRYPTED_PARAMS, it) } + params.unencryptedParams?.let { putExtra(EXTRA_BENDER3_UNENCRYPTED_PARAMS, it) } + params.o2ActionToken?.let { putExtra(EXTRA_BENDER3_O2_ACTION_TOKEN, it) } + intent.getByteArrayExtra(EXTRA_AUTH_TOKEN)?.let { putExtra("productAuthToken", it) } + + if (!params.secureDataList.isNullOrEmpty()) { + putParcelableArrayListExtra("secureDataArray", ArrayList(params.secureDataList)) + } + + putExtra(EXTRA_SUPPORTS_PROTO, intent.getBooleanExtra(EXTRA_SUPPORTS_PROTO, false)) + putExtra("bender3RedirectExtras", Bender3RedirectExtras(UUID.randomUUID().toString(), -1, -1)) + } + } +} diff --git a/settings.gradle b/settings.gradle index 1ab99b9000..5eff458eb1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -52,6 +52,7 @@ include ':play-services-nearby' include ':play-services-oss-licenses' include ':play-services-panorama' include ':play-services-pay' +include ':play-services-payments' include ':play-services-phenotype' include ':play-services-places' include ':play-services-places-placereport' diff --git a/vending-app/build.gradle b/vending-app/build.gradle index acc1ddaad0..64aeedeec9 100644 --- a/vending-app/build.gradle +++ b/vending-app/build.gradle @@ -5,6 +5,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' apply plugin: 'com.squareup.wire' android { @@ -140,6 +141,9 @@ dependencies { } wire { + protoPath { + srcDir '../play-services-core-proto/src/main/proto' + } kotlin { javaInterop = true } diff --git a/vending-app/src/main/AndroidManifest.xml b/vending-app/src/main/AndroidManifest.xml index 3bb7d80b28..df90b2db3e 100644 --- a/vending-app/src/main/AndroidManifest.xml +++ b/vending-app/src/main/AndroidManifest.xml @@ -65,9 +65,11 @@ androidx.compose.material.icons,com.google.accompanist.drawablepainter,androidx.compose.ui.util, androidx.compose.ui.unit,androidx.compose.ui.text,androidx.compose.ui.graphics,androidx.compose.ui.geometry, androidx.activity.compose,androidx.compose.runtime.saveable, + androidx.compose.ui.tooling,androidx.compose.material,androidx.compose.material.ripple, + androidx.compose.ui,androidx.compose.runtime,okhttp.okhttp3,androidx.startup, androidx.compose.material.ripple,androidx.compose.foundation.layout,androidx.compose.animation.core, coil.singleton, coil.base, androidx.compose.material3, com.google.accompanist.systemuicontroller, androidx.compose.animation.graphics, - androidx.compose.ui.tooling.data, androidx.compose.ui.tooling.preview" /> + androidx.compose.ui.tooling.data, androidx.compose.ui.tooling.preview,androidx.compose.animation,androidx.compose.foundation" /> - serial.append( - when { - index < prefixLength -> c - c.isDigit() -> '0' + kotlin.random.Random.nextInt(10) - c.isLowerCase() && c <= 'f' -> 'a' + kotlin.random.Random.nextInt(6) - c.isLowerCase() -> 'a' + kotlin.random.Random.nextInt(26) - c.isUpperCase() && c <= 'F' -> 'A' + kotlin.random.Random.nextInt(6) - c.isUpperCase() -> 'A' + kotlin.random.Random.nextInt(26) - else -> c - } - ) - } - return serial.toString() - } - - private fun randomMeid(): String { - // http://en.wikipedia.org/wiki/International_Mobile_Equipment_Identity - // We start with a known base, and generate random MEID - var meid = "35503104" - val rand = Random() - for (i in 0..5) { - meid += rand.nextInt(10).toString() - } - - // Luhn algorithm (check digit) - var sum = 0 - for (i in meid.indices) { - var c = meid[i].toString().toInt() - if ((meid.length - i - 1) % 2 == 0) { - c *= 2 - c = c % 10 + c / 10 - } - sum += c - } - val check = (100 - sum) % 10 - meid += check.toString() - return meid - } -} \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/billing/InAppBillingServiceImpl.kt b/vending-app/src/main/java/org/microg/vending/billing/InAppBillingServiceImpl.kt index 39a70ece52..12c71877d8 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/InAppBillingServiceImpl.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/InAppBillingServiceImpl.kt @@ -40,6 +40,7 @@ import org.json.JSONObject import org.microg.gms.utils.toHexString import org.microg.gms.utils.warnOnTransactionIssues import org.microg.vending.billing.core.* +import org.microg.vending.billing.proto.SecurePayloadData import java.util.Locale private class BuyFlowCacheEntry( @@ -87,7 +88,9 @@ class InAppBillingServiceImpl(private val context: Context) : IInAppBillingServi cacheKey: String, actionContexts: List = emptyList(), authToken: String? = null, - firstRequest: Boolean = false + firstRequest: Boolean = false, + integratorCallbackData: String? = null, + securePayload: SecurePayloadData? = null ): BuyFlowResult { if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "acquireRequest(cacheKey=$cacheKey, actionContexts=${actionContexts.map { it.toHexString() }}, authToken=$authToken)") val buyFlowCacheEntry = buyFlowCacheMap[cacheKey] ?: return BuyFlowResult( @@ -101,7 +104,9 @@ class InAppBillingServiceImpl(private val context: Context) : IInAppBillingServi actionContext = actionContexts, authToken = authToken, droidGuardResult = buyFlowCacheEntry.droidGuardResult.takeIf { !firstRequest }, - lastAcquireResult = buyFlowCacheEntry.lastAcquireResult.takeIf { !firstRequest } + lastAcquireResult = buyFlowCacheEntry.lastAcquireResult.takeIf { !firstRequest }, + integratorCallbackData = integratorCallbackData, + securePayload = securePayload ) val coreResult = try { @@ -345,7 +350,8 @@ class InAppBillingServiceImpl(private val context: Context) : IInAppBillingServi skuSerializedDockIdList = skuSerializedDocIdList, skuOfferIdTokenList = skuOfferIdTokenList, oldSkuPurchaseId = oldSkuPurchaseId, - oldSkuPurchaseToken = oldSkuPurchaseToken + oldSkuPurchaseToken = oldSkuPurchaseToken, + accountName = accountName ) val cacheEntryKey = "${packageName}:${account.name}" buyFlowCacheMap[cacheEntryKey] = diff --git a/vending-app/src/main/java/org/microg/vending/billing/Utils.kt b/vending-app/src/main/java/org/microg/vending/billing/Utils.kt index fb927291b6..59a6d0115f 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/Utils.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/Utils.kt @@ -7,46 +7,19 @@ package org.microg.vending.billing import android.accounts.Account import android.accounts.AccountManager -import android.annotation.SuppressLint import android.content.Context -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.icu.util.TimeZone -import android.net.ConnectivityManager import android.net.Uri -import android.os.Build.VERSION.SDK_INT import android.os.Bundle -import android.os.SystemClock -import android.provider.Settings import android.util.Base64 import android.util.Log -import android.view.WindowManager -import androidx.core.app.ActivityCompat import androidx.core.os.bundleOf import com.android.billingclient.api.BillingClient.BillingResponseCode +import org.microg.gms.deviceinfo.DeviceEnvInfo import org.microg.gms.profile.Build -import org.microg.gms.profile.ProfileManager import org.microg.gms.utils.digest import org.microg.gms.utils.getExtendedPackageInfo import org.microg.gms.utils.toBase64 -import org.microg.vending.billing.core.* -import java.util.* -import kotlin.collections.Collection -import kotlin.collections.List -import kotlin.collections.Map -import kotlin.collections.Set -import kotlin.collections.any -import kotlin.collections.filter -import kotlin.collections.firstOrNull -import kotlin.collections.joinToString -import kotlin.collections.map -import kotlin.collections.mutableListOf -import kotlin.collections.mutableMapOf -import kotlin.collections.set -import kotlin.collections.toByteArray -import kotlin.collections.toList -import kotlin.collections.toSet -import kotlin.collections.toTypedArray +import org.microg.vending.billing.core.ClientInfo fun Map.toBundle(): Bundle = bundleOf(*this.toList().toTypedArray()) @@ -88,20 +61,6 @@ fun resultBundle(@BillingResponseCode code: Int, msg: String?, data: Bundle = Bu return res } -@SuppressLint("MissingPermission") -fun getDeviceIdentifier(context: Context): String { - // TODO: Improve dummy data - val deviceId = DeviceIdentifier.meid /*try { - (context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager?)?.let { - it.subscriberId ?: it.deviceId - } - } catch (e: Exception) { - null - }*/ - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "getDeviceIdentifier deviceId: $deviceId") - return deviceId.toByteArray(Charsets.UTF_8).digest("SHA-1").toBase64(Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING) -} - fun getGoogleAccount(context: Context, name: String? = null): Account? { var accounts = AccountManager.get(context).getAccountsByType(DEFAULT_ACCOUNT_TYPE).toList() @@ -137,205 +96,15 @@ fun bundleToMap(bundle: Bundle?): Map { return result } -fun getDisplayInfo(context: Context): DisplayMetrics? { - return try { - val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - if (windowManager != null) { - val displayMetrics = android.util.DisplayMetrics() - windowManager.defaultDisplay.getRealMetrics(displayMetrics) - return DisplayMetrics( - displayMetrics.widthPixels, - displayMetrics.heightPixels, - displayMetrics.xdpi, - displayMetrics.ydpi, - displayMetrics.densityDpi - ) - } - return DisplayMetrics( - context.resources.displayMetrics.widthPixels, - context.resources.displayMetrics.heightPixels, - context.resources.displayMetrics.xdpi, - context.resources.displayMetrics.ydpi, - context.resources.displayMetrics.densityDpi - ) - } catch (e: Exception) { - null - } -} - -// TODO: Improve privacy -fun getBatteryLevel(context: Context): Int { - var batteryLevel = -1; - val intentFilter = IntentFilter("android.intent.action.BATTERY_CHANGED") - context.registerReceiver(null, intentFilter)?.let { - val level = it.getIntExtra("level", -1) - val scale = it.getIntExtra("scale", -1) - if (scale > 0) { - batteryLevel = level * 100 / scale - } - } - if (batteryLevel == -1 && SDK_INT >= 33) { - context.registerReceiver(null, intentFilter, Context.RECEIVER_EXPORTED)?.let { - val level = it.getIntExtra("level", -1) - val scale = it.getIntExtra("scale", -1) - if (scale > 0) { - batteryLevel = level * 100 / scale - } - } - } - return batteryLevel -} - -fun getTelephonyData(context: Context): TelephonyData? { - // TODO: Dummy data - return null /*try { - context.getSystemService(Context.TELEPHONY_SERVICE)?.let { - val telephonyManager = it as TelephonyManager - return TelephonyData( - telephonyManager.simOperatorName!!, - DeviceIdentifier.meid, - telephonyManager.networkOperator!!, - telephonyManager.simOperator!!, - telephonyManager.phoneType - ) - } - } catch (e: Exception) { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "getTelephonyData", e) - null - }*/ -} - -fun hasPermissions(context: Context, permissions: List): Boolean { - for (permission in permissions) { - if (ActivityCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) - return false - } - return true -} - -@SuppressLint("MissingPermission") -fun getLocationData(context: Context): LocationData? { - // TODO: Dummy data - return null /*try { - (context.getSystemService(Context.LOCATION_SERVICE) as LocationManager?)?.let { locationManager -> - if (hasPermissions( - context, - listOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - ) - ) { - locationManager.getLastKnownLocation("network")?.let { location -> - return LocationData( - location.altitude, - location.latitude, - location.longitude, - location.accuracy, - location.time.toDouble() - ) - } - } else { - null - } - } - } catch (e: Exception) { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "getLocationData", e) - null - }*/ -} - -fun getNetworkData(context: Context): NetworkData { - val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? - var linkDownstreamBandwidth: Long = 0 - var linkUpstreamBandwidth: Long = 0 - // TODO: Dummy data - /* - if (hasPermissions(context, listOf(Manifest.permission.ACCESS_NETWORK_STATE)) && SDK_INT >= 23) { - connectivityManager?.getNetworkCapabilities(connectivityManager.activeNetwork)?.let { - linkDownstreamBandwidth = (it.linkDownstreamBandwidthKbps * 1000 / 8).toLong() - linkUpstreamBandwidth = (it.linkUpstreamBandwidthKbps * 1000 / 8).toLong() - } - } - */ - val isActiveNetworkMetered = connectivityManager?.isActiveNetworkMetered ?: false - val netAddressList = mutableListOf() - // TODO: Dummy data - /*try { - NetworkInterface.getNetworkInterfaces()?.let { enumeration -> - while (true) { - if (!enumeration.hasMoreElements()) { - break - } - val enumeration1 = enumeration.nextElement().inetAddresses - while (enumeration1.hasMoreElements()) { - val inetAddress = enumeration1.nextElement() as InetAddress - if (inetAddress.isLoopbackAddress) { - continue - } - netAddressList.add(inetAddress.hostAddress) - } - } - } - } catch (socketException: NullPointerException) { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "getNetworkData:${socketException.message}") - }*/ - return NetworkData( - linkDownstreamBandwidth, - linkUpstreamBandwidth, - isActiveNetworkMetered, - netAddressList - ) -} - -@SuppressLint("HardwareIds") -fun getAndroidId(context: Context): String { - return Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "" -} - fun getUserAgent(): String { return "Android-Finsky/${Uri.encode(VENDING_VERSION_NAME)} (api=3,versionCode=$VENDING_VERSION_CODE,sdk=${Build.VERSION.SDK_INT},device=${Build.DEVICE},hardware=${Build.HARDWARE},product=${Build.PRODUCT},platformVersionRelease=${Build.VERSION.RELEASE},model=${Uri.encode(Build.MODEL)},buildId=${Build.ID},isWideScreen=0,supportedAbis=${Build.SUPPORTED_ABIS.joinToString(";")})" } -fun createDeviceEnvInfo(context: Context): DeviceEnvInfo? { - try { - val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) - return DeviceEnvInfo( - gpVersionCode = VENDING_VERSION_CODE, - gpVersionName = VENDING_VERSION_NAME, - gpPkgName = VENDING_PACKAGE_NAME, - androidId = getAndroidId(context), - biometricSupport = true, - biometricSupportCDD = true, - deviceId = getDeviceIdentifier(context), - serialNo = Build.SERIAL ?: "", - locale = Locale.getDefault(), - userAgent = getUserAgent(), - gpLastUpdateTime = packageInfo.lastUpdateTime, - gpFirstInstallTime = packageInfo.firstInstallTime, - gpSourceDir = packageInfo.applicationInfo!!.sourceDir!!, - device = Build.DEVICE ?: "", - displayMetrics = getDisplayInfo(context), - telephonyData = getTelephonyData(context), - product = Build.PRODUCT ?: "", - model = Build.MODEL ?: "", - manufacturer = Build.MANUFACTURER ?: "", - fingerprint = Build.FINGERPRINT ?: "", - release = Build.VERSION.RELEASE ?: "", - brand = Build.BRAND ?: "", - batteryLevel = getBatteryLevel(context), - timeZoneOffset = if (SDK_INT >= 24) TimeZone.getDefault().rawOffset.toLong() else 0, - locationData = getLocationData(context), - isAdbEnabled = false, //Settings.Global.getInt(context.contentResolver, "adb_enabled", 0) == 1, - installNonMarketApps = true, //Settings.Secure.getInt(context.contentResolver, "install_non_market_apps", 0) == 1, - networkData = getNetworkData(context), - uptimeMillis = SystemClock.uptimeMillis(), - timeZoneDisplayName = if (SDK_INT >= 24) TimeZone.getDefault().displayName!! else "", - googleAccounts = AccountManager.get(context).getAccountsByType(DEFAULT_ACCOUNT_TYPE).map { it.name } - ) - } catch (e: Exception) { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "createDeviceInfo", e) - return null - } -} \ No newline at end of file +fun createDeviceEnvInfo(context: Context): DeviceEnvInfo? = + org.microg.gms.deviceinfo.createDeviceEnvInfo( + context, + gpVersionCode = VENDING_VERSION_CODE, + gpVersionName = VENDING_VERSION_NAME, + gpPkgName = VENDING_PACKAGE_NAME, + userAgent = getUserAgent(), + ) \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/AcknowledgePurchaseResult.kt b/vending-app/src/main/java/org/microg/vending/billing/core/AcknowledgePurchaseResult.kt index 5843d53b29..64d60b7215 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/AcknowledgePurchaseResult.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/AcknowledgePurchaseResult.kt @@ -20,17 +20,17 @@ class AcknowledgePurchaseResult( return AcknowledgePurchaseResult( null, mapOf( - "RESPONSE_CODE" to response.failedResponse.statusCode, - "DEBUG_MESSAGE" to response.failedResponse.msg + "RESPONSE_CODE" to (response.failedResponse?.statusCode ?: 0), + "DEBUG_MESSAGE" to (response.failedResponse?.msg ?: "") ) ) } if (response.purchaseItem == null) { throw NullPointerException("AcknowledgePurchaseResponse PurchaseItem is null") } - if (response.purchaseItem.purchaseItemData.size != 1) + if (response.purchaseItem?.purchaseItemData?.size != 1) throw IllegalStateException("AcknowledgePurchaseResult purchase item count != 1") - return AcknowledgePurchaseResult(parsePurchaseItem(response.purchaseItem).getOrNull(0)) + return AcknowledgePurchaseResult(parsePurchaseItem(response.purchaseItem!!).getOrNull(0)) } } } \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/AcquireParams.kt b/vending-app/src/main/java/org/microg/vending/billing/core/AcquireParams.kt index a2339e9d98..f3638960a7 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/AcquireParams.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/AcquireParams.kt @@ -1,9 +1,13 @@ package org.microg.vending.billing.core +import org.microg.vending.billing.proto.SecurePayloadData + data class AcquireParams( val buyFlowParams: BuyFlowParams, val actionContext: List = emptyList(), val droidGuardResult: String? = null, val authToken: String? = null, - var lastAcquireResult: AcquireResult? = null + var lastAcquireResult: AcquireResult? = null, + val integratorCallbackData: String? = null, + val securePayload: SecurePayloadData? = null ) \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/BuyFlowParams.kt b/vending-app/src/main/java/org/microg/vending/billing/core/BuyFlowParams.kt index 5c85720edc..a7261151aa 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/BuyFlowParams.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/BuyFlowParams.kt @@ -11,5 +11,6 @@ data class BuyFlowParams( val skuSerializedDockIdList: List? = null, val skuOfferIdTokenList: List? = null, val oldSkuPurchaseToken: String? = null, - val oldSkuPurchaseId: String? = null + val oldSkuPurchaseId: String? = null, + val accountName: String? = null ) \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/ConsumePurchaseResult.kt b/vending-app/src/main/java/org/microg/vending/billing/core/ConsumePurchaseResult.kt index 19dedcc22d..6cafce06cb 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/ConsumePurchaseResult.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/ConsumePurchaseResult.kt @@ -19,8 +19,8 @@ class ConsumePurchaseResult( if (consumePurchaseResponse.failedResponse != null) { return ConsumePurchaseResult( mapOf( - "RESPONSE_CODE" to consumePurchaseResponse.failedResponse.statusCode, - "DEBUG_MESSAGE" to consumePurchaseResponse.failedResponse.msg + "RESPONSE_CODE" to (consumePurchaseResponse.failedResponse?.statusCode ?: 0), + "DEBUG_MESSAGE" to (consumePurchaseResponse.failedResponse?.msg ?: "") ) ) } diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/GetPurchaseHistoryResult.kt b/vending-app/src/main/java/org/microg/vending/billing/core/GetPurchaseHistoryResult.kt index 801c10a9c9..67bc5eeac7 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/GetPurchaseHistoryResult.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/GetPurchaseHistoryResult.kt @@ -22,8 +22,8 @@ class GetPurchaseHistoryResult( null, null, mapOf( - "RESPONSE_CODE" to response.failedResponse.statusCode, - "DEBUG_MESSAGE" to response.failedResponse.msg + "RESPONSE_CODE" to (response.failedResponse?.statusCode ?: 0), + "DEBUG_MESSAGE" to (response.failedResponse?.msg ?: "") ) ) } diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/GetSkuDetailsResult.kt b/vending-app/src/main/java/org/microg/vending/billing/core/GetSkuDetailsResult.kt index 7664b2dda3..133c2d1b61 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/GetSkuDetailsResult.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/GetSkuDetailsResult.kt @@ -19,18 +19,18 @@ class GetSkuDetailsResult private constructor( return GetSkuDetailsResult( emptyList(), mapOf( - "RESPONSE_CODE" to skuDetailsResponse.failedResponse.statusCode, - "DEBUG_MESSAGE" to skuDetailsResponse.failedResponse.msg + "RESPONSE_CODE" to (skuDetailsResponse.failedResponse?.statusCode ?: 0), + "DEBUG_MESSAGE" to (skuDetailsResponse.failedResponse?.msg ?: "") ) ) } val skuDetailsList = - skuDetailsResponse.details.filter { it.skuDetails.isNotBlank() } + skuDetailsResponse.details.filter { it.skuDetails?.isNotBlank() == true } .map { skuDetails -> val skuInfo = skuDetails.skuInfo ?: SkuInfo() SkuDetailsItem( - skuDetails.skuDetails, - skuInfo.skuItem.associate { it.token to it.docId } + skuDetails.skuDetails ?: "", + skuInfo.skuItem.associate { (it.token ?: "") to it.docId } ) } return GetSkuDetailsResult(skuDetailsList) diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/HeaderProvider.kt b/vending-app/src/main/java/org/microg/vending/billing/core/HeaderProvider.kt index ef12993365..73e987f13a 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/HeaderProvider.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/HeaderProvider.kt @@ -1,5 +1,7 @@ package org.microg.vending.billing.core +import org.microg.gms.deviceinfo.DeviceEnvInfo + object HeaderProvider { fun getBaseHeaders(authData: AuthData, deviceInfo: DeviceEnvInfo): MutableMap { val headers: MutableMap = HashMap() diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/IAPCore.kt b/vending-app/src/main/java/org/microg/vending/billing/core/IAPCore.kt index 7984fe1ae9..5bd170eb93 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/IAPCore.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/IAPCore.kt @@ -1,17 +1,31 @@ package org.microg.vending.billing.core +import android.accounts.AccountManager +import android.app.KeyguardManager import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.util.Base64 import android.util.Log +import com.android.vending.makeTimestamp import org.json.JSONObject +import org.microg.gms.auth.AuthConstants +import org.microg.gms.deviceinfo.DeviceEnvInfo +import org.microg.gms.utils.ExtendedPackageInfo import org.microg.gms.utils.toBase64 import org.microg.vending.billing.proto.* -import org.microg.vending.proto.Timestamp import java.io.IOException -import java.util.concurrent.TimeUnit private val skuDetailsCache = IAPCacheManager(2048) +private fun dumpAcquireBase64(marker: String, bytes: ByteArray) { + Log.d("IAPCore", "===== $marker raw base64 (${bytes.size} bytes) BEGIN =====") + Base64.encodeToString(bytes, 11) + .chunked(200) + .forEach { Log.d("IAPCore", "[$marker] $it") } + Log.d("IAPCore", "===== $marker END =====") +} + class IAPCore( private val context: Context, private val deviceInfo: DeviceEnvInfo, @@ -131,6 +145,7 @@ class IAPCore( val theme = 2 val skuPackageName = params.buyFlowParams.skuParams["skuPackageName"] ?: clientInfo.pkgName + val extendedPackageInfo = ExtendedPackageInfo(context, skuPackageName as String) val docId = if (params.buyFlowParams.skuSerializedDockIdList?.isNotEmpty() == true) { val sDocIdBytes = Base64.decode(params.buyFlowParams.skuSerializedDockIdList[0], Base64.URL_SAFE + Base64.NO_WRAP) DocId.ADAPTER.decode(sDocIdBytes) @@ -164,8 +179,8 @@ class IAPCore( this.skuParamList = mapToSkuParamList(params.buyFlowParams.skuParams) this.unknown8 = 1 this.installerPackage = deviceInfo.gpPkgName - this.unknown10 = false - this.unknown11 = false + this.unknown10 = 0 + this.unknown11 = 1 this.unknown15 = UnkMessage1.Builder().apply { this.unknown1 = UnkMessage2.Builder().apply { this.unknown1 = 1 @@ -174,16 +189,48 @@ class IAPCore( this.versionCode1 = this@IAPCore.clientInfo.versionCode if (params.buyFlowParams.oldSkuPurchaseToken?.isNotBlank() == true) this.oldSkuPurchaseToken = params.buyFlowParams.oldSkuPurchaseToken - if (params.buyFlowParams.oldSkuPurchaseId?.isNotBlank() == true) + if (params.buyFlowParams.oldSkuPurchaseId?.isNotBlank() == true) { + this.oldSkuPurchaseToken = null this.oldSkuPurchaseId = params.buyFlowParams.oldSkuPurchaseId + } + unKnownMessage21 = UnKnownMessage21.Builder().apply { + val pkg = skuPackageName as? String ?: return@apply + this.unknown1 = runCatching { + context.packageManager + .getApplicationInfo(pkg, PackageManager.GET_META_DATA) + .metaData + ?.getInt("com.android.vending.derived.apk.id", 0) + ?.takeIf { it != 0 } + }.getOrNull() + }.build() + this.skuPackageSignatureSha256 = extendedPackageInfo.firstCertificateSha256?.toBase64(11) + this.secondaryAccount = AccountNameMessage.Builder().apply { + this.accountName = params.buyFlowParams.accountName + }.build() }.build() this.clientTokenB64 = createClientToken(this@IAPCore.deviceInfo, this@IAPCore.authData) this.deviceAuthInfo = DeviceAuthInfo.Builder().apply { this.canAuthenticate = true + this.isBiometricStrong = true + this.fingerprintValid = true + this.desiredAuthMethod = 0 this.unknown5 = 1 + this.lastGaiaAuthTimestamp = System.currentTimeMillis() this.unknown9 = true this.authFrequency = authFrequency + this.authParams = mutableMapOf().apply { + put("prc", "true") + put("adca", "true") + val km = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + km.isDeviceSecure + } else { + false + } + ) put("dle", "true") + } + this.unknown20 = false this.itemColor = ItemColor.Builder().apply { this.androidAppsColor = -16735885 this.booksColor = -11488012 @@ -191,11 +238,16 @@ class IAPCore( this.moviesColor = -52375 this.newsStandColor = -7686920 }.build() + this.verificationMethodSelectionMode = 2 + this.allowedGoogleAccounts = AccountManager.get(context).getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE) + .map { account -> + AccountNameMessage.Builder().accountName(account.name).build() + } + this.isAccessibilityServiceEnabled = false + this.isAccessibilityEnabledInConfig = false + this.hasSeenPurchaseSessionAuthRequirementPrompt = false + this.isAuthRationalizationFinished = true }.build() - this.unknown12 = UnkMessage5.Builder().apply { - this.unknown1 = 9 - }.build() - this.deviceIDBase64 = deviceInfo.deviceId this.newAcquireCacheKey = getAcquireCacheKey( this@IAPCore.deviceInfo, this@IAPCore.authData.email, @@ -216,11 +268,7 @@ class IAPCore( ) this.nonce = createNonce() this.theme = theme - this.ts = Timestamp.Builder().apply { - val ts = System.currentTimeMillis() - this.seconds = TimeUnit.MILLISECONDS.toSeconds(ts) - this.nanos = ((ts + TimeUnit.HOURS.toMillis(1L)) % 1000L * 1000000L).toInt() - }.build() + this.createTimestamp = makeTimestamp(System.currentTimeMillis()) }.build() } @@ -243,26 +291,34 @@ class IAPCore( val authTokensTemp = mutableMapOf() params.authToken?.let { authTokensTemp["rpt"] = it - } + params.integratorCallbackData?.let { + authTokensTemp["imeicd"] = it + } + authTokensTemp["spei"] = "false" this.authTokens = authTokensTemp - this.ts = Timestamp.Builder().apply { - val ts = System.currentTimeMillis() - this.seconds = TimeUnit.MILLISECONDS.toSeconds(ts) - this.nanos = ((ts + TimeUnit.HOURS.toMillis(1L)) % 1000L * 1000000L).toInt() - }.build() + params.securePayload?.let { + this.securePayload = it + } }.build() } + + dumpAcquireBase64("acquireRequest", acquireRequest.encode()) + return try { val response = HttpClient().post( GooglePlayApi.URL_EES_ACQUIRE, headers = HeaderProvider.getDefaultHeaders(authData, deviceInfo), - params = mapOf("theme" to acquireRequest.theme.toString()), + params = mapOf("theme" to (acquireRequest.theme ?: 2).toString()), payload = acquireRequest, GoogleApiResponse.ADAPTER ) + response.payload?.acquireResponse?.let { + dumpAcquireBase64("acquireResponse", it.encode()) + } AcquireResult.parseFrom(params, acquireRequest, response.payload?.acquireResponse) } catch (e: Exception) { + Log.e("IAPCore", "acquireRequest failed: ${e.message}", e) throw RuntimeException("Network request failed. message=${e.message}") } } diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/Utils.kt b/vending-app/src/main/java/org/microg/vending/billing/core/Utils.kt index d20419d80b..d50052e9af 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/Utils.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/Utils.kt @@ -6,9 +6,11 @@ import com.android.billingclient.api.BillingClient.ProductType import okio.ByteString import okio.ByteString.Companion.toByteString import org.json.JSONObject +import org.microg.gms.deviceinfo.DeviceEnvInfo import org.microg.vending.billing.TAG import org.microg.vending.billing.proto.* import org.microg.vending.billing.proto.PurchaseItem +import org.microg.vending.billing.proto.ClientToken import java.security.InvalidParameterException import java.security.SecureRandom import java.util.* @@ -81,7 +83,7 @@ fun createClientToken(deviceInfo: DeviceEnvInfo, authData: AuthData): String { this.unknown8 = 2 this.gpVersionCode = deviceInfo.gpVersionCode this.deviceInfo = ClientToken.DeviceInfo.Builder().apply { - this.unknown3 = "33" + this.sdkVersion = "33" this.device = deviceInfo.device deviceInfo.displayMetrics?.let { this.widthPixels = it.widthPixels @@ -113,7 +115,7 @@ fun createClientToken(deviceInfo: DeviceEnvInfo, authData: AuthData): String { this.isEmulator = false }.build() this.otherInfo = ClientToken.OtherInfo.Builder().apply { - this.gpInfo = mutableListOf(( + this.packageInfoList = mutableListOf(( ClientToken.GPInfo.Builder().apply { this.package_ = deviceInfo.gpPkgName this.versionCode = deviceInfo.gpVersionCode.toString() @@ -150,10 +152,10 @@ fun createClientToken(deviceInfo: DeviceEnvInfo, authData: AuthData): String { this.googleAccountCount = deviceInfo.googleAccounts.size }.build() }.build() - this.marketClientId = "am-google" + this.marketClientId = "am-android-att-us" this.unknown15 = 1 - this.unknown16 = 2 - this.unknown22 = 2 + this.grantedPhonePermissionState = 2 + this.cameraPermissionState = 2 deviceInfo.networkData?.let { this.linkDownstreamBandwidth = it.linkDownstreamBandwidth this.linkUpstreamBandwidth = it.linkUpstreamBandwidth @@ -162,9 +164,9 @@ fun createClientToken(deviceInfo: DeviceEnvInfo, authData: AuthData): String { this.unknown34 = 2 this.uptimeMillis = deviceInfo.uptimeMillis this.timeZoneDisplayName = deviceInfo.timeZoneDisplayName - this.unknown40 = 1 + this.secureElementState = SecureElementState.SECURE_ELEMENT_STATE_NOT_SUPPORTED }.build() - this.unknown11 = "-5228872483831680725" + this.leastSignificantBits = UUID.randomUUID().leastSignificantBits this.googleAccounts = deviceInfo.googleAccounts }.build() this.info2 = ClientToken.Info2.Builder().apply { @@ -205,6 +207,7 @@ fun getAcquireCacheKey( for (item in extras) { stringBuilder.append("#${item.key}=${item.value}") } + stringBuilder.append("#accountName=$accountName") return stringBuilder.toString() } @@ -220,17 +223,23 @@ fun responseBundleToMap(responseBundle: ResponseBundle?): Map { val result = mutableMapOf() if (responseBundle != null) { for (bundleItem in responseBundle.bundleItem) { - if (bundleItem.bv != null) { - result[bundleItem.key] = bundleItem.bv - } else if (bundleItem.i32v != null) { - result[bundleItem.key] = bundleItem.i32v - } else if (bundleItem.i64v != null) { - result[bundleItem.key] = bundleItem.i64v - } else if (bundleItem.sv != null) { - result[bundleItem.key] = bundleItem.sv - } else if (bundleItem.sList != null) { - result[bundleItem.key] = - ArrayList(bundleItem.sList.value_) + val key = bundleItem.key ?: continue + val bv = bundleItem.bv + val i32v = bundleItem.i32v + val i64v = bundleItem.i64v + val sv = bundleItem.sv + val sList = bundleItem.sList + if (bv != null) { + result[key] = bv + } else if (i32v != null) { + result[key] = i32v + } else if (i64v != null) { + result[key] = i64v + } else if (sv != null) { + result[key] = sv + } else if (sList != null) { + result[key] = + ArrayList(sList.value_) } else { } @@ -249,7 +258,7 @@ fun getSkuType(skuType: String): Int { } fun splitDocId(docId: DocId): List { - return docId.backendDocId.split(":") + return docId.backendDocId?.split(":") ?: emptyList() } fun parsePurchaseItem(purchaseItem: PurchaseItem): List { @@ -257,8 +266,9 @@ fun parsePurchaseItem(purchaseItem: PurchaseItem): List { - if (it.inAppPurchase == null) - continue - it.inAppPurchase.jsonData to it.inAppPurchase.signature + val inApp = it.inAppPurchase ?: continue + (inApp.jsonData ?: continue) to (inApp.signature ?: continue) } ProductType.SUBS -> { - if (it.subsPurchase == null) - continue - startAt = it.subsPurchase.startAt - expireAt = it.subsPurchase.expireAt - it.subsPurchase.jsonData to it.subsPurchase.signature + val subs = it.subsPurchase ?: continue + startAt = subs.startAt ?: 0L + expireAt = subs.expireAt ?: 0L + (subs.jsonData ?: continue) to (subs.signature ?: continue) } else -> { diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIComponents.kt b/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIComponents.kt index 3ac8ba362e..f75741a983 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIComponents.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIComponents.kt @@ -1,5 +1,7 @@ package org.microg.vending.billing.core.ui +import org.microg.vending.billing.proto.Screen + data class BAction( var type: ActionType, var delay: Int? = null, @@ -20,7 +22,8 @@ enum class ActionType { data class BScreen( val uiInfo: BUIInfo? = null, val action: BAction? = null, - val uiComponents: BUIComponents? = null + val uiComponents: BUIComponents? = null, + val screen: Screen? = null ) data class BUIInfo( diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIParser.kt b/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIParser.kt index ea48a9d9b5..27932643c1 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIParser.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIParser.kt @@ -36,7 +36,7 @@ private fun typeToDpSize(type: Int): Float { private fun parseUIInfo(uiInfo: UIInfo): BUIInfo { if (uiInfo.classType == 1) return BUIInfo(UIType.UNKNOWN) - return BUIInfo(UIType.fromValue(uiInfo.uiType)) + return BUIInfo(UIType.fromValue(uiInfo.uiType ?: 0)) } private fun parseScreen(screen: Screen): BScreen { @@ -53,7 +53,7 @@ private fun parseScreen(screen: Screen): BScreen { action = BAction(ActionType.UNKNOWN) parseAction(it, action!!) } - return BScreen(uiInfo, action, uiComponents) + return BScreen(uiInfo, action, uiComponents, screen) } private fun parseScreenMap(screenMap: Map): Map { @@ -74,47 +74,40 @@ fun parseAcquireResponse(acquireResponse: AcquireResponse): BAcquireResult { private fun parseAction(action: Action?, result: BAction): Boolean { if (action == null) return false - if (action.actionContext != ByteString.EMPTY) { - result.actionContext.add(action.actionContext.toByteArray()) + action.actionContext?.let { ctx -> + if (ctx != ByteString.EMPTY) { + result.actionContext.add(ctx.toByteArray()) + } } - if (action.timerAction != null) { - result.delay = action.timerAction.delay + action.timerAction?.let { timerAction -> + result.delay = timerAction.delay ?: 0 result.type = ActionType.DELAY - result.result = responseBundleToMap(action.timerAction.responseBundle) + result.result = responseBundleToMap(timerAction.responseBundle) return true } - if (action.actionExt?.extAction != null) { - val extAction = action.actionExt.extAction - if (extAction.droidGuardMap != null) { - result.droidGuardMap = extAction.droidGuardMap.map - } - if (extAction.action != null) { - return parseAction(extAction.action, result) - } + action.actionExt?.extAction?.let { extAction -> + extAction.droidGuardMap?.let { result.droidGuardMap = it.map } + extAction.action?.let { return parseAction(it, result) } } - if (action.showAction != null) { + action.showAction?.let { showAction -> result.type = ActionType.SHOW - result.screenId = action.showAction.screenId - if (action.showAction.action1 != null) { - parseAction(action.showAction.action1, result) - } - if (action.showAction.action != null) { - parseAction(action.showAction.action, result) - } + result.screenId = showAction.screenId + showAction.action1?.let { parseAction(it, result) } + showAction.action?.let { parseAction(it, result) } return true } - if (action.viewClickAction != null) { - if (action.viewClickAction.uiInfo != null && result.uiInfo == null) { - result.uiInfo = parseUIInfo(action.viewClickAction.uiInfo) + action.viewClickAction?.let { viewClickAction -> + if (viewClickAction.uiInfo != null && result.uiInfo == null) { + result.uiInfo = parseUIInfo(viewClickAction.uiInfo!!) } - return parseAction(action.viewClickAction.action, result) + return parseAction(viewClickAction.action, result) } - if (action.optionalAction != null) { - return parseAction(action.optionalAction.action1, result) + action.optionalAction?.let { optionalAction -> + return parseAction(optionalAction.action1, result) } - if (action.navigateToPage != null) { - result.srcScreenId = action.navigateToPage.from - return parseAction(action.navigateToPage.action, result) + action.navigateToPage?.let { navigateToPage -> + result.srcScreenId = navigateToPage.from + return parseAction(navigateToPage.action, result) } return false } @@ -137,7 +130,7 @@ private fun parseIconView(iconView: IconView): BIconView { if (iconView.type != 0) { type = iconView.type } - if (iconView.text.isNotBlank()) { + if (iconView.text?.isNotBlank() == true) { text = iconView.text } return BIconView(type, text) @@ -151,23 +144,23 @@ private fun parseInstrumentItemView(instrumentItemView: InstrumentItemView): BIn var state: BImageView? = null var action: BAction? = null if (instrumentItemView.icon != null) { - icon = parseImageView(instrumentItemView.icon) + icon = parseImageView(instrumentItemView.icon!!) } if (instrumentItemView.text != null) { - text = parsePlayTextView(instrumentItemView.text) + text = parsePlayTextView(instrumentItemView.text!!) } if (instrumentItemView.tips != null) { - tips = parsePlayTextView(instrumentItemView.tips) + tips = parsePlayTextView(instrumentItemView.tips!!) } if (instrumentItemView.state != null) { - state = parseImageView(instrumentItemView.state) + state = parseImageView(instrumentItemView.state!!) } if (instrumentItemView.action != null) { action = BAction(ActionType.UNKNOWN) parseAction(instrumentItemView.action, action) } if (instrumentItemView.extraInfo != null) { - extraInfo = parsePlayTextView(instrumentItemView.extraInfo) + extraInfo = parsePlayTextView(instrumentItemView.extraInfo!!) } return BInstrumentItemView(icon, text, tips, extraInfo, state, action) } @@ -177,7 +170,7 @@ private fun parseImageGroup(imageGroup: ImageGroup): BImageGroup { var viewInfo: BViewInfo? = null if (imageGroup.viewInfo != null) { - viewInfo = parseViewInfo(imageGroup.viewInfo) + viewInfo = parseViewInfo(imageGroup.viewInfo!!) } imageGroup.imageView.forEach { imageViews.add(parseImageView(it)) @@ -192,24 +185,14 @@ private fun parseImageView(imageView: ImageView): BImageView { var imageInfo: BImageInfo? = null var iconView: BIconView? = null var animation: BAnimation? = null - if (imageView.thumbnailImageView != null) { - if (imageView.thumbnailImageView.darkUrl.isNotBlank()) - darkUrl = imageView.thumbnailImageView.darkUrl - if (imageView.thumbnailImageView.lightUrl.isNotBlank()) - lightUrl = imageView.thumbnailImageView.lightUrl - } - if (imageView.viewInfo != null) { - viewInfo = parseViewInfo(imageView.viewInfo) - } - if (imageView.imageInfo != null) { - imageInfo = parseImageInfo(imageView.imageInfo) - } - if (imageView.iconView != null) { - iconView = parseIconView(imageView.iconView) - } - if (imageView.animation != null) { - animation = parseAnimation(imageView.animation) - } + imageView.thumbnailImageView?.let { thumb -> + if (thumb.darkUrl?.isNotBlank() == true) darkUrl = thumb.darkUrl + if (thumb.lightUrl?.isNotBlank() == true) lightUrl = thumb.lightUrl + } + imageView.viewInfo?.let { viewInfo = parseViewInfo(it) } + imageView.imageInfo?.let { imageInfo = parseImageInfo(it) } + imageView.iconView?.let { iconView = parseIconView(it) } + imageView.animation?.let { animation = parseAnimation(it) } return BImageView(viewInfo, imageInfo, lightUrl, darkUrl, animation, iconView) } @@ -225,11 +208,9 @@ private fun parseTextInfo(textInfo: TextInfo): BTextInfo { if (textInfo.gravity.isNotEmpty()) { gravityList = textInfo.gravity.map { BGravity.values()[it] } } - if (textInfo.textColorType != null) { - colorType = ColorType.values()[textInfo.textColorType] - } - if (textInfo.textAlignmentType != 0) { - textAlignmentType = TextAlignmentType.values()[textInfo.textAlignmentType] + textInfo.textColorType?.let { colorType = ColorType.values()[it] } + if ((textInfo.textAlignmentType ?: 0) != 0) { + textAlignmentType = TextAlignmentType.values()[textInfo.textAlignmentType ?: 0] } if (textInfo.styleType != 0) { styleType = textInfo.styleType @@ -245,7 +226,7 @@ private fun parseTextSpan(textSpan: TextSpan): BTextSpan { return if (textSpan.bulletSpan != null) { BTextSpan( TextSpanType.BULLETSPAN, - parseBulletSpan(textSpan.bulletSpan) + parseBulletSpan(textSpan.bulletSpan!!) ) } else { BTextSpan(TextSpanType.UNKNOWNSPAN) @@ -257,10 +238,10 @@ private fun parsePlayTextView(playTextView: PlayTextView): BPlayTextView { var viewInfo: BViewInfo? = null var textSpanList: MutableList = mutableListOf() if (playTextView.textInfo != null) { - textInfo = parseTextInfo(playTextView.textInfo) + textInfo = parseTextInfo(playTextView.textInfo!!) } if (playTextView.viewInfo != null) { - viewInfo = parseViewInfo(playTextView.viewInfo) + viewInfo = parseViewInfo(playTextView.viewInfo!!) } if (playTextView.textSpan.isNotEmpty()) { playTextView.textSpan.forEach { @@ -269,7 +250,7 @@ private fun parsePlayTextView(playTextView: PlayTextView): BPlayTextView { } return BPlayTextView( playTextView.text ?: "", - playTextView.isHtml, + playTextView.isHtml ?: false, textInfo, viewInfo, textSpanList @@ -281,9 +262,9 @@ private fun parseSingleLineTextView(singleLineTextView: SingleLineTextView): BSi var playTextView2: BPlayTextView? = null if (singleLineTextView.playTextView1 != null) - playTextView1 = parsePlayTextView(singleLineTextView.playTextView1) + playTextView1 = parsePlayTextView(singleLineTextView.playTextView1!!) if (singleLineTextView.playTextView2 != null) - playTextView2 = parsePlayTextView(singleLineTextView.playTextView2) + playTextView2 = parsePlayTextView(singleLineTextView.playTextView2!!) return BSingleLineTextView(playTextView1, playTextView2) } @@ -297,23 +278,23 @@ private fun parseIconTextCombinationView(iconTextCombinationView: IconTextCombin var footerImageGroup: BImageGroup? = null if (iconTextCombinationView.headerImageView != null) { - headerImageView = parseImageView(iconTextCombinationView.headerImageView) + headerImageView = parseImageView(iconTextCombinationView.headerImageView!!) } if (iconTextCombinationView.playTextView != null) { - playTextView = parsePlayTextView(iconTextCombinationView.playTextView) + playTextView = parsePlayTextView(iconTextCombinationView.playTextView!!) } if (iconTextCombinationView.badgeTextView != null) { - badgeTextView = parsePlayTextView(iconTextCombinationView.badgeTextView) + badgeTextView = parsePlayTextView(iconTextCombinationView.badgeTextView!!) } if (iconTextCombinationView.singleLineTextView.isNotEmpty()) { middleTextViewList = iconTextCombinationView.singleLineTextView.map { parseSingleLineTextView(it) } } if (iconTextCombinationView.footerImageGroup != null) { - footerImageGroup = parseImageGroup(iconTextCombinationView.footerImageGroup) + footerImageGroup = parseImageGroup(iconTextCombinationView.footerImageGroup!!) } if (iconTextCombinationView.viewInfo != null) { - viewInfo = parseViewInfo(iconTextCombinationView.viewInfo) + viewInfo = parseViewInfo(iconTextCombinationView.viewInfo!!) } return BIconTextCombinationView( @@ -329,7 +310,7 @@ private fun parseIconTextCombinationView(iconTextCombinationView: IconTextCombin private fun parseClickableTextView(clickableTextView: ClickableTextView): BClickableTextView { var playTextView: BPlayTextView? = null if (clickableTextView.playTextView != null) - playTextView = parsePlayTextView(clickableTextView.playTextView) + playTextView = parsePlayTextView(clickableTextView.playTextView!!) return BClickableTextView(playTextView) } @@ -340,19 +321,19 @@ private fun parseViewGroup(viewGroup: ViewGroup): BViewGroup { var imageView4: BImageView? = null var playTextView: BPlayTextView? = null if (viewGroup.imageView1 != null) { - imageView1 = parseImageView(viewGroup.imageView1) + imageView1 = parseImageView(viewGroup.imageView1!!) } if (viewGroup.imageView2 != null) { - imageView2 = parseImageView(viewGroup.imageView2) + imageView2 = parseImageView(viewGroup.imageView2!!) } if (viewGroup.imageView3 != null) { - imageView3 = parseImageView(viewGroup.imageView3) + imageView3 = parseImageView(viewGroup.imageView3!!) } if (viewGroup.imageView4 != null) { - imageView4 = parseImageView(viewGroup.imageView4) + imageView4 = parseImageView(viewGroup.imageView4!!) } if (viewGroup.playTextView != null) { - playTextView = parsePlayTextView(viewGroup.playTextView) + playTextView = parsePlayTextView(viewGroup.playTextView!!) } return BViewGroup(imageView1, imageView2, imageView3, imageView4, playTextView) } @@ -360,7 +341,7 @@ private fun parseViewGroup(viewGroup: ViewGroup): BViewGroup { private fun parseModuloImageView(moduloImageView: ModuloImageView): BModuloImageView { var imageView: BImageView? = null if (moduloImageView.imageView != null) { - imageView = parseImageView(moduloImageView.imageView) + imageView = parseImageView(moduloImageView.imageView!!) } return BModuloImageView(imageView) } @@ -407,7 +388,7 @@ private fun parseViewInfo(viewInfo: ViewInfo): BViewInfo { var action: BAction? = null var visibilityType: Int? = null - if (viewInfo.tag.isNotBlank()) { + if (viewInfo.tag?.isNotBlank() == true) { tag = viewInfo.tag } if (viewInfo.widthValue != 0f) { @@ -441,40 +422,40 @@ private fun parseViewInfo(viewInfo: ViewInfo): BViewInfo { bottomPadding = viewInfo.bottomPadding } if (viewInfo.startMarginType != 0) { - startMargin = typeToDpSize(viewInfo.startMarginType) + startMargin = typeToDpSize(viewInfo.startMarginType ?: 0) } if (viewInfo.topMarginType != 0) { - topMargin = typeToDpSize(viewInfo.topMarginType) + topMargin = typeToDpSize(viewInfo.topMarginType ?: 0) } if (viewInfo.endMarginType != 0) { - endMargin = typeToDpSize(viewInfo.endMarginType) + endMargin = typeToDpSize(viewInfo.endMarginType ?: 0) } if (viewInfo.bottomMarginType != 0) { - bottomMargin = typeToDpSize(viewInfo.bottomMarginType) + bottomMargin = typeToDpSize(viewInfo.bottomMarginType ?: 0) } if (viewInfo.startPaddingType != 0) { - startPadding = typeToDpSize(viewInfo.startPaddingType) + startPadding = typeToDpSize(viewInfo.startPaddingType ?: 0) } if (viewInfo.topPaddingType != 0) { - topPadding = typeToDpSize(viewInfo.topPaddingType) + topPadding = typeToDpSize(viewInfo.topPaddingType ?: 0) } if (viewInfo.endPaddingType != 0) { - endPadding = typeToDpSize(viewInfo.endPaddingType) + endPadding = typeToDpSize(viewInfo.endPaddingType ?: 0) } if (viewInfo.bottomPaddingType != 0) { - bottomPadding = typeToDpSize(viewInfo.bottomPaddingType) + bottomPadding = typeToDpSize(viewInfo.bottomPaddingType ?: 0) } - if (viewInfo.contentDescription.isNotBlank()) { + if (viewInfo.contentDescription?.isNotBlank() == true) { contentDescription = viewInfo.contentDescription } if (viewInfo.gravity.isNotEmpty()) { gravityList = viewInfo.gravity.map { BGravity.values()[it] } } if (viewInfo.backgroundColorType != 0) { - backgroundColorType = ColorType.values()[viewInfo.backgroundColorType] + backgroundColorType = ColorType.values()[viewInfo.backgroundColorType ?: 0] } if (viewInfo.borderColorType != 0) { - borderColorType = ColorType.values()[viewInfo.borderColorType] + borderColorType = ColorType.values()[viewInfo.borderColorType ?: 0] } if (viewInfo.action != null) { action = BAction(ActionType.UNKNOWN) @@ -506,72 +487,18 @@ private fun parseViewInfo(viewInfo: ViewInfo): BViewInfo { private fun parseContentComponent(contentComponent: ContentComponent): BComponent { val tag = contentComponent.tag - var viewInfo: BViewInfo? = null - var uiInfo: BUIInfo? = null - if (contentComponent.viewInfo != null) { - viewInfo = parseViewInfo(contentComponent.viewInfo) - } - if (contentComponent.uiInfo != null) { - uiInfo = parseUIInfo(contentComponent.uiInfo) - } - return if (contentComponent.iconTextCombinationView != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.ICONTEXTCOMBINATIONVIEW, - iconTextCombinationView = parseIconTextCombinationView(contentComponent.iconTextCombinationView) - ) - } else if (contentComponent.clickableTextView != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.CLICKABLETEXTVIEW, - clickableTextView = parseClickableTextView(contentComponent.clickableTextView) - ) - } else if (contentComponent.viewGroup != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.VIEWGROUP, - viewGroup = parseViewGroup(contentComponent.viewGroup) - ) - } else if (contentComponent.dividerView != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.DIVIDERVIEW, - dividerView = parseDividerView(contentComponent.dividerView) - ) - } else if (contentComponent.moduloImageView != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.MODULOIMAGEVIEW, - moduloImageView = parseModuloImageView(contentComponent.moduloImageView) - ) - } else if (contentComponent.buttonGroupView != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.BUTTONGROUPVIEW, - buttonGroupView = parseButtonGroupView(contentComponent.buttonGroupView) - ) - } else if (contentComponent.instrumentItemView != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.INSTRUMENTITEMVIEW, - instrumentItemView = parseInstrumentItemView(contentComponent.instrumentItemView) - ) - } else { - BComponent(viewType = ViewType.UNKNOWNVIEW) + val viewInfo = contentComponent.viewInfo?.let { parseViewInfo(it) } + val uiInfo = contentComponent.uiInfo?.let { parseUIInfo(it) } + val cc = contentComponent + return when { + cc.iconTextCombinationView != null -> BComponent(tag, uiInfo, viewInfo, ViewType.ICONTEXTCOMBINATIONVIEW, iconTextCombinationView = parseIconTextCombinationView(cc.iconTextCombinationView!!)) + cc.clickableTextView != null -> BComponent(tag, uiInfo, viewInfo, ViewType.CLICKABLETEXTVIEW, clickableTextView = parseClickableTextView(cc.clickableTextView!!)) + cc.viewGroup != null -> BComponent(tag, uiInfo, viewInfo, ViewType.VIEWGROUP, viewGroup = parseViewGroup(cc.viewGroup!!)) + cc.dividerView != null -> BComponent(tag, uiInfo, viewInfo, ViewType.DIVIDERVIEW, dividerView = parseDividerView(cc.dividerView!!)) + cc.moduloImageView != null -> BComponent(tag, uiInfo, viewInfo, ViewType.MODULOIMAGEVIEW, moduloImageView = parseModuloImageView(cc.moduloImageView!!)) + cc.buttonGroupView != null -> BComponent(tag, uiInfo, viewInfo, ViewType.BUTTONGROUPVIEW, buttonGroupView = parseButtonGroupView(cc.buttonGroupView!!)) + cc.instrumentItemView != null -> BComponent(tag, uiInfo, viewInfo, ViewType.INSTRUMENTITEMVIEW, instrumentItemView = parseInstrumentItemView(cc.instrumentItemView!!)) + else -> BComponent(viewType = ViewType.UNKNOWNVIEW) } } @@ -581,7 +508,7 @@ private fun parseButtonView(buttonView: ButtonView): BButtonView { val action = BAction(ActionType.UNKNOWN) text = buttonView.text ?: "" if (buttonView.viewInfo != null) { - viewInfo = parseViewInfo(buttonView.viewInfo) + viewInfo = parseViewInfo(buttonView.viewInfo!!) } if (buttonView.action != null) { parseAction(buttonView.action, action) @@ -590,47 +517,23 @@ private fun parseButtonView(buttonView: ButtonView): BButtonView { } private fun parseButtonGroupView(buttonGroupView: ButtonGroupView): BButtonGroupView { - var buttonViewList = mutableListOf() - - if (buttonGroupView.newButtonView != null) { - if (buttonGroupView.newButtonView.buttonView != null) { - buttonViewList.add(parseButtonView(buttonGroupView.newButtonView.buttonView)) - } - if (buttonGroupView.newButtonView.buttonView2 != null) { - buttonViewList.add(parseButtonView(buttonGroupView.newButtonView.buttonView2)) - } + val buttonViewList = mutableListOf() + buttonGroupView.newButtonView?.let { nbv -> + nbv.buttonView?.let { buttonViewList.add(parseButtonView(it)) } + nbv.buttonView2?.let { buttonViewList.add(parseButtonView(it)) } } return BButtonGroupView(buttonViewList) } private fun parseFooterComponent(footerComponent: FooterComponent): BComponent { val tag = footerComponent.tag - var viewInfo: BViewInfo? = null - var uiInfo: BUIInfo? = null - if (footerComponent.viewInfo != null) { - viewInfo = parseViewInfo(footerComponent.viewInfo) - } - if (footerComponent.uiInfo != null) { - uiInfo = parseUIInfo(footerComponent.uiInfo) - } - return if (footerComponent.buttonGroupView != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.BUTTONGROUPVIEW, - buttonGroupView = parseButtonGroupView(footerComponent.buttonGroupView) - ) - } else if (footerComponent.dividerView != null) { - BComponent( - tag, - uiInfo, - viewInfo, - ViewType.DIVIDERVIEW, - dividerView = parseDividerView(footerComponent.dividerView) - ) - } else { - BComponent(viewType = ViewType.UNKNOWNVIEW) + val viewInfo = footerComponent.viewInfo?.let { parseViewInfo(it) } + val uiInfo = footerComponent.uiInfo?.let { parseUIInfo(it) } + val fc = footerComponent + return when { + fc.buttonGroupView != null -> BComponent(tag, uiInfo, viewInfo, ViewType.BUTTONGROUPVIEW, buttonGroupView = parseButtonGroupView(fc.buttonGroupView!!)) + fc.dividerView != null -> BComponent(tag, uiInfo, viewInfo, ViewType.DIVIDERVIEW, dividerView = parseDividerView(fc.dividerView!!)) + else -> BComponent(viewType = ViewType.UNKNOWNVIEW) } } @@ -690,16 +593,87 @@ fun parseAcquireResponse( val action = BAction(ActionType.UNKNOWN) parseAction(acquireResponse.action, action) val screenMap = parseScreenMap(acquireResponse.screen) - val (result, purchaseItem) = parsePurchaseResponse( + val (primaryResult, primaryPurchaseItem) = parsePurchaseResponse( acquireParams, acquireResponse.acquireResult?.purchaseResponse ) + + val (result, purchaseItem) = if (primaryPurchaseItem == null) { + val (screenResult, screenItem) = extractPurchaseItemFromScreens( + acquireParams, + acquireResponse.screen + ) + if (screenItem != null) { + screenResult to screenItem + } else { + primaryResult to null + } + } else { + primaryResult to primaryPurchaseItem + } + val purchaseItems = mutableSetOf() - if (acquireResponse.acquireResult?.ownedPurchase != null) { - acquireResponse.acquireResult.ownedPurchase.purchaseItem.forEach { + acquireResponse.acquireResult?.ownedPurchase?.let { owned -> + owned.purchaseItem.forEach { purchaseItems.addAll(parsePurchaseItem(it)) } } if (purchaseItem != null) purchaseItems.add(purchaseItem) return AcquireParsedResult(action, result, purchaseItems.toList(), screenMap) +} +private fun extractPurchaseItemFromScreens( + acquireParams: AcquireParams, + screens: Map +): Pair, PurchaseItem?> { + for ((screenId, screen) in screens) { + val items = screen.actionState?.action?.finish?.purchaseResult?.items ?: continue + if (items.isEmpty()) continue + + val kv = mutableMapOf() + for (item in items) { + val k = item.key ?: continue + when { + item.stringValue != null -> kv[k] = item.stringValue!! + item.boolValue != null -> kv[k] = item.boolValue!! + item.intValue != null -> kv[k] = item.intValue!! + } + } + + val purchaseData = (kv["INAPP_PURCHASE_DATA"] as? String) + ?: (kv["FIRST_PARTY_PURCHASE_DATA"] as? String) + ?: continue + val signature = (kv["INAPP_DATA_SIGNATURE"] as? String) ?: "" + val responseCode = when (val rc = kv["RESPONSE_CODE"]) { + is Long -> rc.toInt() + is Int -> rc + null -> 0 + else -> 0 + } + if (responseCode != 0) continue + + val pdj = try { + JSONObject(purchaseData) + } catch (e: Exception) { + continue + } + val packageName = pdj.optString("packageName").takeIf { it.isNotBlank() } ?: continue + val purchaseToken = pdj.optString("purchaseToken").takeIf { it.isNotBlank() } ?: continue + val purchaseState = pdj.optInt("purchaseState", -1).takeIf { it != -1 } ?: continue + + val normalized = mapOf( + "RESPONSE_CODE" to responseCode, + "INAPP_PURCHASE_DATA" to purchaseData, + "INAPP_DATA_SIGNATURE" to signature + ) + return normalized to PurchaseItem( + acquireParams.buyFlowParams.skuType, + acquireParams.buyFlowParams.sku, + packageName, + purchaseToken, + purchaseState, + purchaseData, + signature + ) + } + return mapOf("RESPONSE_CODE" to 0, "DEBUG_MESSAGE" to "") to null } \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIType.kt b/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIType.kt index ff33e41df1..c497a03436 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIType.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/ui/UIType.kt @@ -20,6 +20,7 @@ enum class UIType(val value: Int) { PURCHASE_PAYMENT_DECLINED_CONTINUE_BUTTON(1301), BILLING_PROFILE_MORE_OPTION_BUTTON_SHOW_HIDEABLE_INSTRUMENT(12034), PURCHASE_CONSENT_COLLECTION_REFUND_RIGHTS_CONTINUE_BUTTON(11872), + PURCHASE_CART_PAYMENT_OPTIONS_LINK2(11916), BILLING_PROFILE_SCREEN_ABANDON(12035); companion object { diff --git a/vending-app/src/main/java/org/microg/vending/billing/ui/InAppBillingHostActivity.kt b/vending-app/src/main/java/org/microg/vending/billing/ui/InAppBillingHostActivity.kt index 85c28a9c61..5f60721377 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/ui/InAppBillingHostActivity.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/ui/InAppBillingHostActivity.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.content.res.Configuration import android.graphics.Rect import android.os.Bundle +import android.os.SystemClock import android.util.Log import android.view.Gravity import android.view.MotionEvent @@ -23,15 +24,23 @@ import androidx.annotation.RequiresApi import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope import com.android.billingclient.api.BillingClient +import com.google.android.gms.wallet.firstparty.WalletCustomTheme +import com.google.android.gms.wallet.firstparty.pm.SecurePaymentsPayload +import com.google.android.gms.wallet.shared.ApplicationParameters +import com.google.android.gms.wallet.shared.BuyFlowConfig import org.microg.vending.billing.ui.logic.NotificationEventId import org.microg.vending.billing.ui.logic.InAppBillingViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.microg.gms.common.Constants import org.microg.vending.billing.ADD_PAYMENT_METHOD_URL import org.microg.vending.billing.TAG +import org.microg.vending.billing.proto.SessionRestoreOption +import java.util.UUID private const val ADD_PAYMENT_REQUEST_CODE = 30002 +private const val PURCHASE_MANAGER_REQUEST_CODE = 68 @RequiresApi(21) class InAppBillingHostActivity : ComponentActivity() { @@ -79,6 +88,19 @@ class InAppBillingHostActivity : ComponentActivity() { val src = it.params.getString("src") openPaymentMethodActivity(src, account) } + NotificationEventId.OPEN_WALLET_PURCHASE_MANAGER -> { + val account = it.params.getParcelable("account") + val securePaymentsPayload = it.params.getParcelable("securePaymentsPayload") + if (account == null) { + Log.d(TAG, "initEventHandler account is null") + return@collect + } + if (securePaymentsPayload == null) { + Log.d(TAG, "initEventHandler securePaymentsPayload is null") + return@collect + } + openWalletPurchaseManager(account, securePaymentsPayload) + } } } } @@ -104,6 +126,31 @@ class InAppBillingHostActivity : ComponentActivity() { startActivityForResult(intent, ADD_PAYMENT_REQUEST_CODE) } + private fun openWalletPurchaseManager(account: Account, securePaymentsPayload: SecurePaymentsPayload) { + val intent = Intent("com.google.android.gms.wallet.firstparty.ACTION_PURCHASE_MANAGER") + intent.`package` = Constants.GMS_PACKAGE_NAME + + val applicationParameters = ApplicationParameters() + applicationParameters.extraParams = Bundle() + applicationParameters.walletCustomTheme = WalletCustomTheme() + + val buyFlowConfig = BuyFlowConfig() + buyFlowConfig.apply { + buyFlowName = "flow_pm" + googleTransactionId = UUID.randomUUID().toString() + callerPackage = packageName + this.applicationParameters = applicationParameters + sessionRestoreOption = SessionRestoreOption.SESSION_RESTORE_OPTION_REQUIRE.value + } + + intent.putExtra("com.google.android.gms.wallet.buyFlowConfig", buyFlowConfig) + intent.putExtra("com.google.android.gms.wallet.account", account) + intent.putExtra("com.google.android.gms.wallet.firstparty.SECURE_PAYMENTS_PAYLOAD", securePaymentsPayload) + intent.putExtra("com.google.android.gms.wallet.firstparty.SUPPORTS_SECURE_PAYMENTS_PAYLOAD_PROTO", false) + intent.putExtra("com.google.android.gms.wallet.intentBuildTimeMs", SystemClock.elapsedRealtime()) + startActivityForResult(intent, PURCHASE_MANAGER_REQUEST_CODE) + } + override fun onTouchEvent(event: MotionEvent?): Boolean { return when (event?.action) { MotionEvent.ACTION_UP -> { @@ -136,6 +183,25 @@ class InAppBillingHostActivity : ComponentActivity() { loadView(false) } + PURCHASE_MANAGER_REQUEST_CODE -> { + Log.d(TAG, "PurchaseManager result: resultCode=$resultCode, data=$data, extras=${data?.extras?.keySet()}") + when (resultCode) { + RESULT_OK -> { + // 3DS2 verification successful — notify ViewModel to proceed with completing the purchase + Log.d(TAG, "3DS2 verification succeeded, resuming purchase flow") + inAppBillingViewModel.onSecureVerificationComplete(true, data) + } + RESULT_CANCELED -> { + Log.d(TAG, "3DS2 verification cancelled by user") + inAppBillingViewModel.onSecureVerificationComplete(false, null) + } + else -> { + Log.d(TAG, "3DS2 verification error, resultCode=$resultCode") + inAppBillingViewModel.onSecureVerificationComplete(false, null) + } + } + } + else -> { super.onActivityResult(requestCode, resultCode, data) finishWithResult( diff --git a/vending-app/src/main/java/org/microg/vending/billing/ui/logic/InAppBillingViewModel.kt b/vending-app/src/main/java/org/microg/vending/billing/ui/logic/InAppBillingViewModel.kt index 26f034d792..063d011c98 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/ui/logic/InAppBillingViewModel.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/ui/logic/InAppBillingViewModel.kt @@ -6,7 +6,9 @@ package org.microg.vending.billing.ui.logic import android.content.Context +import android.content.Intent import android.os.Bundle +import android.util.Base64 import android.util.Log import androidx.annotation.RequiresApi import androidx.compose.runtime.getValue @@ -16,7 +18,8 @@ import androidx.core.os.bundleOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.billingclient.api.BillingClient -import io.ktor.utils.io.errors.IOException +import com.google.android.gms.wallet.firstparty.pm.SecurePaymentsData +import com.google.android.gms.wallet.firstparty.pm.SecurePaymentsPayload import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -27,10 +30,13 @@ import org.microg.vending.billing.* import org.microg.vending.billing.core.ui.ActionType import org.microg.vending.billing.core.ui.BAction import org.microg.vending.billing.core.ui.UIType +import org.microg.vending.billing.proto.SecureDataEntry +import org.microg.vending.billing.proto.SecurePayloadData enum class NotificationEventId { FINISH, - OPEN_PAYMENT_METHOD_ACTIVITY + OPEN_PAYMENT_METHOD_ACTIVITY, + OPEN_WALLET_PURCHASE_MANAGER, } enum class ErrorMessageRef { @@ -45,6 +51,9 @@ data class NotificationEvent( @RequiresApi(21) class InAppBillingViewModel : ViewModel() { + companion object { + private const val TAG = "InAppBillingViewModel" + } private val _event = Channel() val event = _event.receiveAsFlow() var startParams: Bundle? = null @@ -78,12 +87,24 @@ class InAppBillingViewModel : ViewModel() { passwdInputViewState = passwdInputViewState.copy(visible = false) } - private suspend fun submitBuyAction(authToken: String? = null) { + private suspend fun submitBuyAction( + authToken: String? = null, + integratorCallbackData: String? = null, + securePayload: SecurePayloadData? = null + ) { val param = startParams?.getString(KEY_IAP_SHEET_UI_PARAM) ?: return finishWithResult( billingUiViewState.result ) + //Unknown data, no process blockage + if (billingUiViewState.actionContextList.isEmpty()) { + billingUiViewState.actionContextList.add("0a0208027001b80301".decodeHex()) + } val buyFlowResult = - InAppBillingServiceImpl.acquireRequest(ContextProvider.context, param, billingUiViewState.actionContextList, authToken) + InAppBillingServiceImpl.acquireRequest( + ContextProvider.context, param, billingUiViewState.actionContextList, authToken, + integratorCallbackData = integratorCallbackData, + securePayload = securePayload + ) handleBuyFlowResult(buyFlowResult) } @@ -190,6 +211,12 @@ class InAppBillingViewModel : ViewModel() { finishWithResult(billingUiViewState.result) } + UIType.PURCHASE_CART_PAYMENT_OPTIONS_LINK2 -> { + viewModelScope.launch(Dispatchers.IO) { + showPaymentMethodPage("action") + finishWithResult(billingUiViewState.result) + } + } UIType.BILLING_PROFILE_OPTION_CREATE_INSTRUMENT, UIType.BILLING_PROFILE_OPTION_ADD_PLAY_CREDIT, UIType.BILLING_PROFILE_BUTTON_UPDATE_INSTRUMENT, @@ -284,9 +311,29 @@ class InAppBillingViewModel : ViewModel() { actionContextList = action.actionContext, visible = true ) + if (showScreen.screen?.securePayment?.selector?.fullConfig != null) { + val securePaymentsPayload = createSecurePaymentsPayload(billingUiViewState.showScreen.screen?.securePayment?.selector?.fullConfig?.payloadData!!) + _event.send( + NotificationEvent( + NotificationEventId.OPEN_WALLET_PURCHASE_MANAGER, + bundleOf("account" to lastBuyFlowResult.account, "securePaymentsPayload" to securePaymentsPayload) + ) + ) + } loadingDialogVisible = false } + private fun createSecurePaymentsPayload(payloadData: SecurePayloadData) : SecurePaymentsPayload { + val size = payloadData.entries.size + val arrSecurePaymentsData = arrayOfNulls(size) + for (v1 in 0..( + "com.google.android.gms.wallet.firstparty.SECURE_PAYMENTS_PAYLOAD" + ) + val securePayload = securePaymentsPayload?.let { payload -> + SecurePayloadData( + securePayload = if (payload.securePayload != null) okio.ByteString.of(*payload.securePayload) else null, + entries = payload.securePayments?.map { d -> SecureDataEntry(key = d.key, value_ = d.value) } ?: emptyList() + ) + } + viewModelScope.launch(Dispatchers.IO) { + try { + submitBuyAction( + integratorCallbackData = imeicd, + securePayload = securePayload + ) + } catch (e: Exception) { + finishWithResult( + resultBundle(BillingClient.BillingResponseCode.ERROR, "Purchase failed after 3DS2: ${e.message}") + ) + } + } + } else if (success) { + viewModelScope.launch(Dispatchers.IO) { + try { + submitBuyAction() + } catch (e: Exception) { + finishWithResult( + resultBundle(BillingClient.BillingResponseCode.ERROR, "Purchase failed after 3DS2: ${e.message}") + ) + } + } + } else { + finishWithResult( + resultBundle(BillingClient.BillingResponseCode.USER_CANCELED, "3DS2 verification cancelled") + ) + } + } + fun close() { val result = if (billingUiViewState.result.containsKey("INAPP_PURCHASE_DATA")) billingUiViewState.result diff --git a/vending-app/src/main/proto/Purchase.proto b/vending-app/src/main/proto/Purchase.proto deleted file mode 100644 index e03d298426..0000000000 --- a/vending-app/src/main/proto/Purchase.proto +++ /dev/null @@ -1,693 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 microG project team - * SPDX-License-Identifier: Apache-2.0 - */ - -syntax = "proto3"; - -option java_package = "org.microg.vending.billing.proto"; -option java_multiple_files = true; - -import "Timestamp.proto"; - - -message SkuDetailsRequest { - int32 apiVersion = 1; - string package = 2; - string type = 3; - repeated string skuId = 4; - repeated DynamicSku dynamicSku = 5; - bool isWifi = 6; - SkuDetailsExtra skuDetailsExtra = 7; - string skuPackage = 8; - repeated OfferSku offerSkus = 9; - repeated MultiOfferSkuDetail multiOfferSkuDetail = 10; -} - -message AcquireRequest { - DocumentInfo documentInfo = 1; - ClientInfo clientInfo = 2; - bytes serverContextToken = 3; - repeated bytes actionContext = 4; - string clientTokenB64 = 5; - DeviceAuthInfo deviceAuthInfo = 8; - map authTokens = 9; - UnkMessage5 unknown12 = 12; - string deviceIDBase64 = 19; - string newAcquireCacheKey = 20; - string nonce = 22; - int32 theme = 25; - Timestamp ts = 31; -} - -message CKDocument { - DocId docId = 1; - oneof SkuOfferIdToken { - string token3 = 2; - string token14 = 4; - } - int32 unknown3 = 3; -} - -message DeviceAuthInfo { - bool canAuthenticate = 1; - bool isBiometricStrong = 2; - bool fingerprintValid = 3; - int32 desiredAuthMethod = 4; - int32 unknown5 = 5; - int32 authFrequency = 6; - bool unknown9 = 9; - bool userHasFop = 16; - map authParams = 18; - bool unknown20 = 20; - ItemColor itemColor = 26; - string droidGuardPayload = 30; -} - -message UnkMessage5 { - int32 unknown1 = 1; -} - -message ItemColor { - int32 androidAppsColor = 1; - int32 booksColor = 2; - int32 musicColor = 3; - int32 moviesColor = 4; - int32 newsStandColor = 5; -} - -message ClientInfo { - int32 apiVersion = 1; - string package = 2; - int32 versionCode = 3; - string signatureMD5 = 4; - repeated SkuParam skuParamList = 7; - int32 unknown8 = 8; - string installerPackage = 9; - bool unknown10 = 10; - bool unknown11 = 11; - UnkMessage1 unknown15 = 15; - oneof OldSkuPurchase { - string oldSkuPurchaseToken = 16; - string oldSkuPurchaseId = 17; - } - int32 versionCode1 = 18; -} - -message ClientToken { - Info1 info1 = 1; - Info2 info2 = 2; - message Info1 { - bytes unknown2 = 2; - string locale = 7; - int32 unknown8 = 8; - int64 gpVersionCode = 9; - DeviceInfo deviceInfo = 10; - string unknown11 = 11; - repeated string googleAccounts = 19; - } - message Info2 { - string unknown1 = 1; - int32 unknown3 = 3; - repeated int32 unknown4 = 4; - int32 unknown5 = 5; - } - message DeviceInfo { - string unknown3 = 3; - string device = 4; - int32 widthPixels = 5; - int32 heightPixels = 6; - float xdpi = 7; - float ydpi = 8; - string gpPackage = 9; - string gpVersionCode = 10; - string gpVersionName = 11; - EnvInfo envInfo = 12; - string callingPackage = 13; - string marketClientId = 14; - int32 unknown15 = 15; - int32 unknown16 = 16; - string simOperatorName = 17; - string groupIdLevel1 = 18; - int64 subscriberId = 19; - int32 unknown22 = 22; - int64 linkDownstreamBandwidth = 23; - int64 linkUpstreamBandwidth = 24; - bool isActiveNetworkMetered = 25; - int32 densityDpi = 28; - int32 unknown34 = 34; - int64 uptimeMillis = 35; - string timeZoneDisplayName = 36; - int32 unknown40 = 40; - } - - message EnvInfo { - DeviceData deviceData = 1; - OtherInfo otherInfo = 2; - } - - message DeviceData { - int32 unknown1 = 1; - string simOperatorName = 2; - string phoneDeviceId = 3; - string phoneDeviceId1 = 5; - string line1Number = 6; - int64 gsfId = 7; - string device = 9; - string product = 10; - string model = 11; - string manufacturer = 12; - string fingerprint = 13; - string release = 15; - string brand = 21; - string serial = 22; - bool isEmulator = 24; - } - - message OtherInfo { - repeated GPInfo gpInfo = 1; - int32 batteryLevel = 3; - int64 timeZoneOffset = 4; - Location location = 6; - bool isAdbEnabled = 7; - bool installNonMarketApps = 8; - string iso3Language = 9; - repeated string netAddress = 10; - string locale = 11; - string networkOperator = 14; - string simOperator = 15; - string language = 18; - string country = 19; - int32 phoneType = 20; - int64 uptimeMillis = 21; - string timeZoneDisplayName = 22; - int32 googleAccountCount = 23; - bool isUserAMonkey = 24; - bool isAudioWork = 25; - bool hasUsbFeature = 26; - bool isChanging = 27; - int32 brightness = 28; - } - message GPInfo { - string package = 1; - string versionCode = 2; - int64 lastUpdateTime = 3; - int64 firstInstallTime = 4; - string sourceDir = 5; - } - message Location { - double altitude = 1; - double latitude = 2; - double longitude = 3; - float accuracy = 4; - double time = 5; - bool isMock = 6; - } -} - -message UnkMessage1 { - oneof Type { - UnkMessage2 unknown1 = 1; - UnkMessage3 unknown2 = 2; - UnkMessage4 unknown3 = 3; - } -} - -message UnkMessage2 { - int32 unknown1 = 1; -} - -message UnkMessage3 { - int32 unknown1 = 1; -} - -message UnkMessage4 { - int32 unknown1 = 1; -} - -message SkuParam { - string name = 1; - string sv = 2; - bool bv = 3; - int64 i64v = 4; - repeated string svList = 5; -} - -message DocumentInfo { - DocId docId = 1; - int32 unknown2 = 2; - oneof SkuOfferIdToken { - string token3 = 3; - string token14 = 14; - } -} - -message AcknowledgePurchaseRequest { - string purchaseToken = 1; - string developerPayload = 2; -} - -message MultiOfferSkuDetail { - string key = 1; - oneof value { - string sv = 2; - bool bv = 3; - int64 iv = 4; - SkuSerializedDocIds skuSerializedDocIds = 5; - } -} - -message SkuSerializedDocIds { - repeated string docIds = 1; -} - -message OfferSku { - string unknown1 = 1; - string unknown2 = 2; -} - -message SkuDetailsExtra { - string version = 1; -} - -message DynamicSku { - string unknown1 = 1; - string unknown2 = 2; - string unknown3 = 3; -} - -message DetailsResponse { - Item item = 4; -} - -message BuyResponse { - string deliveryToken = 55; -} - -message Item { - Offer offer = 8; - repeated Item subItem = 11; - DocumentDetails details = 13; -} - -message DocumentDetails { - AppDetails appDetails = 1; -} - -message AppDetails { - int32 versionCode = 3; - string packageName = 14; -} - -message Offer { - int64 micros = 1; - int32 offerType = 8; - string offerId = 19; -} - -message AcquireResponse { - map screen = 1; - AcquireResult acquireResult = 3; - bytes serverContextToken = 4; - Action action = 8; - bool needClear = 11; -} - -message AcquireResult { - repeated PurchaseItem purchaseItem = 3; - OwnedPurchase ownedPurchase = 8; - string signature = 9; - PurchaseResponse purchaseResponse = 10; -} - -message OwnedPurchase { - repeated PurchaseItem purchaseItem = 1; -} - -message PurchaseResponse { - bool isSuccessful = 1; - ResponseBundle responseBundle = 2; -} - -message ResponseBundle { - repeated BundleItem bundleItem = 1; -} - -message BundleItem { - string key = 1; - oneof value { - string sv = 2; - bool bv = 3; - int64 i64v = 4; - int32 i32v = 5; - BundleStringList sList = 6; - } -} - -message BundleStringList { - repeated string value = 1; -} - -message Screen { - UIInfo uiInfo = 1; - Action action = 5; - UiComponents uiComponents = 175996169; -} - -message UiComponents { - repeated ContentComponent contentComponent1 = 1; - repeated ContentComponent contentComponent2 = 2; - repeated FooterComponent footerComponent = 3; -} - -message ContentComponent { - UIInfo uiInfo = 1; - ViewInfo viewInfo = 2; - string tag = 4; - oneof UiComponent { - ClickableTextView clickableTextView = 20; - ViewGroup viewGroup = 21; - DividerView dividerView = 23; - InstrumentItemView instrumentItemView = 26; - ModuloImageView moduloImageView = 27; - IconTextCombinationView iconTextCombinationView = 37; - ButtonGroupView buttonGroupView = 57; - } -} - -message InstrumentItemView { - ImageView icon = 1; - PlayTextView text = 2; - PlayTextView tips = 3; - ImageView state = 5; - Action action = 6; - PlayTextView extraInfo = 7; -} - -message ModuloImageView { - ImageView imageView = 1; - Action action = 2; -} - -message DividerView { -} - -message ClickableTextView { - PlayTextView playTextView = 1; - Action action = 2; -} - -message IconView { - int32 type = 1; - string text = 2; -} - -message ImageInfo { - oneof ColorFilter{ - int32 value = 1; - int32 valueType = 3; - } - int32 modeType = 2; - int32 scaleType = 5; -} - -message ImageView { - ThumbnailImageView thumbnailImageView = 1; - ViewInfo viewInfo = 2; - ImageInfo imageInfo = 4; - IconView iconView = 5; - oneof AnimationType{ - Animation animation = 6; - } -} - -message Animation { - int32 type = 1; - int32 repeatCount = 2; -} - -message ViewGroup { - ImageView imageView1 = 1; - ImageView imageView2 = 2; - ImageView imageView3 = 3; - ImageView imageView4 = 4; - PlayTextView playTextView = 5; -} - -message ViewInfo { - string tag = 1; - float widthValue = 2; - float heightValue = 3; - float startMargin = 4; - float topMargin = 5; - float endMargin = 6; - float bottomMargin = 7; - float startPadding = 8; - float topPadding = 9; - float endPadding = 10; - float bottomPadding = 11; - int32 backgroundColor = 12; - int32 backgroundColorType = 37; - string contentDescription = 14; - Action action = 20; - repeated int32 gravity = 22; - int32 widthTypedValue = 23; - int32 heightTypedValue = 24; - int32 visibilityType = 29; - int32 borderColorType = 30; - int32 startMarginType = 41; - int32 topMarginType = 42; - int32 endMarginType = 43; - int32 bottomMarginType = 44; - int32 startPaddingType = 45; - int32 topPaddingType = 46; - int32 endPaddingType = 47; - int32 bottomPaddingType = 48; -} - -message ThumbnailImageView { - string lightUrl = 5; - string darkUrl = 28; -} - -message ImageGroup { - repeated ImageView imageView = 1; - ViewInfo viewInfo = 2; -} - -message IconTextCombinationView { - ImageView headerImageView = 1; - PlayTextView playTextView = 2; - repeated SingleLineTextView singleLineTextView = 5; - ViewInfo viewInfo = 6; - PlayTextView badgeTextView = 9; - ImageGroup footerImageGroup = 12; -} - -message SingleLineTextView { - PlayTextView playTextView1 = 1; - PlayTextView playTextView2 = 2; -} - -message Dimension { - int32 unitType = 1; - float unitValue = 2; -} - -message BulletSpan { - Dimension gapWidth = 1; -} - -message TextSpan { - oneof Span { - BulletSpan bulletSpan = 4; - } -} - -message PlayTextView { - oneof TextData { - string text = 1; - int32 textType = 10; - } - bool isHtml = 2; - ViewInfo viewInfo = 3; - TextInfo textInfo = 4; - repeated TextSpan textSpan = 7; -} - -message TextInfo { - oneof TextColor { - int32 textColorValue = 2; - int32 textColorType = 39; - } - int32 maxLines = 6; - repeated int32 gravity = 17; - int32 textAlignmentType = 36; - int32 styleType = 41; -} - -message FooterComponent { - UIInfo uiInfo = 1; - ViewInfo viewInfo = 2; - string tag = 5; - oneof UiComponent { - ButtonGroupView buttonGroupView = 22; - DividerView dividerView = 24; - IconTextCombinationView iconTextCombinationView = 25; - } -} - -message ButtonGroupView { - NewButtonView newButtonView = 6; -} - -message NewButtonView { - ButtonView buttonView = 1; - ButtonView buttonView2 = 2; -} - -message ButtonView { - oneof TextData { - string text = 1; - int32 fixedTextType = 9; - } - Action action = 2; - ViewInfo viewInfo = 4; -} - -message UIInfo { - int32 uiType = 1; - bytes context = 2; - int32 classType = 5; -} - -message NavigateToPage { - string id = 1; - string from = 2; - Action action = 3; -} - -message Action { - TimerAction timerAction = 3; - ShowAction showAction = 4; - bytes actionContext = 7; - NavigateToPage navigateToPage = 8; - ViewClickAction viewClickAction = 10; - OptionalAction optionalAction = 19; - ActionExt actionExt = 148814548; -} - -message OptionalAction { - repeated int32 unknown1 = 1; - Action action1 = 2; - Action action2 = 3; -} - -message ViewClickAction { - UIInfo uiInfo = 2; - Action action = 3; -} - -message TimerAction { - ResponseBundle responseBundle = 1; - bool isSuccessful = 2; - int32 delay = 3; - string url = 4; -} - -message ShowAction { - string screenId = 1; - Action action = 7; - Action action1 = 8; -} - -message ExtAction { - Action action = 1; - DroidGuardMap droidGuardMap = 20; -} - -message IABX { - repeated SkuParam skuParam = 1; -} - -message DroidGuardMap { - map map = 1; - int32 type = 2; -} - -message ActionExt { - ExtAction extAction = 1; -} - -message AcknowledgePurchaseResponse { - PurchaseItem purchaseItem = 1; - FailedResponse failedResponse = 2; -} - -message SkuDetailsResponse { - repeated SkuDetails details = 1; - bool unknown2 = 2; - FailedResponse failedResponse = 4; - repeated SkuInfo skuInfo = 6; -} - -message SkuInfo { - repeated SkuItem skuItem = 1; -} - -message SkuItem { - DocId docId = 1; - string unknown2 = 2; - string token = 3; -} - -message DocId { - string backendDocId = 1; - int32 type = 2; - int32 backend = 3; -} - -message SkuDetails { - string skuDetails = 1; - SkuInfo skuInfo = 4; -} - -message ConsumePurchaseResponse { - PurchaseItem purchaseItem = 1; - FailedResponse failedResponse = 3; -} - -message PurchaseItem { - repeated PurchaseItemData purchaseItemData = 4; -} - -message PurchaseItemData { - DocId docId = 1; - SubsPurchase subsPurchase = 6; - InAppPurchase inAppPurchase = 7; -} - -message InAppPurchase { - string jsonData = 1; - string signature = 2; -} - -message SubsPurchase { - int64 startAt = 1; - int64 expireAt = 2; - string jsonData = 5; - string signature = 6; -} - -message FailedResponse { - int32 statusCode = 1; - string msg = 2; -} - -message PurchaseHistoryResponse { - repeated string productId = 1; - repeated string purchaseJson = 2; - repeated string signature = 3; - string continuationToken = 4; - FailedResponse failedResponse = 5; -} \ No newline at end of file