diff --git a/build.gradle b/build.gradle index d64e7e78a5..6adf9327ef 100644 --- a/build.gradle +++ b/build.gradle @@ -31,12 +31,13 @@ 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.2' - ext.androidBuildVersionTools = '34.0.0' + ext.androidBuildVersionTools = '35.0.0' ext.androidMinSdk = 19 ext.androidTargetSdk = 29 @@ -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 5a5073cb96..4d25419ecd 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 @@ -107,7 +108,9 @@ dependencies { implementation "androidx.credentials:credentials:$credentialsVersion" implementation "androidx.work:work-runtime-ktx:$workVersion" + implementation "com.google.guava:guava:$guavaVersion" implementation "com.google.crypto.tink:tink-android:$tinkVersion" + implementation "io.coil-kt:coil-compose:2.4.0" } android { @@ -147,10 +150,6 @@ android { } } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.10" - } - sourceSets { main { java.srcDirs += 'src/main/kotlin' @@ -158,7 +157,9 @@ android { } lintOptions { - disable 'MissingTranslation', 'GetLocales', 'InvalidPackage', 'BatteryLife', 'ImpliedQuantity', 'MissingQuantity', 'InvalidWakeLockTag', 'UniquePermission' + disable 'MissingTranslation', 'GetLocales', 'InvalidPackage', 'BatteryLife', 'ImpliedQuantity', 'MissingQuantity', 'InvalidWakeLockTag', 'UniquePermission', 'ProtectedPermissions', 'AppLinkUrlError' + // FIXME: Requires newer kotlin + disable 'FlowOperatorInvokedInComposition', 'CoroutineCreationDuringComposition', 'StateFlowValueCalledInComposition' } buildTypes { diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index e938f9ca4f..713deb4842 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -158,6 +158,9 @@ + + + @@ -183,28 +186,16 @@ android:name="android.hardware.camera" android:required="false" /> - + + + android:theme="@style/Theme.LoginBlue" + tools:targetApi="21"/> + + + + + + + + + + + + + + + pair.first.order = idx pair diff --git a/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/ComponentTreeManager.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/ComponentTreeManager.kt new file mode 100644 index 0000000000..5f74652a3c --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/ComponentTreeManager.kt @@ -0,0 +1,243 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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 +import kotlin.collections.iterator + +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-core/src/main/kotlin/org/microg/gms/wallet/activity/DelegatorActivity.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/DelegatorActivity.kt new file mode 100644 index 0000000000..82855d3e77 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/DelegatorActivity.kt @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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.shared.BuyFlowConfig +import org.microg.gms.wallet.activity.PaymentState.* +import kotlinx.coroutines.launch +import org.microg.gms.wallet.ACTION_BENDER3 +import org.microg.gms.wallet.EXTRA_BENDER3_BUYFLOW_CONFIG +import org.microg.gms.wallet.EXTRA_BENDER3_ENCRYPTED_PARAMS +import org.microg.gms.wallet.EXTRA_BENDER3_O2_ACTION_TOKEN +import org.microg.gms.wallet.EXTRA_BENDER3_UNENCRYPTED_PARAMS +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 DelegatorActivity : 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-core/src/main/kotlin/org/microg/gms/wallet/activity/EventEngine.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/EventEngine.kt new file mode 100644 index 0000000000..6dca96f6b1 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/EventEngine.kt @@ -0,0 +1,284 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wallet.activity + +import android.util.Log +import org.microg.vending.billing.proto.* +import kotlin.collections.iterator + +/** + * 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-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentContext.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentContext.kt new file mode 100644 index 0000000000..e80f7a0005 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentContext.kt @@ -0,0 +1,1040 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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.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.gms.wallet.OAUTH_SCOPE_SIERRA +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 +import kotlin.collections.iterator + +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-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentController.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentController.kt new file mode 100644 index 0000000000..ef924eb200 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentController.kt @@ -0,0 +1,299 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentState.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentState.kt new file mode 100644 index 0000000000..348deb964b --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentState.kt @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentsScreen.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentsScreen.kt new file mode 100644 index 0000000000..9331335fbf --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/PaymentsScreen.kt @@ -0,0 +1,363 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wallet.activity + +import android.graphics.Typeface +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 +import kotlin.collections.iterator + +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) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + 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-core/src/main/kotlin/org/microg/gms/wallet/activity/Utils.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/Utils.kt new file mode 100644 index 0000000000..a4d54f8135 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/Utils.kt @@ -0,0 +1,238 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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.gms.deviceinfo.createDeviceEnvInfo +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.lang.Long +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 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 = 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 = 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-core/src/main/kotlin/org/microg/gms/wallet/activity/WidgetResultIntentBuilder.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/WidgetResultIntentBuilder.kt new file mode 100644 index 0000000000..22aefb81e0 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/activity/WidgetResultIntentBuilder.kt @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.wallet.activity + +import android.content.Intent +import android.util.Base64 +import org.microg.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-core/src/main/kotlin/org/microg/gms/wallet/extensions.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/extensions.kt new file mode 100644 index 0000000000..31de587f0d --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/extensions.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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-core/src/main/kotlin/org/microg/gms/wallet/firstparty/ExitResult.java b/play-services-core/src/main/kotlin/org/microg/gms/wallet/firstparty/ExitResult.java new file mode 100644 index 0000000000..3036bf72fe --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/firstparty/ExitResult.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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-core/src/main/kotlin/org/microg/gms/wallet/pm/PurchaseManagerActivity.kt b/play-services-core/src/main/kotlin/org/microg/gms/wallet/pm/PurchaseManagerActivity.kt new file mode 100644 index 0000000000..d7318738e8 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/wallet/pm/PurchaseManagerActivity.kt @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.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 org.microg.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.gms.wallet.ACTION_BENDER3 +import org.microg.gms.wallet.EXTRA_BENDER3_BUYFLOW_CONFIG +import org.microg.gms.wallet.EXTRA_BENDER3_ENCRYPTED_PARAMS +import org.microg.gms.wallet.EXTRA_BENDER3_O2_ACTION_TOKEN +import org.microg.gms.wallet.EXTRA_BENDER3_UNENCRYPTED_PARAMS +import org.microg.vending.billing.proto.PaymentManagerConfig +import java.util.UUID + +class PurchaseManagerActivity : 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/play-services-core/src/main/res/values-cs/strings.xml b/play-services-core/src/main/res/values-cs/strings.xml index 9f46a7863e..32fc682f79 100644 --- a/play-services-core/src/main/res/values-cs/strings.xml +++ b/play-services-core/src/main/res/values-cs/strings.xml @@ -403,4 +403,17 @@ Pokud je tato možnost povolena, zařízení může odpovědět na oznámení ze služby Najít moje zařízení od společnosti Google (jsou vyžadovány cloudové zprávy). Přihlášení přes Google Bezpečnostní klíč, mobilní telefon nebo tablet + Spravovat přístupové klíče + Žádné uložené přístupové klíče + Když si aplikace třetích stran zaregistrují přístupové klíče, microG si ponechá lokální záznam. Pokud aplikace smaže přístupový klíč bez upozornění služby microG, může záznam zůstat nepřiřazený. Zde můžete tyto záznamy odstranit. + Odstranit přístupový klíč + Odstranit přístupový klíč pro „%2$s“ na „%1$s“. Tato akce je nevratná; pro opětovné přihlášení si budete muset zaregistrovat nový přístupový klíč. + Odstranit + Neznámý uživatel + Zámek obrazovky + USB + NFC + Bluetooth + Hybridní + ID: %1$s diff --git a/play-services-core/src/main/res/values-de/strings.xml b/play-services-core/src/main/res/values-de/strings.xml index 5db3d7781d..27258d5350 100644 --- a/play-services-core/src/main/res/values-de/strings.xml +++ b/play-services-core/src/main/res/values-de/strings.xml @@ -411,4 +411,15 @@ Dies kann etwas dauern." Wenn aktiviert, kann das Gerät auf Mein-Gerät-finden-Benachrichtigungen von Google reagieren (Cloud Messaging ist erforderlich). Über Google anmelden Sicherheitsschlüssel, Smartphone oder Tablet + Kennung: %1$s + Passkeys verwalten + Keine gespeicherten Passkeys + Passkey löschen + Löschen + Unbekannter Benutzer + Bildschirmsperre + USB + NFC + Bluetooth + Hybrid diff --git a/play-services-core/src/main/res/values-ga/strings.xml b/play-services-core/src/main/res/values-ga/strings.xml index e19e450821..13484e81ed 100644 --- a/play-services-core/src/main/res/values-ga/strings.xml +++ b/play-services-core/src/main/res/values-ga/strings.xml @@ -403,4 +403,17 @@ Nuair a bhíonn sé cumasaithe, is féidir leis an bhfeiste freagairt d’fhógraí Aimsigh Mo Ghléas ó Google (tá Cloud Teachtaireachtaí ag teastáil). Sínigh isteach le Google Eochair slándála, fón cliste nó táibléad + Bainistigh eochracha pas + Gan aon eochracha pas sábháilte + Nuair a chláraíonn aipeanna tríú páirtí pasfhocail, coinníonn microG taifead áitiúil. Má scriosann aip pasfhocail gan fógra a thabhairt do microG, féadfaidh an taifead fanacht sáinnithe. Is féidir leat iontrálacha iarmharacha den sórt sin a bhaint anseo. + Scrios an eochair faire + Scrios an eochair faire do \"%2$s\" ar \"%1$s\". Ní féidir é seo a chealú; beidh ort eochair faire nua a chlárú le síniú isteach arís. + Scrios + Úsáideoir anaithnid + Glas scáileáin + USB + NFC + Bluetooth + Hibrideach + Aitheantas: %1$s diff --git a/play-services-core/src/main/res/values-pl/strings.xml b/play-services-core/src/main/res/values-pl/strings.xml index e6f3bd7ab9..e1aa8b1d75 100644 --- a/play-services-core/src/main/res/values-pl/strings.xml +++ b/play-services-core/src/main/res/values-pl/strings.xml @@ -415,4 +415,17 @@ Spróbuj ponownie później." Gdy włączone, urządzenie może odpowiadać powiadomieniom z usługi Google Znajdź Urządzenie (Wymagane Cloud Messaging). Zaloguj się z Google Klucz dostępu, smartfon lub tablet + Zarządzaj kluczami dostępu + Brak zapisanych kluczy dostępu + Gdy aplikacje innych producentów rejestrują klucze dostępu, microG przechowuje ich lokalny zapis. Jeśli aplikacja usunie klucz dostępu bez powiadomienia microG, zapis ten może pozostać w systemie. Tutaj można usunąć takie pozostałe wpisy. + Usuń klucz dostępu + Usuń klucz dostępu dla „%2$s” na „%1$s”. Czynności tej nie można cofnąć! Aby ponownie się zalogować, konieczne będzie zarejestrowanie nowego klucza dostępu. + Usuń + Nieznany użytkownik + Blokada ekranu + USB + NFC + Bluetooth + Hybrydowy + ID: %1$s diff --git a/play-services-core/src/main/res/values-pt-rBR/strings.xml b/play-services-core/src/main/res/values-pt-rBR/strings.xml index 2f5d1bd2b6..2177400551 100644 --- a/play-services-core/src/main/res/values-pt-rBR/strings.xml +++ b/play-services-core/src/main/res/values-pt-rBR/strings.xml @@ -410,4 +410,17 @@ Isso pode demorar alguns minutos." Ao ativar, o dispositivo poderá responder notificações do localizador do Google (O Cloud Messaging é necessário). Conectar-se com o Google Chave de segurança, celular ou tablet + Gerenciar chaves de acesso + Não há chaves de acesso + Quando apps de terceiros cadastram chaves de acesso, o microG mantém um registro local. Se um app apagar uma chave de acesso sem avisar o microG, o registro pode permanecer preso. Você pode remover esses itens residuais aqui. + Apagar chave de acesso + Apagar chave de acesso de \"%2$s\" em \"%1$s\". Isso não pode ser desfeito; você precisará cadastrar uma nova chave de acesso para se conectar de novo. + Apagar + Usuário desconhecido + Bloqueio da tela + USB + NFC + Bluetooth + Híbrido + ID: %1$s diff --git a/play-services-core/src/main/res/values-ro/strings.xml b/play-services-core/src/main/res/values-ro/strings.xml index 74978f7356..49a45d186a 100644 --- a/play-services-core/src/main/res/values-ro/strings.xml +++ b/play-services-core/src/main/res/values-ro/strings.xml @@ -415,4 +415,17 @@ Când este activat, dispozitivul poate răspunde la notificările Găsește-mi dispozitivul de la Google (este necesară mesageria în cloud). Conectează-te cu Google Cheie de securitate, smartphone sau tabletă + Gestionează cheile de acces + Fără chei de acces salvate + Atunci când aplicațiile terțe înregistrează cheile de acces, microG păstrează o înregistrare locală. Dacă o aplicație șterge o cheie de acces fără notificarea microG, înregistrarea poate rămâne blocată. Poți elimina astfel de intrări reziduale aici. + Șterge cheia de acces + Șterge cheia de acces pentru \"%2$s\" pe \"%1$s\". Acest lucru nu poate fi anulat; va trebui să înregistrezi o nouă cheie de acces pentru a te conecta din nou. + Șterge + Utilizator necunoscut + Blocare ecran + USB + NFC + Bluetooth + Hibrid + ID: %1$s diff --git a/play-services-core/src/main/res/values-ru/strings.xml b/play-services-core/src/main/res/values-ru/strings.xml index d70c734322..0d4c37bae9 100644 --- a/play-services-core/src/main/res/values-ru/strings.xml +++ b/play-services-core/src/main/res/values-ru/strings.xml @@ -407,4 +407,17 @@ Если эта функция включена, устройство может отвечать на уведомления \"Найти мое устройство»\"от Google (требуется Cloud Messaging). Войти через Google Ключ безопасности, смартфон или планшет + Управление ключами доступа + Нет сохраненных ключей доступа + Когда сторонние программы регистрируют ключи доступа, microG сохраняет локальную запись. Если приложение удаляет ключ доступа, не сообщая microG, запись может остаться заблокированной. Вы можете удалить следующие остаточные записи здесь. + Удалить ключ доступа + Удалить ключ доступа для \"%2$s\" из \"%1$s\". Это невозможно отменить; вам нужно будет зарегистрировать новый ключ доступа, чтобы снова войти в систему. + Удалить + Неизвестный пользователь + Блокировка экрана + USB + NFC + Bluetooth + Гибридный + ID: %1$s diff --git a/play-services-core/src/main/res/values-th/strings.xml b/play-services-core/src/main/res/values-th/strings.xml index 560b26ac8b..31e9eef69a 100644 --- a/play-services-core/src/main/res/values-th/strings.xml +++ b/play-services-core/src/main/res/values-th/strings.xml @@ -390,4 +390,17 @@ เมื่อเปิดใช้งาน อุปกรณ์จะสามารถตอบสนองต่อการแจ้งเตือน \"ค้นหาอุปกรณ์ของฉัน\" จาก Google (ต้องใช้บริการ Cloud Messaging) ลงชื่อเข้าใช้ด้วย Google กุญแจรักษาความปลอดภัย, สมาร์ทโฟน หรือแท็บเล็ต + จัดการพาสคีย์ + ไม่มีพาสคีย์ที่บันทึกไว้ + เมื่อแอปพลิเคชันของบุคคลที่สามลงทะเบียนพาสคีย์ microG จะเก็บบันทึกไว้ในเครื่อง หากแอปนั้นลบพาสคีย์โดยไม่แจ้งให้ microG ทราบ บันทึกนั้นอาจยังคงอยู่ คุณสามารถลบรายการที่เหลืออยู่เหล่านั้นได้ที่นี่ + ลบพาสคีย์ + ลบพาสคีย์สำหรับ \"%2$s\" บน \"%1$s\" การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณจะต้องลงทะเบียนพาสคีย์ใหม่เพื่อเข้าสู่ระบบอีกครั้ง + ลบ + ผู้ใช้ที่ไม่รู้จัก + การล็อกหน้าจอ + USB + NFC + Bluetooth + ผสม + รหัสประจำตัว: %1$s diff --git a/play-services-core/src/main/res/values-uk/strings.xml b/play-services-core/src/main/res/values-uk/strings.xml index 9fd1bad353..184d9a02d3 100644 --- a/play-services-core/src/main/res/values-uk/strings.xml +++ b/play-services-core/src/main/res/values-uk/strings.xml @@ -416,4 +416,17 @@ Якщо цю функцію ввімкнено, пристрій зможе відповідати на сповіщення Google Пошук телефона (потрібна служба Cloud Messaging). Увійти через Google Ключ безпеки, смартфон або планшет + Керування ключами доступу + Немає збережених ключів доступу + Коли сторонні програми реєструють ключі доступу, microG зберігає локальний запис. Якщо застосунок видаляє ключ доступу, не повідомляючи microG, запис може залишитися заблокованим. Ви можете видалити такі залишкові записи тут. + Видалити ключ доступу + Видалити ключ доступу для %2$s з %1$s. Це неможливо скасувати; Вам потрібно буде зареєструвати новий ключ доступу, щоб знову увійти до системи. + Видалити + Невідомий користувач + Блокування екрану + USB + NFC + Bluetooth + Гібридний + ID: %1$s 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-fido/core/src/main/res/layout/fido_qr_code_fragment.xml b/play-services-fido/core/src/main/res/layout/fido_qr_code_fragment.xml index 8e67d82c23..7e04df548f 100644 --- a/play-services-fido/core/src/main/res/layout/fido_qr_code_fragment.xml +++ b/play-services-fido/core/src/main/res/layout/fido_qr_code_fragment.xml @@ -4,7 +4,8 @@ --> + xmlns:tools="http://schemas.android.com/tools" + tools:targetApi="24"> diff --git a/play-services-fido/core/src/main/res/navigation/nav_fido_authenticator.xml b/play-services-fido/core/src/main/res/navigation/nav_fido_authenticator.xml index 3ca941e27b..43d2defbf7 100644 --- a/play-services-fido/core/src/main/res/navigation/nav_fido_authenticator.xml +++ b/play-services-fido/core/src/main/res/navigation/nav_fido_authenticator.xml @@ -68,7 +68,8 @@ + tools:layout="@layout/fido_qr_code_fragment" + tools:targetApi="24"> 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-location/core/src/main/res/values-cs/strings.xml b/play-services-location/core/src/main/res/values-cs/strings.xml index 89c1d730fc..a037d735d5 100644 --- a/play-services-location/core/src/main/res/values-cs/strings.xml +++ b/play-services-location/core/src/main/res/values-cs/strings.xml @@ -45,4 +45,9 @@ Importovat polohová data ze souboru Importováno %1$d záznamů. Poslední nahlášená poloha + Časová osa Map Google + Povolit historii polohy + Pravidelně zaznamenávat informace o vaší poloze a zpřístupnit je skrze Časovou osu Map Google. + Synchronizovat do účtu Google + Synchronizovat historii polohy na servery Google, aby se daly zobrazit na ostatních zařízeních. diff --git a/play-services-location/core/src/main/res/values-pt-rBR/strings.xml b/play-services-location/core/src/main/res/values-pt-rBR/strings.xml index 7107e6771a..a330dea3cc 100644 --- a/play-services-location/core/src/main/res/values-pt-rBR/strings.xml +++ b/play-services-location/core/src/main/res/values-pt-rBR/strings.xml @@ -45,4 +45,9 @@ Para continuar usando os serviços de localização online, você precisa selecionar um serviço de dados de localização. %1$d relatórios importados. Última localização relatada + Histórico do Google Maps + Ativar histórico de localização + Grave sua localização periodicamente e torná-la disponível no histórico do Google Maps. + Sincronizar na conta do Google + Sincronize o histórico de localizações nos servidores do Google, para que apareça em outros dispositivos. diff --git a/play-services-location/core/src/main/res/values-ru/strings.xml b/play-services-location/core/src/main/res/values-ru/strings.xml index c49913aaa7..4f0fa3e432 100644 --- a/play-services-location/core/src/main/res/values-ru/strings.xml +++ b/play-services-location/core/src/main/res/values-ru/strings.xml @@ -45,9 +45,9 @@ Чтобы продолжить использование онлайн-сервисов определения местоположения, вам необходимо выбрать сервис определения местоположения. Импорт данных о местоположении из файла Последнее сообщенное местоположение - Таймлан Google Maps - Включить историю геолокации - Периодически записывать вашу геолокацию и сделать ее доступной в таймлайне Google Maps. - Синхронизация с аккаунтом Google - Синхронизировать историю геолокации с серверами Google для того, чтобы она стала доступна на других устройствах. + Хронология Google Карт + Включить историю местоположений + Периодически записывайте информацию о вашем местоположении и делайте ее доступной для хронологии Карт Google. + Синхронизировать с аккаунтом Google + Синхронизируйте историю местоположений с серверами Google, чтобы ее можно было отображать на других устройствах. diff --git a/play-services-location/core/src/main/res/values-uk/strings.xml b/play-services-location/core/src/main/res/values-uk/strings.xml index 4012434ccc..26d288ccd2 100644 --- a/play-services-location/core/src/main/res/values-uk/strings.xml +++ b/play-services-location/core/src/main/res/values-uk/strings.xml @@ -45,4 +45,9 @@ Запропоновано Потрібне налаштування Останнє зареєстроване розташування + Хронологія Google Карт + Включити історію розташування + Періодично записувати інформацію про своє місцезнаходження та робіть її доступною для хронології Карт Google. + Синхронізувати з обліковим записом Google + Синхронізуйте історію місцезнаходжень із серверами Google, щоб її можна було відображати на інших пристроях. diff --git a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleLocationEngine.kt b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleLocationEngine.kt index 43a6f53b2b..eaef52438b 100644 --- a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleLocationEngine.kt +++ b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleLocationEngine.kt @@ -5,9 +5,12 @@ package org.microg.gms.maps.mapbox +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION import android.app.PendingIntent import android.content.Context import android.os.Looper +import androidx.annotation.RequiresPermission import com.google.android.gms.location.LocationListener import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationServices @@ -21,6 +24,7 @@ class GoogleLocationEngine(context: Context) : LocationEngine { private val listenerMap: MutableMap, LocationListener> = hashMapOf() private val client = LocationServices.getFusedLocationProviderClient(context) + @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) override fun getLastLocation(callback: LocationEngineCallback) { client.lastLocation.addOnCompleteListener { if (it.isSuccessful) callback.onSuccess(LocationEngineResult.create(it.result)) @@ -28,6 +32,7 @@ class GoogleLocationEngine(context: Context) : LocationEngine { } } + @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) override fun requestLocationUpdates(request: LocationEngineRequest, callback: LocationEngineCallback, looper: Looper?) { listenerMap[callback] = listenerMap[callback] ?: LocationListener { callback.onSuccess(LocationEngineResult.create(it)) } client.requestLocationUpdates( @@ -48,6 +53,7 @@ class GoogleLocationEngine(context: Context) : LocationEngine { ) } + @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) override fun requestLocationUpdates(request: LocationEngineRequest, pendingIntent: PendingIntent?) { throw UnsupportedOperationException() } diff --git a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleMap.kt b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleMap.kt index 3d6f289323..8dff38050b 100644 --- a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleMap.kt +++ b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/GoogleMap.kt @@ -449,15 +449,16 @@ class GoogleMapImpl(context: Context, var options: GoogleMapOptions) : AbstractG } } + @SuppressLint("MissingPermission") private fun updateLocationEngineListener(myLocation: Boolean) { map?.locationComponent?.let { if (it.isLocationComponentActivated) { - it.isLocationComponentEnabled = myLocation if (myLocation) { it.locationEngine?.requestLocationUpdates(it.locationEngineRequest, locationEngineCallback, Looper.getMainLooper()) } else { it.locationEngine?.removeLocationUpdates(locationEngineCallback) } + it.isLocationComponentEnabled = myLocation } } } @@ -866,7 +867,16 @@ class GoogleMapImpl(context: Context, var options: GoogleMapOptions) : AbstractG mapView?.onResume() map?.locationComponent?.let { if (it.isLocationComponentEnabled) { - it.locationEngine?.requestLocationUpdates(it.locationEngineRequest, locationEngineCallback, Looper.getMainLooper()) + try { + it.locationEngine?.requestLocationUpdates( + it.locationEngineRequest, + locationEngineCallback, + Looper.getMainLooper() + ) + } catch (e: SecurityException) { + it.isLocationComponentEnabled = false + locationEnabled = false + } } } } diff --git a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/Circle.kt b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/Circle.kt index 5cc551212d..06d35b974a 100644 --- a/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/Circle.kt +++ b/play-services-maps/core/mapbox/src/main/kotlin/org/microg/gms/maps/mapbox/model/Circle.kt @@ -126,7 +126,7 @@ abstract class AbstractCircle( return pointList } else { // Cycle point list - val zero = pointList.removeFirst() + val zero = pointList.removeAt(0) pointList.add(zero) } } diff --git a/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ui/ExposureNotificationsSettingsActivity.kt b/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ui/ExposureNotificationsSettingsActivity.kt index d46685c688..355dce2c7e 100644 --- a/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ui/ExposureNotificationsSettingsActivity.kt +++ b/play-services-nearby/core/src/main/kotlin/org/microg/gms/nearby/exposurenotification/ui/ExposureNotificationsSettingsActivity.kt @@ -23,6 +23,7 @@ class ExposureNotificationsSettingsActivity : AppCompatActivity() { setContentView(R.layout.exposure_notifications_settings_activity) appBarConfiguration = AppBarConfiguration.Builder(navController.graph).build() NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration!!) + navController.setGraph(R.navigation.nav_nearby) } override fun onSupportNavigateUp(): Boolean { diff --git a/play-services-nearby/core/src/main/res/layout/exposure_notifications_settings_activity.xml b/play-services-nearby/core/src/main/res/layout/exposure_notifications_settings_activity.xml index 4d3d2716d6..20e65f11a8 100644 --- a/play-services-nearby/core/src/main/res/layout/exposure_notifications_settings_activity.xml +++ b/play-services-nearby/core/src/main/res/layout/exposure_notifications_settings_activity.xml @@ -14,6 +14,5 @@ android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" - app:defaultNavHost="true" - app:navGraph="@navigation/nav_nearby" /> + app:defaultNavHost="true" /> diff --git a/play-services-nearby/core/src/main/res/values-th/strings.xml b/play-services-nearby/core/src/main/res/values-th/strings.xml index 529dd1b21e..df175eaed9 100644 --- a/play-services-nearby/core/src/main/res/values-th/strings.xml +++ b/play-services-nearby/core/src/main/res/values-th/strings.xml @@ -19,7 +19,7 @@ ความเสี่ยงในบริเวณใกล้เคียง ระยะความเสี่ยง %1$s, %2$s - ประมวลผล %1$dกุญแจสำหรับการวินิจฉัยแล้ว + ประมวลผล %1$d คีย์การวินิจฉัยแล้ว รายงาน %1$d การเข้าปะทะกับผู้ที่มีความเสี่ยงติดเชื้อ: การใช้งาน API ในช่วง 14 วันที่ผ่านมา %1$d เรียกไปยัง %2$s 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 801549a9ef..97b1f65042 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" /> + android:theme="@style/Theme.Dialog.NoActionBar" + tools:targetApi="23" /> + android:exported="true" + tools:targetApi="21"> @@ -212,7 +216,8 @@ android:name="org.microg.vending.billing.ui.InAppBillingHostActivity" android:exported="true" android:theme="@style/InAppBillingStyle" - android:windowSoftInputMode="adjustResize" /> + android:windowSoftInputMode="adjustResize" + tools:targetApi="21" /> - 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 7e325af29f..35ffb000a3 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..7521ae3dcb 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 = 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/java/org/microg/vending/ui/InstallProgressNotification.kt b/vending-app/src/main/java/org/microg/vending/ui/InstallProgressNotification.kt index 49e14207ed..33d3b9c237 100644 --- a/vending-app/src/main/java/org/microg/vending/ui/InstallProgressNotification.kt +++ b/vending-app/src/main/java/org/microg/vending/ui/InstallProgressNotification.kt @@ -42,7 +42,12 @@ internal fun Context.notifyInstallPrompt(packageName: String, sessionId: Int, in setContentIntent(installIntent) setAutoCancel(true) }.apply { - notificationManager.notify(sessionId, this.build()) + try { + notificationManager.notify(sessionId, this.build()) + } catch (e: SecurityException) { + // Ignore + // TODO + } } } @@ -85,7 +90,11 @@ internal fun Context.notifySplitInstallProgress(packageName: String, sessionId: }?.apply { setOngoing(true) - notificationManager.notify(sessionId, this.build()) + try { + notificationManager.notify(sessionId, this.build()) + } catch (e: SecurityException) { + // Ignore + } } } @@ -116,7 +125,13 @@ internal fun Context.notifyInstallProgress( ) setProgress(progress.bytesTotal.toInt(), progress.bytesDownloaded.toInt(), false) setOngoing(true) - return this.build().also { notificationManager.notify(sessionId, it) } + return this.build().also { + try { + notificationManager.notify(sessionId, it) + } catch (e: SecurityException) { + // Ignore + } + } } CommitingSession -> { // Check whether silent installation is possible. Only show the notification if silent installation is supported, @@ -131,7 +146,13 @@ internal fun Context.notifyInstallProgress( ) setProgress(0, 0, true) setOngoing(true) - return this.build().also { notificationManager.notify(sessionId, it) } + return this.build().also { + try { + notificationManager.notify(sessionId, it) + } catch (e: SecurityException) { + // Ignore + } + } } else { notificationManager.cancel(sessionId) return null @@ -146,7 +167,11 @@ internal fun Context.notifyInstallProgress( ) ) setSmallIcon(android.R.drawable.stat_sys_download_done) - notificationManager.notify(sessionId, this.build()) + try { + notificationManager.notify(sessionId, this.build()) + } catch (e: SecurityException) { + // Ignore + } } else { notificationManager.cancel(sessionId) } @@ -162,7 +187,11 @@ internal fun Context.notifyInstallProgress( ) setSmallIcon(android.R.drawable.stat_notify_error) // see `InstallComplete` case - notificationManager.notify(sessionId, this.build()) + try { + notificationManager.notify(sessionId, this.build()) + } catch (e: SecurityException) { + // Ignore + } } else { notificationManager.cancel(sessionId) } diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/DownloadManager.kt b/vending-app/src/main/kotlin/com/google/android/finsky/DownloadManager.kt index 6c35ce6488..ddce64a404 100644 --- a/vending-app/src/main/kotlin/com/google/android/finsky/DownloadManager.kt +++ b/vending-app/src/main/kotlin/com/google/android/finsky/DownloadManager.kt @@ -120,14 +120,22 @@ class DownloadManager(private val context: Context) { notifyBuilderMap[moduleName] = notifyBuilder - NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notifyBuilder.build()) + try { + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notifyBuilder.build()) + } catch (e: SecurityException) { + // Ignore + } } private fun updateProgress(moduleName: String, progress: Int) { val notifyBuilder = notifyBuilderMap[moduleName] ?: return notifyBuilder.setProgress(100, progress, false) - NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notifyBuilder.build()) + try { + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notifyBuilder.build()) + } catch (e: SecurityException) { + // Ignore + } } fun shouldStop(shouldStop: Boolean) { 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 diff --git a/vending-app/src/main/res/values-ru/strings.xml b/vending-app/src/main/res/values-ru/strings.xml index ade499d74f..3e37836432 100644 --- a/vending-app/src/main/res/values-ru/strings.xml +++ b/vending-app/src/main/res/values-ru/strings.xml @@ -26,7 +26,7 @@ Доступно обновление Установленные приложения Установить - Удалить + Деинсталлировать Повторить Установка приложения и компонента Скачивание \"%s\" diff --git a/vending-app/src/main/res/values-uk/strings.xml b/vending-app/src/main/res/values-uk/strings.xml index 9165e944e9..e12a208d51 100644 --- a/vending-app/src/main/res/values-uk/strings.xml +++ b/vending-app/src/main/res/values-uk/strings.xml @@ -29,7 +29,7 @@ Встановлені застосунки Встановити Оновити - Видалити + Деінсталювати Встановлення застосунків і компонентів Показує хід встановлення застосунку та компонентів, повідомлення про успіх та невдачу. Завантаження «%s»