From f39735d67819acf2d0490aa6aab5fffb44219e0f Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 26 Sep 2026 15:22:09 +0000
Subject: [PATCH 01/13] ci: build a minified release app per store
No Android build in CI ran R8: the library and Example disable minify, and
every assemble step is a debug build. An app's release build is where a class
R8 cannot resolve, or a pin that links the wrong store SDK, first shows up.
verify-release-consumer.sh publishes the Play, Horizon, and Amazon artifacts
locally, then builds a minified release app for each through the shared store
resolver: no pin, -PopeniapStore=horizon, and ORG_GRADLE_PROJECT_openiapStore
=amazon. Each case asserts the resolved store, that releaseRuntimeClasspath and
R8's mapping carry only that store's SDK, and, for Amazon, that the manifest
receiver keeps its name in the dex.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_017jh5cR6NE24PUBfP9fAEF9
---
.github/workflows/ci.yml | 5 +
knowledge/_agent-context/context.md | 18 ++-
knowledge/internal/04-platform-packages.md | 16 +-
.../release-consumer/build.gradle | 37 +++++
.../release-consumer/gradle.properties | 2 +
.../release-consumer/proguard-rules.pro | 3 +
.../release-consumer/settings.gradle | 21 +++
.../src/main/AndroidManifest.xml | 4 +
.../google/scripts/verify-release-consumer.sh | 145 ++++++++++++++++++
9 files changed, 240 insertions(+), 11 deletions(-)
create mode 100644 packages/google/compatibility/release-consumer/build.gradle
create mode 100644 packages/google/compatibility/release-consumer/gradle.properties
create mode 100644 packages/google/compatibility/release-consumer/proguard-rules.pro
create mode 100644 packages/google/compatibility/release-consumer/settings.gradle
create mode 100644 packages/google/compatibility/release-consumer/src/main/AndroidManifest.xml
create mode 100755 packages/google/scripts/verify-release-consumer.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5b74d7f5d..01a614e7b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -599,6 +599,11 @@ jobs:
working-directory: packages/google
run: bash scripts/verify-store-plugin.sh
+ # Nothing above runs R8; an app's release build does.
+ - name: Verify minified release builds per store
+ working-directory: packages/google
+ run: bash scripts/verify-release-consumer.sh
+
# Run every store flavor so flavor-specific API-23 regressions cannot
# bypass lint coverage.
- name: Lint Android API compatibility
diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md
index 06a11f966..8ee115ec8 100644
--- a/knowledge/_agent-context/context.md
+++ b/knowledge/_agent-context/context.md
@@ -1,7 +1,7 @@
# OpenIAP Project Context
> **Auto-generated shared context for AI assistants**
-> Last updated: 2026-09-25T17:11:40.022Z
+> Last updated: 2026-09-26T15:21:34.573Z
>
> Canonical file: `knowledge/_agent-context/context.md`
@@ -1453,11 +1453,6 @@ a Horizon build link the Play SDK and now fail at the task-graph check:
cd packages/google && bash scripts/verify-store-resolver.sh
```
-`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved
-store reaches the published `openiap-google` and `kmp-iap` artifacts in an app,
-a KMP library module, and a module with its own `platform` flavors (which the
-plugin leaves alone). It needs an Android SDK and the network.
-
It applies the real resolver to the fixture in
`packages/google/compatibility/store-resolver`, so no Android SDK, device, or
network is needed; `compatibility/store-resolver/fake-adb` stands in for adb and
@@ -1468,6 +1463,17 @@ covers pins and their aliases, the legacy flags and their conflicts, the
for Quest, Fire and everything else, `ANDROID_SERIAL`, several attached
devices, release builds, `clean`, and the configuration cache.
+`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved
+store reaches the published `openiap-google` and `kmp-iap` artifacts in an app,
+a KMP library module, and a module with its own `platform` flavors (which the
+plugin leaves alone). It needs an Android SDK and the network.
+
+`scripts/verify-release-consumer.sh` is the only check that runs R8, as an
+app's release build does. It builds a minified release app per store from the
+locally published artifacts and asserts that each links only its store's SDK,
+that R8 keeps that SDK, and that the Amazon receiver keeps its name. It also
+needs an Android SDK and the network.
+
**Add a case whenever the rule changes.** A wrong store is invisible on the
machine that built it — it only appears when the artifact reaches a device that
cannot serve that billing SDK, which is after release. The suite is the only
diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md
index 9506f37b7..caeed4522 100644
--- a/knowledge/internal/04-platform-packages.md
+++ b/knowledge/internal/04-platform-packages.md
@@ -371,11 +371,6 @@ a Horizon build link the Play SDK and now fail at the task-graph check:
cd packages/google && bash scripts/verify-store-resolver.sh
```
-`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved
-store reaches the published `openiap-google` and `kmp-iap` artifacts in an app,
-a KMP library module, and a module with its own `platform` flavors (which the
-plugin leaves alone). It needs an Android SDK and the network.
-
It applies the real resolver to the fixture in
`packages/google/compatibility/store-resolver`, so no Android SDK, device, or
network is needed; `compatibility/store-resolver/fake-adb` stands in for adb and
@@ -386,6 +381,17 @@ covers pins and their aliases, the legacy flags and their conflicts, the
for Quest, Fire and everything else, `ANDROID_SERIAL`, several attached
devices, release builds, `clean`, and the configuration cache.
+`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved
+store reaches the published `openiap-google` and `kmp-iap` artifacts in an app,
+a KMP library module, and a module with its own `platform` flavors (which the
+plugin leaves alone). It needs an Android SDK and the network.
+
+`scripts/verify-release-consumer.sh` is the only check that runs R8, as an
+app's release build does. It builds a minified release app per store from the
+locally published artifacts and asserts that each links only its store's SDK,
+that R8 keeps that SDK, and that the Amazon receiver keeps its name. It also
+needs an Android SDK and the network.
+
**Add a case whenever the rule changes.** A wrong store is invisible on the
machine that built it — it only appears when the artifact reaches a device that
cannot serve that billing SDK, which is after release. The suite is the only
diff --git a/packages/google/compatibility/release-consumer/build.gradle b/packages/google/compatibility/release-consumer/build.gradle
new file mode 100644
index 000000000..243a57f7f
--- /dev/null
+++ b/packages/google/compatibility/release-consumer/build.gradle
@@ -0,0 +1,37 @@
+plugins {
+ id 'com.android.application' version '8.13.2'
+}
+
+layout.buildDirectory.set(file(providers.gradleProperty('consumerBuildDirectory').get()))
+
+// Picks the artifact the way the React Native, Expo, and Flutter wrappers do.
+apply from: new File(rootDir, '../../gradle/openiap-store.gradle')
+def store = openIapResolveStore('release-consumer').store
+def artifact = store == 'play' ? 'openiap-google' : "openiap-google-${store}"
+
+android {
+ namespace = 'dev.hyo.openiap.compatibility.release'
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = 'dev.hyo.openiap.compatibility.release'
+ minSdk = 23
+ targetSdk = 36
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled = true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ // Lint has its own CI step; this build checks what R8 links.
+ lint {
+ checkReleaseBuilds = false
+ }
+}
+
+dependencies {
+ implementation "io.github.hyochan.openiap:${artifact}:${providers.gradleProperty('openIapVersion').get()}"
+}
diff --git a/packages/google/compatibility/release-consumer/gradle.properties b/packages/google/compatibility/release-consumer/gradle.properties
new file mode 100644
index 000000000..1edd5f5a3
--- /dev/null
+++ b/packages/google/compatibility/release-consumer/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+android.useAndroidX=true
diff --git a/packages/google/compatibility/release-consumer/proguard-rules.pro b/packages/google/compatibility/release-consumer/proguard-rules.pro
new file mode 100644
index 000000000..c0e55dfe0
--- /dev/null
+++ b/packages/google/compatibility/release-consumer/proguard-rules.pro
@@ -0,0 +1,3 @@
+# Stand-ins for an app that calls the whole API, so R8 traces every store path.
+-keep class dev.hyo.openiap.OpenIapModule { public *; }
+-keep class dev.hyo.openiap.store.OpenIapStore { public *; }
diff --git a/packages/google/compatibility/release-consumer/settings.gradle b/packages/google/compatibility/release-consumer/settings.gradle
new file mode 100644
index 000000000..5c931b00c
--- /dev/null
+++ b/packages/google/compatibility/release-consumer/settings.gradle
@@ -0,0 +1,21 @@
+// Driven by scripts/verify-release-consumer.sh, which publishes the artifacts first.
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ maven {
+ url = uri(providers.gradleProperty('openIapRepository').get())
+ }
+ mavenCentral()
+ }
+}
+
+rootProject.name = 'openiap-google-release-consumer'
diff --git a/packages/google/compatibility/release-consumer/src/main/AndroidManifest.xml b/packages/google/compatibility/release-consumer/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..11bed8604
--- /dev/null
+++ b/packages/google/compatibility/release-consumer/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/packages/google/scripts/verify-release-consumer.sh b/packages/google/scripts/verify-release-consumer.sh
new file mode 100755
index 000000000..8ccc7adae
--- /dev/null
+++ b/packages/google/scripts/verify-release-consumer.sh
@@ -0,0 +1,145 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# No other build here runs R8. Build a minified release app per store from the
+# published artifacts and check which store SDK it linked and what R8 kept.
+
+google_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+repo_root=$(cd "$google_root/../.." && pwd)
+consumer_root="$google_root/compatibility/release-consumer"
+consumer_temp=$(mktemp -d)
+
+cleanup() {
+ find "$consumer_temp" -type f -delete
+ find "$consumer_temp" -type l -delete
+ find "$consumer_temp" -depth -type d -empty -delete
+}
+trap cleanup EXIT
+
+openiap_version=$(node -e \
+ "const versions = require(process.argv[1]); process.stdout.write(versions.google)" \
+ "$repo_root/openiap-versions.json")
+local_repository="$consumer_temp/repository"
+
+cd "$google_root"
+for variant in play horizon amazon; do
+ ./gradlew :openiap:publishMavenPublicationToMavenLocal \
+ -POPENIAP_PUBLISH_VARIANT="$variant" \
+ -Dmaven.repo.local="$local_repository" \
+ --no-daemon
+done
+
+stores=(play horizon amazon)
+declare -A artifact=(
+ [play]=openiap-google
+ [horizon]=openiap-google-horizon
+ [amazon]=openiap-google-amazon
+)
+declare -A sdk=(
+ [play]=com.android.billingclient:billing
+ [horizon]=com.meta.horizon.billingclient.api:horizon-billing-compatibility
+ [amazon]=com.amazon.device:amazon-appstore-sdk
+)
+declare -A sdk_package=(
+ [play]=com.android.billingclient.
+ [horizon]=com.meta.horizon.billingclient.
+ [amazon]=com.amazon.device.iap.
+)
+# The Appstore broadcasts to this receiver by name, so R8 must not rename it.
+declare -A named_class=(
+ [amazon]=com.amazon.device.iap.ResponseReceiver
+)
+
+failures=0
+
+fail() {
+ echo "FAIL [$1] $2" >&2
+ failures=$((failures + 1))
+}
+
+# Class lines in R8's mapping start at column one with the original name.
+mapping_has_package() {
+ awk -v prefix="$2" 'index($0, prefix) == 1 { found = 1; exit } END { exit !found }' "$1"
+}
+
+# check [gradle args...]
+check() {
+ local name=$1 store=$2 source=$3
+ shift 3
+ local build="$consumer_temp/build/$name"
+ local log="$consumer_temp/$name.log"
+ local mapping="$build/outputs/mapping/release/mapping.txt"
+ local failures_before=$failures
+ echo "== $name: expecting store=$store (source=$source)"
+
+ if ! "$repo_root/scripts/ci/retry-gradle.sh" ./gradlew -p "$consumer_root" \
+ dependencies --configuration releaseRuntimeClasspath assembleRelease \
+ -PopenIapRepository="$local_repository" \
+ -PopenIapVersion="$openiap_version" \
+ -PconsumerBuildDirectory="$build" \
+ --project-cache-dir "$consumer_temp/project-cache/$name" \
+ --no-daemon "$@" > "$log" 2>&1; then
+ tail -n 80 "$log" >&2
+ fail "$name" "the minified release build failed"
+ return 0
+ fi
+
+ if ! grep -qF "openiap: store=$store (source=$source;" "$log"; then
+ grep -F "openiap: store=" "$log" >&2 || true
+ fail "$name" "the resolver did not pick $store from $source"
+ fi
+ if [ ! -f "$mapping" ]; then
+ fail "$name" "R8 wrote no mapping at $mapping"
+ return 0
+ fi
+
+ local other
+ for other in "${stores[@]}"; do
+ if [ "$other" = "$store" ]; then
+ if ! grep -qF "io.github.hyochan.openiap:${artifact[$other]}:$openiap_version" "$log"; then
+ fail "$name" "releaseRuntimeClasspath lacks ${artifact[$other]}:$openiap_version"
+ fi
+ if ! grep -qF "${sdk[$other]}:" "$log"; then
+ fail "$name" "releaseRuntimeClasspath lacks ${sdk[$other]}"
+ fi
+ if ! mapping_has_package "$mapping" "${sdk_package[$other]}"; then
+ fail "$name" "R8 kept no ${sdk_package[$other]} class"
+ fi
+ else
+ if grep -qF "io.github.hyochan.openiap:${artifact[$other]}:" "$log"; then
+ fail "$name" "releaseRuntimeClasspath also links ${artifact[$other]}"
+ fi
+ if grep -qF "${sdk[$other]}:" "$log"; then
+ fail "$name" "releaseRuntimeClasspath also links ${sdk[$other]}"
+ fi
+ if mapping_has_package "$mapping" "${sdk_package[$other]}"; then
+ fail "$name" "the release APK carries ${sdk_package[$other]} classes"
+ fi
+ fi
+ done
+
+ local kept=${named_class[$store]:-}
+ if [ -n "$kept" ]; then
+ local apk descriptor="L${kept//.//};"
+ apk=$(find "$build/outputs/apk/release" -name '*.apk' | head -n 1)
+ if [ -z "$apk" ] || ! unzip -p "$apk" 'classes*.dex' | grep -aqF "$descriptor"; then
+ fail "$name" "the release APK has no $kept under that name"
+ fi
+ fi
+
+ if [ "$failures" -eq "$failures_before" ]; then
+ echo " ok: $name linked ${artifact[$store]} and R8 kept ${sdk_package[$store]}"
+ fi
+}
+
+# No pin and no store flavor: Play.
+check play play default
+check horizon horizon explicit -PopeniapStore=horizon
+# The channel CI and EAS builds use.
+ORG_GRADLE_PROJECT_openiapStore=amazon check amazon amazon explicit
+
+if [ "$failures" -gt 0 ]; then
+ echo "$failures release-build check(s) failed" >&2
+ exit 1
+fi
+echo "Minified release builds link the resolved store for play, horizon, and amazon."
From 04e5eebaebc846bd20c7c1999832dcaf9c8fb8b8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 26 Sep 2026 15:43:40 +0000
Subject: [PATCH 02/13] fix: ship the R8 rules the Horizon and Amazon SDKs need
The new minified release check failed on its first CI run. R8 stops every
minified Horizon build: the Horizon platform SDK references
javax.annotation.Nullable without shipping it. Amazon builds, but R8 renames
the Appstore SDK's classes and strips the fields it fills by reflection (none
of 71 survive), so its request pipeline cannot run in a release app.
Each flavor now ships the rules its SDK needs as consumer rules: a -dontwarn
for the JSR-305 annotation on Horizon, and Amazon's documented keep rules on
Amazon. The check now asserts that R8 renames no Amazon SDK class, in place of
the receiver-only check, which passed while the SDK was broken.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_017jh5cR6NE24PUBfP9fAEF9
---
knowledge/_agent-context/context.md | 8 +++++---
knowledge/internal/04-platform-packages.md | 6 ++++--
packages/google/openiap/build.gradle.kts | 2 ++
.../google/openiap/consumer-rules-amazon.pro | 6 ++++++
.../google/openiap/consumer-rules-horizon.pro | 3 +++
.../google/scripts/verify-release-consumer.sh | 18 ++++++++----------
6 files changed, 28 insertions(+), 15 deletions(-)
create mode 100644 packages/google/openiap/consumer-rules-amazon.pro
create mode 100644 packages/google/openiap/consumer-rules-horizon.pro
diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md
index 8ee115ec8..3bb468fba 100644
--- a/knowledge/_agent-context/context.md
+++ b/knowledge/_agent-context/context.md
@@ -1,7 +1,7 @@
# OpenIAP Project Context
> **Auto-generated shared context for AI assistants**
-> Last updated: 2026-09-26T15:21:34.573Z
+> Last updated: 2026-09-26T15:43:08.465Z
>
> Canonical file: `knowledge/_agent-context/context.md`
@@ -1471,8 +1471,10 @@ plugin leaves alone). It needs an Android SDK and the network.
`scripts/verify-release-consumer.sh` is the only check that runs R8, as an
app's release build does. It builds a minified release app per store from the
locally published artifacts and asserts that each links only its store's SDK,
-that R8 keeps that SDK, and that the Amazon receiver keeps its name. It also
-needs an Android SDK and the network.
+that R8 keeps that SDK, and that R8 renames no Amazon SDK class, because that
+SDK fills its own classes by reflection. It also needs an Android SDK and the
+network. A store SDK that needs R8 rules gets them in its flavor's consumer
+file (`openiap/consumer-rules-.pro`), so apps never add them by hand.
**Add a case whenever the rule changes.** A wrong store is invisible on the
machine that built it — it only appears when the artifact reaches a device that
diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md
index caeed4522..7dc6d1b6c 100644
--- a/knowledge/internal/04-platform-packages.md
+++ b/knowledge/internal/04-platform-packages.md
@@ -389,8 +389,10 @@ plugin leaves alone). It needs an Android SDK and the network.
`scripts/verify-release-consumer.sh` is the only check that runs R8, as an
app's release build does. It builds a minified release app per store from the
locally published artifacts and asserts that each links only its store's SDK,
-that R8 keeps that SDK, and that the Amazon receiver keeps its name. It also
-needs an Android SDK and the network.
+that R8 keeps that SDK, and that R8 renames no Amazon SDK class, because that
+SDK fills its own classes by reflection. It also needs an Android SDK and the
+network. A store SDK that needs R8 rules gets them in its flavor's consumer
+file (`openiap/consumer-rules-.pro`), so apps never add them by hand.
**Add a case whenever the rule changes.** A wrong store is invisible on the
machine that built it — it only appears when the artifact reaches a device that
diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts
index ecfbb8969..31d06e1e4 100644
--- a/packages/google/openiap/build.gradle.kts
+++ b/packages/google/openiap/build.gradle.kts
@@ -114,11 +114,13 @@ android {
create("horizon") {
dimension = "platform"
buildConfigField("String", "OPENIAP_STORE", "\"horizon\"")
+ consumerProguardFiles("consumer-rules-horizon.pro")
}
// Amazon flavor - Amazon Appstore SDK IAP only
create("amazon") {
dimension = "platform"
buildConfigField("String", "OPENIAP_STORE", "\"amazon\"")
+ consumerProguardFiles("consumer-rules-amazon.pro")
}
}
diff --git a/packages/google/openiap/consumer-rules-amazon.pro b/packages/google/openiap/consumer-rules-amazon.pro
new file mode 100644
index 000000000..f8f88d33d
--- /dev/null
+++ b/packages/google/openiap/consumer-rules-amazon.pro
@@ -0,0 +1,6 @@
+# The Appstore SDK fills fields of its own com.amazon.* classes by reflection,
+# so R8 must keep it whole. These are Amazon's documented rules; keeping the
+# whole SDK also keeps code that references optional libraries it doesn't ship.
+-dontwarn com.amazon.**
+-keep class com.amazon.** { *; }
+-keepattributes *Annotation*
diff --git a/packages/google/openiap/consumer-rules-horizon.pro b/packages/google/openiap/consumer-rules-horizon.pro
new file mode 100644
index 000000000..51093b678
--- /dev/null
+++ b/packages/google/openiap/consumer-rules-horizon.pro
@@ -0,0 +1,3 @@
+# The Horizon platform SDK annotates with JSR-305 without shipping it; R8 stops
+# the build on the missing annotation, which nothing needs at runtime.
+-dontwarn javax.annotation.Nullable
diff --git a/packages/google/scripts/verify-release-consumer.sh b/packages/google/scripts/verify-release-consumer.sh
index 8ccc7adae..5ab33a033 100755
--- a/packages/google/scripts/verify-release-consumer.sh
+++ b/packages/google/scripts/verify-release-consumer.sh
@@ -45,10 +45,6 @@ declare -A sdk_package=(
[horizon]=com.meta.horizon.billingclient.
[amazon]=com.amazon.device.iap.
)
-# The Appstore broadcasts to this receiver by name, so R8 must not rename it.
-declare -A named_class=(
- [amazon]=com.amazon.device.iap.ResponseReceiver
-)
failures=0
@@ -118,12 +114,14 @@ check() {
fi
done
- local kept=${named_class[$store]:-}
- if [ -n "$kept" ]; then
- local apk descriptor="L${kept//.//};"
- apk=$(find "$build/outputs/apk/release" -name '*.apk' | head -n 1)
- if [ -z "$apk" ] || ! unzip -p "$apk" 'classes*.dex' | grep -aqF "$descriptor"; then
- fail "$name" "the release APK has no $kept under that name"
+ # The Appstore SDK finds and fills its own classes by their com.amazon. names,
+ # and the Appstore broadcasts to its receiver by name. R8's own synthesized
+ # classes are not the SDK's.
+ if [ "$store" = amazon ]; then
+ local renamed
+ renamed=$(awk '/^com\.amazon\./ && !index($1, "$$ExternalSynthetic") && $1 ":" != $3 { print $1; exit }' "$mapping")
+ if [ -n "$renamed" ]; then
+ fail "$name" "R8 renamed Amazon SDK classes such as $renamed"
fi
fi
From fa23b5dc39dca9680666d0a4dd6864f5149a68d0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 26 Sep 2026 16:18:41 +0000
Subject: [PATCH 03/13] fix: call openiap directly instead of by reflection
OpenIapStore.enableBillingProgram looked up the Play module's method by name,
because only the Play flavor defined it. Nothing calls that method directly,
so R8 removes it from a minified app, and React Native's
enableBillingProgramAndroid then only logs a warning: External Payments and
Billing Choice never turn on in a release build.
enableBillingProgram is now part of OpenIapProtocol; Horizon and Amazon ignore
it with a warning, as they do for the other Play billing-program calls. The
two other reflective lookups into openiap go too: kmp-iap constructs
OpenIapModule directly, and React Native reads the Billing Choice fields
directly. Both exist in the published 3.6.0.
The parity audit now rejects reflective lookups in openiap's shared source and
in the framework bridges.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_017jh5cR6NE24PUBfP9fAEF9
---
.../OpenIapDelegateInAppPurchaseAndroid.kt | 7 ++--
.../java/com/margelo/nitro/iap/HybridRnIap.kt | 13 ++------
.../java/dev/hyo/openiap/OpenIapModule.kt | 4 +++
.../java/dev/hyo/openiap/OpenIapModule.kt | 5 +++
.../java/dev/hyo/openiap/OpenIapProtocol.kt | 6 ++++
.../dev/hyo/openiap/store/OpenIapStore.kt | 14 +-------
.../java/dev/hyo/openiap/OpenIapModule.kt | 2 +-
scripts/audit-non-godot-parity.mjs | 32 +++++++++++++++++--
8 files changed, 50 insertions(+), 33 deletions(-)
diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt
index 246cab5a2..a13e75cf6 100644
--- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt
+++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt
@@ -10,6 +10,7 @@ import android.app.Application
import android.content.Context
import android.os.Bundle
import dev.hyo.openiap.OpenIapError as AndroidOpenIapError
+import dev.hyo.openiap.OpenIapModule
import dev.hyo.openiap.OpenIapProtocol as AndroidOpenIapProtocol
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
@@ -374,11 +375,7 @@ internal class OpenIapDelegateInAppPurchaseAndroid(
private fun requireModule(): AndroidOpenIapProtocol =
module ?: failWith(PurchaseError(code = ErrorCode.NotPrepared, message = "$storeName billing module not initialized"))
- private fun buildOpenIapModule(ctx: Context): AndroidOpenIapProtocol {
- val clazz = Class.forName("dev.hyo.openiap.OpenIapModule")
- val constructor = clazz.getConstructor(Context::class.java)
- return constructor.newInstance(ctx) as AndroidOpenIapProtocol
- }
+ private fun buildOpenIapModule(ctx: Context): AndroidOpenIapProtocol = OpenIapModule(ctx)
private fun registerListeners(openModule: AndroidOpenIapProtocol) {
val purchaseUpdate = OpenIapPurchaseUpdateListener { purchase ->
diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
index feabdcb66..a33f0c55a 100644
--- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
+++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
@@ -327,19 +327,10 @@ class HybridRnIap : HybridRnIapSpec() {
"userChoiceBillingListener",
mapOf("products" to details.products, "token" to details.externalTransactionToken)
)
- val originalTransactionId = runCatching {
- details.javaClass
- .getMethod("getOriginalExternalTransactionId")
- .invoke(details) as? String
- }.getOrNull()
- val productDetails = runCatching {
- (details.javaClass.getMethod("getProductDetailsAndroid").invoke(details) as? List<*>)
- ?.mapNotNull { it as? dev.hyo.openiap.DeveloperProvidedBillingProductAndroid }
- }.getOrNull()
val nitroDetails = UserChoiceBillingDetails(
externalTransactionToken = details.externalTransactionToken,
- originalExternalTransactionId = originalTransactionId.wrapVariant(),
- productDetailsAndroid = productDetails?.map { product ->
+ originalExternalTransactionId = details.originalExternalTransactionId.wrapVariant(),
+ productDetailsAndroid = details.productDetailsAndroid?.map { product ->
DeveloperProvidedBillingProductAndroid(
id = product.id,
offerToken = product.offerToken.wrapVariant(),
diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt
index 6b2052e75..1231c90e5 100644
--- a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt
@@ -1076,6 +1076,10 @@ class OpenIapModule(
override fun removeConnectionStateListener(listener: OpenIapConnectionStateListener) = Unit
+ override fun enableBillingProgram(program: BillingProgramAndroid) {
+ OpenIapLog.warn("enableBillingProgram is not supported on Amazon Appstore (no-op)", TAG)
+ }
+
override suspend fun isBillingProgramAvailable(
program: BillingProgramAndroid
): BillingProgramAvailabilityResultAndroid = BillingProgramAvailabilityResultAndroid(
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
index 959579c69..ab6f07362 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
@@ -1887,6 +1887,11 @@ class OpenIapModule(
}
// Google Play billing programs are not supported on Horizon.
+ override fun enableBillingProgram(program: BillingProgramAndroid) {
+ // No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon
+ OpenIapLog.warn("enableBillingProgram is not supported on Meta Horizon (no-op)", TAG)
+ }
+
override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid {
// No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon
OpenIapLog.warn("isBillingProgramAvailable is not supported on Meta Horizon (no-op)", TAG)
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
index e15faebc1..8f893e0cc 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
@@ -68,6 +68,12 @@ interface OpenIapProtocol {
fun removeConnectionStateListener(listener: OpenIapConnectionStateListener)
// Billing Programs (Google Play Billing Library 8.2.0+)
+ /**
+ * Enable a billing program for the next connection; call before initConnection.
+ * Stores other than Google Play ignore it.
+ */
+ fun enableBillingProgram(program: BillingProgramAndroid)
+
/**
* Check if a billing program is available for this user/device.
* Checks whether the selected billing program is available.
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
index ceb956722..459223bbd 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
@@ -632,19 +632,7 @@ class OpenIapStore(private val module: OpenIapProtocol) {
*
* @param program The billing program to enable
*/
- fun enableBillingProgram(program: BillingProgramAndroid) {
- // Use reflection to call enableBillingProgram on the module
- // This is needed because the method is only available in the Play flavor
- try {
- val method = module.javaClass.getMethod("enableBillingProgram", BillingProgramAndroid::class.java)
- method.invoke(module, program)
- OpenIapLog.debug("Billing program enabled via store: $program", "OpenIapStore")
- } catch (e: NoSuchMethodException) {
- OpenIapLog.warn("enableBillingProgram not available (Horizon flavor or older library)", "OpenIapStore")
- } catch (e: Exception) {
- OpenIapLog.error("Failed to enable billing program: ${e.message}", e, "OpenIapStore")
- }
- }
+ fun enableBillingProgram(program: BillingProgramAndroid) = module.enableBillingProgram(program)
// -------------------------------------------------------------------------
// Event listeners passthrough
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
index 34e643cef..1a80c2f0c 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
@@ -1590,7 +1590,7 @@ class OpenIapModule(
*
* @param program The billing program to enable
*/
- fun enableBillingProgram(program: BillingProgramAndroid) {
+ override fun enableBillingProgram(program: BillingProgramAndroid) {
if (program != BillingProgramAndroid.Unspecified) {
synchronized(connectionLifecycleLock) {
pendingBillingPrograms.add(program)
diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs
index 4ab64b895..aa6e3f6ee 100644
--- a/scripts/audit-non-godot-parity.mjs
+++ b/scripts/audit-non-godot-parity.mjs
@@ -292,6 +292,33 @@ function listTrackedFiles(relativePath) {
.filter((file) => file.length > 0 && exists(file));
}
+// The shared store and the framework bridges call openiap through its API, so R8
+// sees every call; a reflective lookup hides its target and a release build then
+// strips it.
+function checkNoReflectionIntoOpenIap() {
+ const sources = [
+ "packages/google/openiap/src/main",
+ "libraries/react-native-iap/android/src/main",
+ "libraries/expo-iap/android/src/main",
+ "libraries/flutter_inapp_purchase/android/src",
+ "libraries/maui-iap/android",
+ "libraries/godot-iap/android",
+ "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt",
+ ];
+ const reflective =
+ /\b(?:getMethod|getDeclaredMethod|getField|getDeclaredField|getConstructor|getDeclaredConstructor)\(|Class\.forName\(/;
+ for (const source of sources) {
+ for (const file of listTrackedFiles(source)) {
+ if (!/\.(?:kt|java)$/.test(file) || /\/(?:test|androidTest)[A-Za-z]*\//.test(file)) continue;
+ if (reflective.test(read(file))) {
+ fail(
+ `${file} looks up code by reflection, which R8 removes from release builds; call openiap directly`,
+ );
+ }
+ }
+ }
+}
+
function checkNoOutboundWebhookStream() {
const forbiddenFiles = [
"packages/kit/server/api/v1/webhookStreamDrain.ts",
@@ -3374,9 +3401,7 @@ function checkBillingChoiceFieldBindings() {
"externalTransactionToken = params.externalTransactionToken.unwrapString()",
"linkUri = details.linkUri.wrapVariant()",
"originalExternalTransactionId = details.originalExternalTransactionId.wrapVariant()",
- 'getMethod("getOriginalExternalTransactionId")',
- 'getMethod("getProductDetailsAndroid")',
- "productDetailsAndroid = productDetails?.map",
+ "productDetailsAndroid = details.productDetailsAndroid?.map",
"products = details.products.map",
"subResponseCode = mapSubResponseCode(result.subResponseCode)",
],
@@ -9641,6 +9666,7 @@ checkLibraryCoverageRegistry();
checkClientProtocol();
checkDeprecationSchedule();
checkNoOutboundWebhookStream();
+checkNoReflectionIntoOpenIap();
checkExpoSsotRegistry();
checkE2eExampleIds();
checkGeneratedTypeSync();
From c58b4a7b02c657720be53b2a9dfb93c3438e1f9c Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 26 Sep 2026 16:19:01 +0000
Subject: [PATCH 04/13] test: check that the Play module's reflective lookups
survive R8
The Play module reaches 22 Play Billing classes and 37 methods by name, so it
still runs when an app pins an older billing version. Most have no direct
call, so R8 may drop or rename them in a release build, and the feature then
fails quietly: External Payments, Billing Choice, external links, user choice
billing, service reconnection, and a subscription's suspended state.
The release check reads those names from the module's source and fails when
the Play release APK lacks one. It reads the APK with dexdump.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_017jh5cR6NE24PUBfP9fAEF9
---
.../google/scripts/verify-release-consumer.sh | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/packages/google/scripts/verify-release-consumer.sh b/packages/google/scripts/verify-release-consumer.sh
index 5ab33a033..b07bb1a46 100755
--- a/packages/google/scripts/verify-release-consumer.sh
+++ b/packages/google/scripts/verify-release-consumer.sh
@@ -58,6 +58,40 @@ mapping_has_package() {
awk -v prefix="$2" 'index($0, prefix) == 1 { found = 1; exit } END { exit !found }' "$1"
}
+# The Play module looks up newer Play Billing APIs by name, so it still runs when
+# an app pins an older billing version. Reading the names from its source checks
+# a new lookup as soon as it lands.
+play_source=$(find "$google_root/openiap/src/play" -name '*.kt' -exec cat {} + | tr -s '[:space:]' ' ')
+play_classes=$(grep -oE 'Class\.forName\( ?"com\.android\.billingclient\.api\.[^"]+"' <<< "$play_source" \
+ | sed -E 's/.*"(.*)"/\1/; s/\\\$/$/g' | sort -u || true)
+play_methods=$(grep -oE 'get(Declared)?Method\( ?"[A-Za-z0-9_]+"' <<< "$play_source" \
+ | sed -E 's/.*"(.*)"/\1/' | sort -u || true)
+if [ -z "$play_classes" ] || [ -z "$play_methods" ]; then
+ echo "Found no Play Billing lookups in the Play module; update this check." >&2
+ exit 1
+fi
+
+# Prints each looked-up Play Billing class or method that the APK lacks.
+missing_play_lookups() {
+ local sdk_root=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} dexdump defined name
+ dexdump=$(find "$sdk_root/build-tools" -name dexdump -type f 2>/dev/null | sort -V | tail -n 1)
+ if [ -z "$dexdump" ]; then
+ echo "(no dexdump under \$ANDROID_HOME/build-tools)"
+ return 0
+ fi
+ defined=$("$dexdump" "$1" | awk -F "'" '
+ /^ Class descriptor/ { c = substr($2, 2, length($2) - 2); gsub("/", ".", c); print "class " c; next }
+ /^ (Direct|Virtual) methods/ { m = 1; next }
+ /^ (Static|Instance) fields/ { m = 0; next }
+ m && /^ name +:/ { print "method " c " " $2 }')
+ for name in $play_classes; do
+ grep -qxF "class $name" <<< "$defined" || echo "$name"
+ done
+ for name in $play_methods; do
+ grep -qE "^method com\.android\.billingclient\.api\.[^ ]+ $name\$" <<< "$defined" || echo "$name()"
+ done
+}
+
# check [gradle args...]
check() {
local name=$1 store=$2 source=$3
@@ -125,6 +159,15 @@ check() {
fi
fi
+ if [ "$store" = play ]; then
+ local apk missing
+ apk=("$build"/outputs/apk/release/*.apk)
+ missing=$(missing_play_lookups "${apk[0]}")
+ if [ -n "$missing" ]; then
+ fail "$name" "the release APK lacks Play Billing names the Play module looks up: $(echo $missing)"
+ fi
+ fi
+
if [ "$failures" -eq "$failures_before" ]; then
echo " ok: $name linked ${artifact[$store]} and R8 kept ${sdk_package[$store]}"
fi
From f0760b9a2b47c6181652bd9132319385c762247a Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 26 Sep 2026 16:22:08 +0000
Subject: [PATCH 05/13] fix: keep the Play Billing API that the Play module
reaches by reflection
The Play module looks up 22 Play Billing classes and 37 methods by name, so it
still runs when an app pins an older billing version, and most of them have no
direct call. R8 removes what nothing calls, so in a minified app the lookups
fail and the feature quietly does nothing.
The Play artifact now keeps the public Play Billing API for its consumers, and
the release check confirms every looked-up name survives. The two consumer
rules that named classes which no longer exist are gone.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_017jh5cR6NE24PUBfP9fAEF9
---
knowledge/_agent-context/context.md | 19 +++++++++++++------
knowledge/internal/04-platform-packages.md | 17 ++++++++++++-----
packages/google/openiap/build.gradle.kts | 1 +
.../google/openiap/consumer-rules-play.pro | 3 +++
packages/google/openiap/consumer-rules.pro | 4 +---
5 files changed, 30 insertions(+), 14 deletions(-)
create mode 100644 packages/google/openiap/consumer-rules-play.pro
diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md
index 3bb468fba..d3780a327 100644
--- a/knowledge/_agent-context/context.md
+++ b/knowledge/_agent-context/context.md
@@ -1,7 +1,7 @@
# OpenIAP Project Context
> **Auto-generated shared context for AI assistants**
-> Last updated: 2026-09-26T15:43:08.465Z
+> Last updated: 2026-09-26T16:20:04.056Z
>
> Canonical file: `knowledge/_agent-context/context.md`
@@ -1470,11 +1470,13 @@ plugin leaves alone). It needs an Android SDK and the network.
`scripts/verify-release-consumer.sh` is the only check that runs R8, as an
app's release build does. It builds a minified release app per store from the
-locally published artifacts and asserts that each links only its store's SDK,
-that R8 keeps that SDK, and that R8 renames no Amazon SDK class, because that
-SDK fills its own classes by reflection. It also needs an Android SDK and the
-network. A store SDK that needs R8 rules gets them in its flavor's consumer
-file (`openiap/consumer-rules-.pro`), so apps never add them by hand.
+locally published artifacts and asserts that each links only its store's SDK
+and that R8 keeps what runs by name: every Play Billing class and method the
+Play module looks up by reflection (read from its source), and every Amazon SDK
+class, because that SDK fills its own classes by reflection. It also needs an
+Android SDK and the network. A store SDK that needs R8 rules gets them in its
+flavor's consumer file (`openiap/consumer-rules-.pro`), so apps never add
+them by hand.
**Add a case whenever the rule changes.** A wrong store is invisible on the
machine that built it — it only appears when the artifact reaches a device that
@@ -1513,6 +1515,11 @@ the runtime routes by install source; nothing is guessed at build time.
no connection to drop, so its implementation is a documented no-op
([#408](https://github.com/hyodotdev/openiap/issues/408)). `bun audit:parity`
pins the notification in both flavors.
+8. **Call openiap directly, never by reflection.** R8 removes what only
+ reflection reaches, so the call works in debug and silently does nothing in
+ a minified release. A method that only some flavors support belongs on
+ `OpenIapProtocol`, with a no-op in the others. `bun audit:parity` rejects
+ reflective lookups in the shared source and the framework bridges.
### Build Commands
diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md
index 7dc6d1b6c..71c764be8 100644
--- a/knowledge/internal/04-platform-packages.md
+++ b/knowledge/internal/04-platform-packages.md
@@ -388,11 +388,13 @@ plugin leaves alone). It needs an Android SDK and the network.
`scripts/verify-release-consumer.sh` is the only check that runs R8, as an
app's release build does. It builds a minified release app per store from the
-locally published artifacts and asserts that each links only its store's SDK,
-that R8 keeps that SDK, and that R8 renames no Amazon SDK class, because that
-SDK fills its own classes by reflection. It also needs an Android SDK and the
-network. A store SDK that needs R8 rules gets them in its flavor's consumer
-file (`openiap/consumer-rules-.pro`), so apps never add them by hand.
+locally published artifacts and asserts that each links only its store's SDK
+and that R8 keeps what runs by name: every Play Billing class and method the
+Play module looks up by reflection (read from its source), and every Amazon SDK
+class, because that SDK fills its own classes by reflection. It also needs an
+Android SDK and the network. A store SDK that needs R8 rules gets them in its
+flavor's consumer file (`openiap/consumer-rules-.pro`), so apps never add
+them by hand.
**Add a case whenever the rule changes.** A wrong store is invisible on the
machine that built it — it only appears when the artifact reaches a device that
@@ -431,6 +433,11 @@ the runtime routes by install source; nothing is guessed at build time.
no connection to drop, so its implementation is a documented no-op
([#408](https://github.com/hyodotdev/openiap/issues/408)). `bun audit:parity`
pins the notification in both flavors.
+8. **Call openiap directly, never by reflection.** R8 removes what only
+ reflection reaches, so the call works in debug and silently does nothing in
+ a minified release. A method that only some flavors support belongs on
+ `OpenIapProtocol`, with a no-op in the others. `bun audit:parity` rejects
+ reflective lookups in the shared source and the framework bridges.
### Build Commands
diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts
index 31d06e1e4..26bf09dfe 100644
--- a/packages/google/openiap/build.gradle.kts
+++ b/packages/google/openiap/build.gradle.kts
@@ -108,6 +108,7 @@ android {
create("play") {
dimension = "platform"
buildConfigField("String", "OPENIAP_STORE", "\"play\"")
+ consumerProguardFiles("consumer-rules-play.pro")
isDefault = true
}
// Horizon flavor - Meta Horizon Billing only
diff --git a/packages/google/openiap/consumer-rules-play.pro b/packages/google/openiap/consumer-rules-play.pro
new file mode 100644
index 000000000..dbece6f2f
--- /dev/null
+++ b/packages/google/openiap/consumer-rules-play.pro
@@ -0,0 +1,3 @@
+# OpenIapModule reaches newer Play Billing APIs by reflection so it still runs
+# when an app pins an older billing version; R8 must keep their public names.
+-keep public class com.android.billingclient.api.** { public *; }
diff --git a/packages/google/openiap/consumer-rules.pro b/packages/google/openiap/consumer-rules.pro
index 19a112ae2..08c20cacf 100644
--- a/packages/google/openiap/consumer-rules.pro
+++ b/packages/google/openiap/consumer-rules.pro
@@ -1,4 +1,2 @@
-# Keep OpenIAP public API
--keep public class dev.hyo.openiap.OpenIAP { public *; }
--keep public class dev.hyo.openiap.models.** { *; }
+# Keep the listener interfaces that framework bridges implement.
-keep public class dev.hyo.openiap.listener.** { *; }
From 3cb2d46dd740900f1c01beb310b1d134d3002b16 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 26 Sep 2026 16:32:57 +0000
Subject: [PATCH 06/13] docs: add the 3.6.1 release card and drop the manual
ProGuard steps
The card ships with this PR, written as published: openiap-google 3.6.1 and
the framework patch releases that pick it up, with their expected tags.
The Android, Flutter, and KMP setup guides asked apps to add keep rules by
hand. The store artifacts now carry the rules their billing SDKs need, so the
guides say no rules are needed.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_017jh5cR6NE24PUBfP9fAEF9
---
.../docs/src/pages/docs/android-setup.tsx | 9 +-
.../docs/src/pages/docs/setup/flutter.tsx | 12 +-
packages/docs/src/pages/docs/setup/kmp.tsx | 13 +-
.../docs/src/pages/docs/updates/releases.tsx | 121 ++++++++++++++++++
4 files changed, 136 insertions(+), 19 deletions(-)
diff --git a/packages/docs/src/pages/docs/android-setup.tsx b/packages/docs/src/pages/docs/android-setup.tsx
index f681dd22b..7f7ac2178 100644
--- a/packages/docs/src/pages/docs/android-setup.tsx
+++ b/packages/docs/src/pages/docs/android-setup.tsx
@@ -243,14 +243,15 @@ dependencies {
{`-keep class com.android.billingclient.** { *; }
--keep class com.android.vending.billing.** { *; }`}
+
+ No rules to add. From 3.6.1, each store artifact ships the keep rules
+ its billing SDK needs, so minified release builds work as they are.
+
diff --git a/packages/docs/src/pages/docs/setup/flutter.tsx b/packages/docs/src/pages/docs/setup/flutter.tsx
index 43de78bb7..28f250d9a 100644
--- a/packages/docs/src/pages/docs/setup/flutter.tsx
+++ b/packages/docs/src/pages/docs/setup/flutter.tsx
@@ -235,16 +235,12 @@ function FlutterSetup() {
"no pin", so the opt-out beside it still applies.
-
ProGuard Rules (if using ProGuard)
+
R8 and ProGuard
- Add to your android/app/proguard-rules.pro:
+ No rules to add. From 10.7.1, the Android store artifact ships the
+ keep rules its billing SDK needs, so minified release builds work as
+ they are.
-
- {`# In-App Purchase
--keep class dev.hyo.** { *; }
--keep class com.android.vending.billing.**
--keepattributes *Annotation*`}
-
diff --git a/packages/docs/src/pages/docs/setup/kmp.tsx b/packages/docs/src/pages/docs/setup/kmp.tsx
index edbfb5cba..2b3f92b0c 100644
--- a/packages/docs/src/pages/docs/setup/kmp.tsx
+++ b/packages/docs/src/pages/docs/setup/kmp.tsx
@@ -248,13 +248,12 @@ openiapStore=horizon`}
for the full rule.
-
+ No rules to add. From 3.6.1, the Android store artifact ships the keep
+ rules its billing SDK needs, so minified release builds work as they
+ are.
+
+ Android release builds shrunk with R8 no longer lose billing
+ features on any store, and no ProGuard rules are needed in your app.
+ See{' '}
+
+ PR #490
+
+ .
+
+
+
+ Protocols and native packages
+
+
+
+ openiap-google 3.6.1 - fixes minified Horizon
+ builds that failed on javax.annotation.Nullable, and
+ keeps the Amazon Appstore SDK classes it fills by reflection, so
+ Amazon purchases work in release builds.
+
+
+ openiap-google 3.6.1 - keeps the Play Billing
+ APIs behind External Payments, Billing Choice, external links,
+ user choice billing, automatic reconnection, and{' '}
+ isSuspended, which R8 used to strip.
+
+
+ openiap-google 3.6.1 -{' '}
+ OpenIapStore.enableBillingProgram now takes effect in
+ release builds; Horizon and Amazon ignore it with a warning.
+
+
+
+
Framework libraries
+
+
+ react-native-iap 16.7.1 -{' '}
+ enableBillingProgramAndroid takes effect in release
+ builds, and user choice billing events keep{' '}
+ originalExternalTransactionId and{' '}
+ productDetailsAndroid.
+
+
+ kmp-iap 3.6.1 - creates the Horizon and Amazon
+ billing module directly instead of by reflection that R8 can
+ strip.
+
diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx
index 529bbb7c4..5273584f2 100644
--- a/packages/docs/src/pages/docs/updates/releases.tsx
+++ b/packages/docs/src/pages/docs/updates/releases.tsx
@@ -517,9 +517,9 @@ function Releases() {
productDetailsAndroid.
- kmp-iap 3.6.1 - creates the Horizon and Amazon
- billing module directly instead of by reflection that R8 can
- strip.
+ kmp-iap 3.6.1 - fixes minified Horizon and Amazon
+ release builds, which failed on Play Billing classes those stores
+ don't ship or could not start billing.
From 7b9c87b975cdcee51b479e3920b8baeb7f2e6b9f Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 26 Sep 2026 17:38:42 +0000
Subject: [PATCH 13/13] fix: gate the docs deploy on published GitHub Releases
Release workflows push the tag before they publish and create the GitHub
Release last, so a train that stops after its tag would still pass a tag
check. The deploy now lists published GitHub Releases with gh instead. Two
older Apple links, 2.1.6 and 3.5.0, have a tag but no release, so they join
the known exceptions. The deploy test covers an unpublished link, a known
historical one, and a failed release list.
The release check also stops, with a clear message, when no dexdump is
available. Under set -e the lookup exited silently before.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_017jh5cR6NE24PUBfP9fAEF9
---
knowledge/_agent-context/context.md | 5 ++-
knowledge/internal/06-git-deployment.md | 3 +-
.../google/scripts/verify-release-consumer.sh | 14 ++++---
scripts/deploy.sh | 13 +++---
scripts/release-branch-policy.test.mjs | 42 +++++++++++++++++++
5 files changed, 63 insertions(+), 14 deletions(-)
diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md
index 4786f620f..91c3422dc 100644
--- a/knowledge/_agent-context/context.md
+++ b/knowledge/_agent-context/context.md
@@ -1,7 +1,7 @@
# OpenIAP Project Context
> **Auto-generated shared context for AI assistants**
-> Last updated: 2026-09-26T17:20:20.257Z
+> Last updated: 2026-09-26T17:36:18.906Z
>
> Canonical file: `knowledge/_agent-context/context.md`
@@ -2719,7 +2719,8 @@ links.
Production documentation is stable-only and must deploy from a clean `main`
checkout that exactly matches `origin/main`. The script rejects prerelease spec
versions, other branches, stale or unpublished local snapshots, and a release
-page that links a release not yet published.
+page that links a GitHub Release not yet published, which it lists with an
+authenticated `gh`.
On a fresh checkout, first run `cd packages/docs && vercel link` and select the
existing OpenIAP project. Deployment stops when that local project link is
diff --git a/knowledge/internal/06-git-deployment.md b/knowledge/internal/06-git-deployment.md
index 09c54554c..e30b77b0a 100644
--- a/knowledge/internal/06-git-deployment.md
+++ b/knowledge/internal/06-git-deployment.md
@@ -375,7 +375,8 @@ links.
Production documentation is stable-only and must deploy from a clean `main`
checkout that exactly matches `origin/main`. The script rejects prerelease spec
versions, other branches, stale or unpublished local snapshots, and a release
-page that links a release not yet published.
+page that links a GitHub Release not yet published, which it lists with an
+authenticated `gh`.
On a fresh checkout, first run `cd packages/docs && vercel link` and select the
existing OpenIAP project. Deployment stops when that local project link is
diff --git a/packages/google/scripts/verify-release-consumer.sh b/packages/google/scripts/verify-release-consumer.sh
index 92dba9c35..3effb6f77 100755
--- a/packages/google/scripts/verify-release-consumer.sh
+++ b/packages/google/scripts/verify-release-consumer.sh
@@ -62,14 +62,16 @@ if [ -z "$play_classes" ] || [ -z "$play_methods" ]; then
exit 1
fi
+dexdump=$(find "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}/build-tools" -name dexdump -type f 2>/dev/null \
+ | sort -V | tail -n 1 || true)
+if [ -z "$dexdump" ]; then
+ echo "No dexdump under \$ANDROID_HOME/build-tools to read the Play APK with." >&2
+ exit 1
+fi
+
# Prints each looked-up Play Billing class or method that the APK lacks.
missing_play_lookups() {
- local sdk_root=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}} dexdump defined name
- dexdump=$(find "$sdk_root/build-tools" -name dexdump -type f 2>/dev/null | sort -V | tail -n 1)
- if [ -z "$dexdump" ]; then
- echo "(no dexdump under \$ANDROID_HOME/build-tools)"
- return 0
- fi
+ local defined name
defined=$("$dexdump" "$1" | awk -F "'" '
/^ Class descriptor/ { c = substr($2, 2, length($2) - 2); gsub("/", ".", c); print "class " c; next }
/^ (Direct|Virtual) methods/ { m = 1; next }
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index db0309923..e5140e9e2 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -65,20 +65,23 @@ if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then
fi
# A release card merges with its PR, before its packages publish, so production
-# docs wait until every release the page links exists.
+# docs wait until every release the page links is out. Release workflows push the
+# tag before publishing and create the GitHub Release last, so a tag alone is not
+# proof.
echo -e "${BLUE}🔗 Checking release links...${NC}"
RELEASES_PAGE="packages/docs/src/pages/docs/updates/releases.tsx"
# Older cards that link releases which never published; drop each once its card is fixed.
-UNPUBLISHED_HISTORY="2.2.2 apple-2.0.0 flutter-iap-10.6.2 google-3.5.3 kmp-iap-3.5.2 maui-iap-1.0.1 maui-iap-2.5.1"
-if ! PUBLISHED_TAGS=$(git ls-remote --tags --refs origin | sed 's|.*refs/tags/||'); then
- echo -e "${RED}❌ Could not list release tags on origin${NC}"
+UNPUBLISHED_HISTORY="2.1.6 2.2.2 3.5.0 apple-2.0.0 flutter-iap-10.6.2 google-3.5.3 kmp-iap-3.5.2 maui-iap-1.0.1 maui-iap-2.5.1"
+if ! PUBLISHED_RELEASES=$(gh release list --repo hyodotdev/openiap --limit 5000 \
+ --exclude-drafts --json tagName --jq '.[].tagName'); then
+ echo -e "${RED}❌ Could not list GitHub Releases; install gh and run gh auth login${NC}"
exit 1
fi
UNPUBLISHED_LINKS=$(
{
grep -oE "hyodotdev/openiap/releases/tag/[A-Za-z0-9._-]+" "$RELEASES_PAGE" | sed 's|.*/tag/||'
grep -oE "tag: '[^']+'" "$RELEASES_PAGE" | sed -E "s/tag: '(.*)'/\1/"
- } | sort -u | grep -vxF -f <(printf '%s\n' $PUBLISHED_TAGS $UNPUBLISHED_HISTORY) || true
+ } | sort -u | grep -vxF -f <(printf '%s\n' $PUBLISHED_RELEASES $UNPUBLISHED_HISTORY) || true
)
if [ -n "$UNPUBLISHED_LINKS" ]; then
echo -e "${RED}❌ The release page links releases that are not published yet:${NC}"
diff --git a/scripts/release-branch-policy.test.mjs b/scripts/release-branch-policy.test.mjs
index 3ab34079f..099b08810 100644
--- a/scripts/release-branch-policy.test.mjs
+++ b/scripts/release-branch-policy.test.mjs
@@ -1445,6 +1445,33 @@ test("production docs require a verified Vercel deployment result", (context) =>
"",
].join("\n"),
);
+ // Stands in for `gh release list`, printing the published release tags.
+ writeExecutable(
+ resolve(temporaryRoot, "mock-bin/gh"),
+ [
+ "#!/bin/sh",
+ 'if [ -n "${MOCK_GH_FAIL:-}" ]; then',
+ " exit 1",
+ "fi",
+ "printf '%s\\n' ${MOCK_GH_RELEASES:-}",
+ "",
+ ].join("\n"),
+ );
+ // A card for an unreleased train, and a historical link the deploy knows never published.
+ mkdirSync(resolve(temporaryRoot, "packages/docs/src/pages/docs/updates"), {
+ recursive: true,
+ });
+ writeFileSync(
+ resolve(
+ temporaryRoot,
+ "packages/docs/src/pages/docs/updates/releases.tsx",
+ ),
+ [
+ "const RELEASES = [{ name: 'openiap-google', version: '9.9.9', tag: 'google-9.9.9' }];",
+ 'const OLD = "https://github.com/hyodotdev/openiap/releases/tag/google-3.5.3";',
+ "",
+ ].join("\n"),
+ );
execFileSync("git", ["init", "-q", "-b", "main"], {
cwd: temporaryRoot,
@@ -1471,6 +1498,7 @@ test("production docs require a verified Vercel deployment result", (context) =>
delete environment.VERCEL_PROJECT_ID;
delete environment.VERCEL_ORG_ID;
environment.PATH = `${resolve(temporaryRoot, "mock-bin")}:${process.env.PATH}`;
+ environment.MOCK_GH_RELEASES = "google-9.9.9";
const runDeploy = (mockOutput = "", environmentOverrides = {}) =>
spawnSync("bash", ["scripts/deploy.sh"], {
cwd: temporaryRoot,
@@ -1483,6 +1511,20 @@ test("production docs require a verified Vercel deployment result", (context) =>
input: "y\n",
});
+ const unpublished = runDeploy("", { MOCK_GH_RELEASES: "" });
+ assert.notEqual(unpublished.status, 0);
+ assert.match(
+ unpublished.stdout,
+ /links releases that are not published yet/,
+ );
+ assert.match(unpublished.stdout, /google-9\.9\.9/);
+ assert.doesNotMatch(unpublished.stdout, /google-3\.5\.3/);
+ assert.doesNotMatch(unpublished.stdout, /Successfully deployed to Vercel/);
+
+ const noReleaseList = runDeploy("", { MOCK_GH_FAIL: "1" });
+ assert.notEqual(noReleaseList.status, 0);
+ assert.match(noReleaseList.stdout, /Could not list GitHub Releases/);
+
const unlinked = runDeploy();
assert.notEqual(unlinked.status, 0);
assert.match(unlinked.stdout, /not linked to the OpenIAP Vercel project/);