From 742d075c7addd9e84a13f95e67af1055d248f4db Mon Sep 17 00:00:00 2001 From: Hyo Date: Tue, 11 Aug 2026 08:47:46 +0900 Subject: [PATCH] fix(google): avoid r8 billing reflection Use typed Play Billing APIs for product-level subscription replacement parameters so R8 can safely optimize release apps. Replace obsolete reflection-path tests with direct builder coverage. Closes #307 --- .../java/dev/hyo/openiap/OpenIapModule.kt | 72 +--------- .../SubscriptionReplacementModeAndroidExt.kt | 12 ++ .../openiap/BillingLibraryClassPathTest.kt | 127 +----------------- .../SubscriptionReplacementModeTest.kt | 13 ++ 4 files changed, 29 insertions(+), 195 deletions(-) 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 77ff4cc2d..76cb1812a 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 @@ -1737,7 +1737,9 @@ class OpenIapModule( // Apply per-product subscription replacement params (8.1.0+) androidArgs.subscriptionProductReplacementParams?.let { replacementParams -> - applySubscriptionProductReplacementParams(builder, replacementParams) + builder.setSubscriptionProductReplacementParams( + replacementParams.toBillingSubscriptionProductReplacementParams() + ) } } else if (androidArgs.type == ProductQueryType.InApp && !androidArgs.offerToken.isNullOrEmpty()) { // Handle one-time purchase discount offers (Android 8.0+) @@ -2837,74 +2839,6 @@ class OpenIapModule( currentActivityRef = activity?.let { WeakReference(it) } } - /** - * Apply SubscriptionProductReplacementParams to ProductDetailsParams builder using reflection. - * This enables per-product replacement mode configuration (Billing Library 8.1.0+). - * - * @param builder The ProductDetailsParams.Builder to configure - * @param params The replacement parameters containing oldProductId and replacementMode - */ - private fun applySubscriptionProductReplacementParams( - builder: BillingFlowParams.ProductDetailsParams.Builder, - params: SubscriptionProductReplacementParamsAndroid - ) { - try { - // Convert our enum to BillingClient SubscriptionProductReplacementParams.ReplacementMode constant - val replacementModeConstant = params.replacementMode.toReplacementModeConstant() - - // Build SubscriptionProductReplacementParams using reflection - // Note: SubscriptionProductReplacementParams is nested under ProductDetailsParams (Billing Library 8.1.0+) - val replacementParamsClass = Class.forName( - "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams" - ) - val replacementBuilderClass = Class.forName( - "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder" - ) - - // Create new builder - val newBuilderMethod = replacementParamsClass.getMethod("newBuilder") - val replacementBuilder = newBuilderMethod.invoke(null) - - // Set old product ID - val setOldProductIdMethod = replacementBuilderClass.getMethod("setOldProductId", String::class.java) - setOldProductIdMethod.invoke(replacementBuilder, params.oldProductId) - - // Set replacement mode - val setReplacementModeMethod = replacementBuilderClass.getMethod("setReplacementMode", Int::class.javaPrimitiveType) - setReplacementModeMethod.invoke(replacementBuilder, replacementModeConstant) - - // Build the params - val buildMethod = replacementBuilderClass.getMethod("build") - val subscriptionReplacementParams = buildMethod.invoke(replacementBuilder) - - // Apply to ProductDetailsParams builder - val setSubsReplacementParamsMethod = builder.javaClass.getMethod( - "setSubscriptionProductReplacementParams", - replacementParamsClass - ) - setSubsReplacementParamsMethod.invoke(builder, subscriptionReplacementParams) - - OpenIapLog.debug("Applied SubscriptionProductReplacementParams: oldProductId=${params.oldProductId}, mode=${params.replacementMode} (constant=$replacementModeConstant)", TAG) - } catch (e: NoSuchMethodException) { - OpenIapLog.warn("setSubscriptionProductReplacementParams not found. Requires Billing Library 8.1.0+.", TAG) - throw OpenIapError.FeatureNotSupported( - "Subscription product replacement requires Play Billing 8.1.0+" - ) - } catch (e: ClassNotFoundException) { - OpenIapLog.warn("SubscriptionProductReplacementParams class not found. Requires Billing Library 8.1.0+.", TAG) - throw OpenIapError.FeatureNotSupported( - "Subscription product replacement requires Play Billing 8.1.0+" - ) - } catch (e: OpenIapError) { - throw e - } catch (e: Exception) { - OpenIapLog.error("Failed to apply SubscriptionProductReplacementParams: ${e.message}", e, TAG) - throw OpenIapError.DeveloperError( - e.message ?: "Invalid subscription product replacement parameters" - ) - } - } - /** * Enable a billing program with DeveloperProvidedBillingListener. * Used by EXTERNAL_PAYMENTS (8.3.0+) and BILLING_CHOICE (9.1.0+). diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt index 16d4753d1..6fec98511 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt @@ -1,5 +1,6 @@ package dev.hyo.openiap +import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode /** @@ -26,3 +27,14 @@ internal fun SubscriptionReplacementModeAndroid.toReplacementModeConstant(): Int SubscriptionReplacementModeAndroid.KeepExisting -> ReplacementMode.KEEP_EXISTING } } + +/** + * Builds the native replacement parameters through typed Billing API calls so + * minified consumer apps do not depend on class or method names. + */ +internal fun SubscriptionProductReplacementParamsAndroid.toBillingSubscriptionProductReplacementParams(): SubscriptionProductReplacementParams { + return SubscriptionProductReplacementParams.newBuilder() + .setOldProductId(oldProductId) + .setReplacementMode(replacementMode.toReplacementModeConstant()) + .build() +} diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt index f51cd0a4f..9c1cdf7d2 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt @@ -9,129 +9,14 @@ import org.junit.Test import java.util.Locale /** - * Tests to verify that reflection-based class paths used in OpenIapModule + * Tests to verify that reflection-based class paths still used in OpenIapModule * match the actual Google Play Billing Library class structure. * - * These tests prevent issues like #70 where SubscriptionProductReplacementParams - * was referenced at the wrong path (missing ProductDetailsParams in the hierarchy). - * * IMPORTANT: Every Class.forName() and getMethod() call in OpenIapModule.kt * should have a corresponding test here to catch API changes early. - * - * @see Issue #70 */ class BillingLibraryClassPathTest { - // ============================================================================ - // MARK: - SubscriptionProductReplacementParams (Billing Library 8.1.0+) - // Used in: OpenIapModule.applySubscriptionProductReplacementParams() - // ============================================================================ - - @Test - fun `SubscriptionProductReplacementParams class exists at correct path`() { - // Issue #70: Was incorrectly using BillingFlowParams$SubscriptionProductReplacementParams - // Correct path: BillingFlowParams$ProductDetailsParams$SubscriptionProductReplacementParams - val className = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams" - assertClassExists(className, "8.1.0+") - } - - @Test - fun `SubscriptionProductReplacementParams Builder class exists at correct path`() { - val className = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder" - assertClassExists(className, "8.1.0+") - } - - @Test - fun `SubscriptionProductReplacementParams has newBuilder method`() { - assertClassHasMethod( - "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams", - "newBuilder" - ) - } - - @Test - fun `SubscriptionProductReplacementParams Builder has setOldProductId method`() { - assertClassHasMethod( - "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder", - "setOldProductId", - String::class.java - ) - } - - @Test - fun `SubscriptionProductReplacementParams Builder has setReplacementMode method`() { - assertClassHasMethod( - "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder", - "setReplacementMode", - Int::class.javaPrimitiveType!! - ) - } - - @Test - fun `SubscriptionProductReplacementParams Builder has build method`() { - assertClassHasMethod( - "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder", - "build" - ) - } - - @Test - fun `WRONG path for SubscriptionProductReplacementParams should NOT exist`() { - // This is the WRONG path that was causing Issue #70 - val wrongClassName = "com.android.billingclient.api.BillingFlowParams\$SubscriptionProductReplacementParams" - assertClassDoesNotExist(wrongClassName) - } - - @Test - fun `SubscriptionProductReplacementParams ReplacementMode annotation exists`() { - val className = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$ReplacementMode" - try { - val clazz = Class.forName(className) - assertNotNull("ReplacementMode annotation should exist", clazz) - assertTrue("ReplacementMode should be an annotation", clazz.isAnnotation) - } catch (e: ClassNotFoundException) { - fail("ReplacementMode annotation not found: $className") - } - } - - // ============================================================================ - // MARK: - ProductDetailsParams (base class) - // Used in: OpenIapModule for subscription replacement params - // ============================================================================ - - @Test - fun `ProductDetailsParams class exists`() { - assertClassExists( - "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams", - "5.0+" - ) - } - - @Test - fun `ProductDetailsParams Builder class exists`() { - assertClassExists( - "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$Builder", - "5.0+" - ) - } - - @Test - fun `ProductDetailsParams Builder has setSubscriptionProductReplacementParams method`() { - val builderClassName = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$Builder" - val replacementParamsClassName = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams" - - try { - val builderClass = Class.forName(builderClassName) - val replacementParamsClass = Class.forName(replacementParamsClassName) - val setMethod = builderClass.getMethod("setSubscriptionProductReplacementParams", replacementParamsClass) - assertNotNull("setSubscriptionProductReplacementParams method should exist", setMethod) - } catch (e: ClassNotFoundException) { - fail("Class not found: ${e.message}") - } catch (e: NoSuchMethodException) { - fail("setSubscriptionProductReplacementParams method not found. Requires Billing Library 8.1.0+") - } - } - // ============================================================================ // MARK: - SubscriptionUpdateParams (legacy) // Used for backwards compatibility @@ -1030,16 +915,6 @@ class BillingLibraryClassPathTest { } } - private fun assertClassDoesNotExist(className: String) { - try { - Class.forName(className) - fail("Class should NOT exist at: $className") - } catch (e: ClassNotFoundException) { - // Expected - the class should not exist - assertTrue("Class correctly does not exist at $className", true) - } - } - private fun assertClassHasMethod( className: String, methodName: String, diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt index aafa4d442..861f95df4 100644 --- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt @@ -83,6 +83,19 @@ class SubscriptionReplacementModeTest { } } + @Test + fun `subscription replacement params use the native Billing builder`() { + for (mode in SubscriptionReplacementModeAndroid.entries) { + val params = SubscriptionProductReplacementParamsAndroid( + oldProductId = "old.product", + replacementMode = mode, + ).toBillingSubscriptionProductReplacementParams() + + assertEquals("old.product", params.oldProductId) + assertEquals(mode.toReplacementModeConstant(), params.replacementMode) + } + } + @Test fun `SubscriptionReplacementModeAndroid fromJson parses correctly`() { assertEquals(SubscriptionReplacementModeAndroid.UnknownReplacementMode, SubscriptionReplacementModeAndroid.fromJson("unknown-replacement-mode"))