From d7bf1c8586665923ac49136149300a03d0443a40 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 14 Apr 2026 20:01:10 +0900 Subject: [PATCH] fix(flutter,google): correct Android replacement mode and source values from native Billing constants Closes #92 The Flutter int enum mapping for AndroidReplacementMode had `deferred = 4`, which is not a valid `BillingFlowParams.SubscriptionUpdateParams.ReplacementMode` value (the legacy API consumed by `setSubscriptionReplacementMode(int)` on the Android side). Verified against billing-ktx 8.3.0 bytecode: legacy values are `CHARGE_FULL_PRICE = 5`, `DEFERRED = 6`. Updated the enum extension and tests accordingly. To prevent the same class of drift from recurring on the Kotlin side, moved `SubscriptionReplacementModeAndroidExt.kt` to the play/ source set and replaced the hardcoded 0..6 literals with direct references to `BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode` constants. The matching tests were moved to testPlay/ and now assert against those same native constants, so the mapping is self-correcting if Google ever renumbers them. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../flutter_inapp_purchase/lib/enums.dart | 12 +- .../test/enums_unit_test.dart | 23 +++- packages/google/openiap/build.gradle.kts | 3 + .../SubscriptionReplacementModeAndroidExt.kt | 21 +-- .../SubscriptionReplacementModeTest.kt | 125 ++++++------------ 5 files changed, 86 insertions(+), 98 deletions(-) rename packages/google/openiap/src/{main => play}/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt (53%) rename packages/google/openiap/src/{test => testPlay}/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt (50%) diff --git a/libraries/flutter_inapp_purchase/lib/enums.dart b/libraries/flutter_inapp_purchase/lib/enums.dart index 6591a0e1..bb520a0a 100644 --- a/libraries/flutter_inapp_purchase/lib/enums.dart +++ b/libraries/flutter_inapp_purchase/lib/enums.dart @@ -156,6 +156,14 @@ enum AndroidReplacementMode { chargeFullPrice, } +/// Integer values for the legacy +/// `BillingFlowParams.SubscriptionUpdateParams.ReplacementMode` API, which is +/// what `setSubscriptionReplacementMode(int)` consumes on the Android side. +/// +/// Prefer the new string-keyed API via `SubscriptionProductReplacementParamsAndroid` +/// + `SubscriptionReplacementModeAndroid` (see `lib/types.dart`). That path +/// avoids hand-typed integers entirely — the Kotlin side resolves the enum +/// against the live Billing Library constants. extension AndroidReplacementModeValue on AndroidReplacementMode { int get value { switch (this) { @@ -167,10 +175,10 @@ extension AndroidReplacementModeValue on AndroidReplacementMode { return 2; case AndroidReplacementMode.withoutProration: return 3; - case AndroidReplacementMode.deferred: - return 4; case AndroidReplacementMode.chargeFullPrice: return 5; + case AndroidReplacementMode.deferred: + return 6; } } } diff --git a/libraries/flutter_inapp_purchase/test/enums_unit_test.dart b/libraries/flutter_inapp_purchase/test/enums_unit_test.dart index 94a411f4..faeeda03 100644 --- a/libraries/flutter_inapp_purchase/test/enums_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/enums_unit_test.dart @@ -14,8 +14,23 @@ void main() { expect(AndroidPurchaseState.Unknown.value, 0); }); - test('AndroidReplacementModeValue returns mapped integers', () { - expect(AndroidReplacementMode.withTimeProration.value, 1); - expect(AndroidReplacementMode.chargeFullPrice.value, 5); - }); + test( + 'AndroidReplacementModeValue returns legacy SubscriptionUpdateParams.ReplacementMode integers', + () { + // Reference (Billing Library 8.x): + // BillingFlowParams.SubscriptionUpdateParams.ReplacementMode + // - UNKNOWN_REPLACEMENT_MODE = 0 + // - WITH_TIME_PRORATION = 1 + // - CHARGE_PRORATED_PRICE = 2 + // - WITHOUT_PRORATION = 3 + // - CHARGE_FULL_PRICE = 5 + // - DEFERRED = 6 + expect(AndroidReplacementMode.unknownReplacementMode.value, 0); + expect(AndroidReplacementMode.withTimeProration.value, 1); + expect(AndroidReplacementMode.chargeProratedPrice.value, 2); + expect(AndroidReplacementMode.withoutProration.value, 3); + expect(AndroidReplacementMode.chargeFullPrice.value, 5); + expect(AndroidReplacementMode.deferred.value, 6); + }, + ); } diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index 07f39b44..96f3d8d8 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -74,6 +74,9 @@ android { named("horizon") { java.srcDirs("src/horizon/java") } + named("testPlay") { + java.srcDirs("src/testPlay/java") + } } } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt similarity index 53% rename from packages/google/openiap/src/main/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt rename to packages/google/openiap/src/play/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt index c1b7a74b..16d4753d 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt @@ -1,5 +1,7 @@ package dev.hyo.openiap +import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode + /** * Extension function to convert SubscriptionReplacementModeAndroid enum to * BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode constants. @@ -7,19 +9,20 @@ package dev.hyo.openiap * Reference (Billing Library 8.1.0+): * https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode * + * Values are sourced from the native Billing Library constants so the mapping + * tracks Google's library, not a hardcoded copy that can drift. + * * Note: These constants differ from the legacy SubscriptionUpdateParams.ReplacementMode API. * See: https://github.com/hyodotdev/openiap/issues/71 - * - * @return The integer constant for SubscriptionProductReplacementParams.ReplacementMode */ internal fun SubscriptionReplacementModeAndroid.toReplacementModeConstant(): Int { return when (this) { - SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 - SubscriptionReplacementModeAndroid.WithTimeProration -> 1 - SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 - SubscriptionReplacementModeAndroid.WithoutProration -> 3 - SubscriptionReplacementModeAndroid.ChargeFullPrice -> 4 - SubscriptionReplacementModeAndroid.Deferred -> 5 - SubscriptionReplacementModeAndroid.KeepExisting -> 6 + SubscriptionReplacementModeAndroid.UnknownReplacementMode -> ReplacementMode.UNKNOWN_REPLACEMENT_MODE + SubscriptionReplacementModeAndroid.WithTimeProration -> ReplacementMode.WITH_TIME_PRORATION + SubscriptionReplacementModeAndroid.ChargeProratedPrice -> ReplacementMode.CHARGE_PRORATED_PRICE + SubscriptionReplacementModeAndroid.WithoutProration -> ReplacementMode.WITHOUT_PRORATION + SubscriptionReplacementModeAndroid.ChargeFullPrice -> ReplacementMode.CHARGE_FULL_PRICE + SubscriptionReplacementModeAndroid.Deferred -> ReplacementMode.DEFERRED + SubscriptionReplacementModeAndroid.KeepExisting -> ReplacementMode.KEEP_EXISTING } } diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt similarity index 50% rename from packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt rename to packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt index d6de6146..aafa4d44 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt @@ -1,13 +1,16 @@ package dev.hyo.openiap +import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals import org.junit.Test /** * Tests for SubscriptionReplacementModeAndroid enum and its mapping to * BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode constants. * + * These tests reference the native Billing Library constants directly so the + * mapping cannot drift if Google ever renumbers the values. + * * Reference (Billing Library 8.1.0+): * https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode * @@ -16,114 +19,70 @@ import org.junit.Test */ class SubscriptionReplacementModeTest { - // MARK: - SubscriptionProductReplacementParams.ReplacementMode constants (8.1.0+) - @Test - fun `UNKNOWN_REPLACEMENT_MODE constant is 0`() { - assertEquals(0, SubscriptionReplacementModeAndroid.UnknownReplacementMode.toReplacementModeConstant()) - } - - @Test - fun `WITH_TIME_PRORATION constant is 1`() { - assertEquals(1, SubscriptionReplacementModeAndroid.WithTimeProration.toReplacementModeConstant()) + fun `UnknownReplacementMode maps to native UNKNOWN_REPLACEMENT_MODE`() { + assertEquals( + ReplacementMode.UNKNOWN_REPLACEMENT_MODE, + SubscriptionReplacementModeAndroid.UnknownReplacementMode.toReplacementModeConstant() + ) } @Test - fun `CHARGE_PRORATED_PRICE constant is 2`() { - assertEquals(2, SubscriptionReplacementModeAndroid.ChargeProratedPrice.toReplacementModeConstant()) + fun `WithTimeProration maps to native WITH_TIME_PRORATION`() { + assertEquals( + ReplacementMode.WITH_TIME_PRORATION, + SubscriptionReplacementModeAndroid.WithTimeProration.toReplacementModeConstant() + ) } @Test - fun `WITHOUT_PRORATION constant is 3`() { - assertEquals(3, SubscriptionReplacementModeAndroid.WithoutProration.toReplacementModeConstant()) + fun `ChargeProratedPrice maps to native CHARGE_PRORATED_PRICE`() { + assertEquals( + ReplacementMode.CHARGE_PRORATED_PRICE, + SubscriptionReplacementModeAndroid.ChargeProratedPrice.toReplacementModeConstant() + ) } @Test - fun `CHARGE_FULL_PRICE constant is 4 for SubscriptionProductReplacementParams`() { - // This was incorrectly mapped to 5 (legacy value) before the fix - // Correct value for SubscriptionProductReplacementParams.ReplacementMode is 4 - assertEquals(4, SubscriptionReplacementModeAndroid.ChargeFullPrice.toReplacementModeConstant()) + fun `WithoutProration maps to native WITHOUT_PRORATION`() { + assertEquals( + ReplacementMode.WITHOUT_PRORATION, + SubscriptionReplacementModeAndroid.WithoutProration.toReplacementModeConstant() + ) } @Test - fun `DEFERRED constant is 5 for SubscriptionProductReplacementParams`() { - // This was incorrectly mapped to 6 (legacy value) before the fix - // Correct value for SubscriptionProductReplacementParams.ReplacementMode is 5 - assertEquals(5, SubscriptionReplacementModeAndroid.Deferred.toReplacementModeConstant()) + fun `ChargeFullPrice maps to native CHARGE_FULL_PRICE`() { + assertEquals( + ReplacementMode.CHARGE_FULL_PRICE, + SubscriptionReplacementModeAndroid.ChargeFullPrice.toReplacementModeConstant() + ) } @Test - fun `KEEP_EXISTING constant is 6 for SubscriptionProductReplacementParams`() { - // This was incorrectly mapped to 7 before the fix - // Correct value for SubscriptionProductReplacementParams.ReplacementMode is 6 - // Note: KEEP_EXISTING is only available in Billing Library 8.1.0+ - assertEquals(6, SubscriptionReplacementModeAndroid.KeepExisting.toReplacementModeConstant()) - } - - // MARK: - Verify all enum values are covered - - @Test - fun `all SubscriptionReplacementModeAndroid values have correct constants`() { - val expectedMappings = mapOf( - SubscriptionReplacementModeAndroid.UnknownReplacementMode to 0, - SubscriptionReplacementModeAndroid.WithTimeProration to 1, - SubscriptionReplacementModeAndroid.ChargeProratedPrice to 2, - SubscriptionReplacementModeAndroid.WithoutProration to 3, - SubscriptionReplacementModeAndroid.ChargeFullPrice to 4, - SubscriptionReplacementModeAndroid.Deferred to 5, - SubscriptionReplacementModeAndroid.KeepExisting to 6 + fun `Deferred maps to native DEFERRED`() { + assertEquals( + ReplacementMode.DEFERRED, + SubscriptionReplacementModeAndroid.Deferred.toReplacementModeConstant() ) - - // Verify all enum values are tested - assertEquals(7, SubscriptionReplacementModeAndroid.entries.size) - assertEquals(7, expectedMappings.size) - - // Verify each mapping - for ((mode, expectedConstant) in expectedMappings) { - assertEquals( - "Mapping for $mode should be $expectedConstant", - expectedConstant, - mode.toReplacementModeConstant() - ) - } } - // MARK: - Document the difference between legacy and new API - @Test - fun `CHARGE_FULL_PRICE differs between legacy and new API`() { - // Legacy SubscriptionUpdateParams.ReplacementMode: CHARGE_FULL_PRICE = 5 - // New SubscriptionProductReplacementParams.ReplacementMode: CHARGE_FULL_PRICE = 4 - val legacyValue = 5 - val newValue = 4 - - assertEquals(newValue, SubscriptionReplacementModeAndroid.ChargeFullPrice.toReplacementModeConstant()) - // Document that legacy value is different - assertNotEquals( - "Legacy and new API values should differ for CHARGE_FULL_PRICE", - legacyValue, - newValue + fun `KeepExisting maps to native KEEP_EXISTING`() { + assertEquals( + ReplacementMode.KEEP_EXISTING, + SubscriptionReplacementModeAndroid.KeepExisting.toReplacementModeConstant() ) } @Test - fun `DEFERRED differs between legacy and new API`() { - // Legacy SubscriptionUpdateParams.ReplacementMode: DEFERRED = 6 - // New SubscriptionProductReplacementParams.ReplacementMode: DEFERRED = 5 - val legacyValue = 6 - val newValue = 5 - - assertEquals(newValue, SubscriptionReplacementModeAndroid.Deferred.toReplacementModeConstant()) - // Document that legacy value is different - assertNotEquals( - "Legacy and new API values should differ for DEFERRED", - legacyValue, - newValue - ) + fun `all enum values are covered by toReplacementModeConstant`() { + assertEquals(7, SubscriptionReplacementModeAndroid.entries.size) + for (mode in SubscriptionReplacementModeAndroid.entries) { + mode.toReplacementModeConstant() + } } - // MARK: - Enum JSON serialization tests - @Test fun `SubscriptionReplacementModeAndroid fromJson parses correctly`() { assertEquals(SubscriptionReplacementModeAndroid.UnknownReplacementMode, SubscriptionReplacementModeAndroid.fromJson("unknown-replacement-mode"))