Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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+)
Expand Down Expand Up @@ -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+).
Expand Down
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="https://github.com/hyodotdev/openiap/issues/70">Issue #70</a>
*/
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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down