Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.

Commit 045933e

Browse files
authored
feat(android): support android billing client 8.2.0 (#3103)
## Summary This release adds support for **Google Play Billing Library 8.2.0** features, including the new **Billing Programs API** for external billing and **one-time product discount support** from Billing Library 7.0+. ## New Features ### 1. Billing Programs API (Android 8.2.0+) New methods for handling external billing programs: - `isBillingProgramAvailableAndroid(program)` - Check if a billing program is available for the user - `createBillingProgramReportingDetailsAndroid(program)` - Get external transaction token for reporting - `launchExternalLinkAndroid(params)` - Launch external link for billing programs **Supported Programs:** - `external-offer` - External offer programs - `external-content-link` - External content link programs ```tsx import { isBillingProgramAvailableAndroid, createBillingProgramReportingDetailsAndroid, launchExternalLinkAndroid, } from 'react-native-iap'; // Step 1: Check availability const result = await isBillingProgramAvailableAndroid('external-offer'); if (!result.isAvailable) return; // Step 2: Launch external link await launchExternalLinkAndroid({ billingProgram: 'external-offer', launchMode: 'launch-in-external-browser-or-app', linkType: 'link-to-digital-content-offer', linkUri: 'https://your-payment-site.com', }); // Step 3: Get reporting token const details = await createBillingProgramReportingDetailsAndroid('external-offer'); // Report token to Google Play within 24 hours ``` ### 2. One-Time Product Discounts (Android 7.0+) `oneTimePurchaseOfferDetailsAndroid` is now an **array** to support multiple offers with discounts. **New Fields:** - `offerId` - Unique offer identifier - `fullPriceMicros` - Original price before discount - `discountDisplayInfo` - Percentage and amount discount info - `limitedQuantityInfo` - Purchase quantity limits - `validTimeWindow` - Offer validity period - `preorderDetailsAndroid` - Preorder release info - `rentalDetailsAndroid` - Rental period info ### 3. Purchase Suspension Status (Android 8.1.0+) - Added `isSuspendedAndroid` field to `PurchaseAndroid` type ## Breaking Changes ### `oneTimePurchaseOfferDetailsAndroid` Type Change **Before (14.5.x):** ```ts oneTimePurchaseOfferDetailsAndroid?: ProductAndroidOneTimePurchaseOfferDetail | null; ``` **After (14.6.0):** ```ts oneTimePurchaseOfferDetailsAndroid?: ProductAndroidOneTimePurchaseOfferDetail[] | null; ``` **Migration:** Update code that accesses this field to handle arrays: ```ts // Before const price = product.oneTimePurchaseOfferDetailsAndroid?.formattedPrice; // After const offers = product.oneTimePurchaseOfferDetailsAndroid; const price = offers?.[0]?.formattedPrice; ``` ## New Types ```ts // Billing Programs type BillingProgramAndroid = 'unspecified' | 'external-content-link' | 'external-offer'; type ExternalLinkLaunchModeAndroid = 'unspecified' | 'launch-in-external-browser-or-app' | 'caller-will-launch-link'; type ExternalLinkTypeAndroid = 'unspecified' | 'link-to-digital-content-offer' | 'link-to-app-download'; interface LaunchExternalLinkParamsAndroid { billingProgram: BillingProgramAndroid; launchMode: ExternalLinkLaunchModeAndroid; linkType: ExternalLinkTypeAndroid; linkUri: string; } interface BillingProgramAvailabilityResultAndroid { billingProgram: BillingProgramAndroid; isAvailable: boolean; } interface BillingProgramReportingDetailsAndroid { billingProgram: BillingProgramAndroid; externalTransactionToken: string; } // One-Time Product Discounts interface DiscountDisplayInfoAndroid { percentageDiscount?: number | null; discountAmount?: DiscountAmountAndroid | null; } interface DiscountAmountAndroid { discountAmountMicros: string; formattedDiscountAmount: string; } interface LimitedQuantityInfoAndroid { maximumQuantity: number; remainingQuantity: number; } interface ValidTimeWindowAndroid { startTimeMillis: string; endTimeMillis: string; } interface PreorderDetailsAndroid { preorderReleaseTimeMillis: string; preorderPresaleEndTimeMillis: string; } interface RentalDetailsAndroid { rentalPeriod: string; rentalExpirationPeriod?: string | null; } ``` ## OpenIAP Updates - **openiap-google**: [1.3.10 → 1.3.12](hyodotdev/openiap@google-v1.3.10...google-v1.3.12) - **openiap-gql**: [1.3.0 → 1.3.2](hyodotdev/openiap@1.3.0...1.3.2) ## Dependency Updates - **Kotlin**: 2.1.20 → 2.2.0 ## Documentation - Added versioned docs for 14.5 (preserving previous API documentation) - Updated types documentation with new Android discount fields - Updated alternative billing guide with Billing Programs API - Added release blog post for 14.6.0 ## Files Changed ### Core Implementation - `android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt` - Billing Programs API implementation - `ios/HybridRnIap.swift` - iOS stub implementations for cross-platform API - `src/specs/RnIap.nitro.ts` - Nitro interface definitions - `src/types.ts` - TypeScript type definitions - `src/index.ts` - Public API exports ### Example App - `example/screens/AlternativeBilling.tsx` - Billing Programs API demo with mode selector - `example/screens/AllProducts.tsx` - One-time offer discount display - `example/src/components/AndroidOneTimeOfferDetails.tsx` - Shared discount component ### Documentation - `docs/blog/2025-12-11-release-14.6.0-billing-programs.md` - Release blog post - `docs/docs/api/types.md` - Updated type documentation - `docs/docs/api/methods/core-methods.md` - New method documentation - `docs/docs/guides/alternative-billing.md` - Billing Programs guide - `docs/docs/examples/alternative-billing.md` - Updated examples - `docs/versioned_docs/version-14.5/` - Complete 14.5 version documentation ## Testing - Added tests for Android one-time purchase offer array handling - Example app includes full Billing Programs API flow demonstration ## References - [Google Play Billing Library 8.2.0 Release Notes](https://developer.android.com/google/play/billing/release-notes#8-2-0) - [Google Play Billing Programs](https://developer.android.com/google/play/billing/billing-programs) - [Google Play One-Time Product Discounts](https://developer.android.com/google/play/billing/integrate#one-time-discounts) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Google Play Billing Programs API (Android 8.2.0+) — enable/availability/reporting and external-link launching; richer Android one‑time offer metadata; purchase suspended flag; example app verification UI and Android deep‑link flow. * **Breaking Changes** * oneTimePurchaseOfferDetailsAndroid changed from a single object to an array. * **Documentation** * Extensive docs, guides and examples for Billing Programs, alternative billing, offers, installation, error handling, and troubleshooting. * **Chores** * Kotlin requirement bumped to 2.2.0+ and Billing Library references updated. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 71c739e commit 045933e

68 files changed

Lines changed: 12282 additions & 243 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt

Lines changed: 173 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ import dev.hyo.openiap.InitConnectionConfig as OpenIapInitConnectionConfig
3131
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
3232
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
3333
import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener
34+
import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgramAndroid
35+
import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams
36+
import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunchMode
37+
import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType
38+
import dev.hyo.openiap.store.OpenIapStore
3439
import kotlinx.coroutines.Dispatchers
3540
import kotlinx.coroutines.withContext
3641
import kotlinx.coroutines.CompletableDeferred
@@ -815,20 +820,59 @@ class HybridRnIap : HybridRnIapSpec() {
815820
is ProductAndroid -> product.subscriptionOfferDetailsAndroid.orEmpty()
816821
else -> emptyList()
817822
}
818-
val oneTimeOffer = when (product) {
823+
val oneTimeOffers = when (product) {
819824
is ProductSubscriptionAndroid -> product.oneTimePurchaseOfferDetailsAndroid
820825
is ProductAndroid -> product.oneTimePurchaseOfferDetailsAndroid
821826
else -> null
822827
}
823828

824829
val subscriptionOffersJson = subscriptionOffers.takeIf { it.isNotEmpty() }?.let { serializeSubscriptionOffers(it) }
825-
val oneTimeOfferNitro = oneTimeOffer?.let { otp ->
830+
val oneTimeOffersNitro = oneTimeOffers?.map { otp ->
826831
NitroOneTimePurchaseOfferDetail(
827832
formattedPrice = otp.formattedPrice,
828833
priceAmountMicros = otp.priceAmountMicros,
829-
priceCurrencyCode = otp.priceCurrencyCode
834+
priceCurrencyCode = otp.priceCurrencyCode,
835+
offerId = otp.offerId,
836+
offerToken = otp.offerToken,
837+
offerTags = otp.offerTags.toTypedArray(),
838+
fullPriceMicros = otp.fullPriceMicros,
839+
discountDisplayInfo = otp.discountDisplayInfo?.let { discount ->
840+
NitroDiscountDisplayInfoAndroid(
841+
percentageDiscount = discount.percentageDiscount?.toDouble(),
842+
discountAmount = discount.discountAmount?.let { amount ->
843+
NitroDiscountAmountAndroid(
844+
discountAmountMicros = amount.discountAmountMicros,
845+
formattedDiscountAmount = amount.formattedDiscountAmount
846+
)
847+
}
848+
)
849+
},
850+
validTimeWindow = otp.validTimeWindow?.let { window ->
851+
NitroValidTimeWindowAndroid(
852+
startTimeMillis = window.startTimeMillis,
853+
endTimeMillis = window.endTimeMillis
854+
)
855+
},
856+
limitedQuantityInfo = otp.limitedQuantityInfo?.let { info ->
857+
NitroLimitedQuantityInfoAndroid(
858+
maximumQuantity = info.maximumQuantity.toDouble(),
859+
remainingQuantity = info.remainingQuantity.toDouble()
860+
)
861+
},
862+
preorderDetailsAndroid = otp.preorderDetailsAndroid?.let { preorder ->
863+
NitroPreorderDetailsAndroid(
864+
preorderPresaleEndTimeMillis = preorder.preorderPresaleEndTimeMillis,
865+
preorderReleaseTimeMillis = preorder.preorderReleaseTimeMillis
866+
)
867+
},
868+
rentalDetailsAndroid = otp.rentalDetailsAndroid?.let { rental ->
869+
NitroRentalDetailsAndroid(
870+
rentalPeriod = rental.rentalPeriod,
871+
rentalExpirationPeriod = rental.rentalExpirationPeriod
872+
)
873+
}
830874
)
831-
}
875+
}?.toTypedArray()
832876

833877
var originalPriceAndroid: String? = null
834878
var originalPriceAmountMicrosAndroid: Double? = null
@@ -839,7 +883,7 @@ class HybridRnIap : HybridRnIapSpec() {
839883
var freeTrialPeriodAndroid: String? = null
840884

841885
if (product.type == ProductType.InApp) {
842-
oneTimeOffer?.let { otp ->
886+
oneTimeOffers?.firstOrNull()?.let { otp ->
843887
originalPriceAndroid = otp.formattedPrice
844888
originalPriceAmountMicrosAndroid = otp.priceAmountMicros.toDoubleOrNull()
845889
}
@@ -903,7 +947,7 @@ class HybridRnIap : HybridRnIapSpec() {
903947
subscriptionPeriodAndroid = subscriptionPeriodAndroid,
904948
freeTrialPeriodAndroid = freeTrialPeriodAndroid,
905949
subscriptionOfferDetailsAndroid = subscriptionOffersJson,
906-
oneTimePurchaseOfferDetailsAndroid = oneTimeOfferNitro
950+
oneTimePurchaseOfferDetailsAndroid = oneTimeOffersNitro
907951
)
908952
}
909953

@@ -957,7 +1001,8 @@ class HybridRnIap : HybridRnIapSpec() {
9571001
packageNameAndroid = androidPurchase?.packageNameAndroid,
9581002
obfuscatedAccountIdAndroid = androidPurchase?.obfuscatedAccountIdAndroid,
9591003
obfuscatedProfileIdAndroid = androidPurchase?.obfuscatedProfileIdAndroid,
960-
developerPayloadAndroid = androidPurchase?.developerPayloadAndroid
1004+
developerPayloadAndroid = androidPurchase?.developerPayloadAndroid,
1005+
isSuspendedAndroid = androidPurchase?.isSuspendedAndroid
9611006
)
9621007
}
9631008

@@ -1341,6 +1386,127 @@ class HybridRnIap : HybridRnIapSpec() {
13411386
}
13421387
}
13431388

1389+
// -------------------------------------------------------------------------
1390+
// Billing Programs API (Android 8.2.0+)
1391+
// -------------------------------------------------------------------------
1392+
1393+
// Create OpenIapStore lazily for Billing Programs API
1394+
private val openIapStore: OpenIapStore by lazy { OpenIapStore(context) }
1395+
1396+
override fun enableBillingProgramAndroid(program: BillingProgramAndroid) {
1397+
RnIapLog.payload("enableBillingProgramAndroid", mapOf("program" to program.name))
1398+
try {
1399+
val openIapProgram = mapBillingProgram(program)
1400+
openIapStore.enableBillingProgram(openIapProgram)
1401+
RnIapLog.result("enableBillingProgramAndroid", true)
1402+
} catch (err: Throwable) {
1403+
RnIapLog.failure("enableBillingProgramAndroid", err)
1404+
// enableBillingProgram is void, so we just log the error
1405+
}
1406+
}
1407+
1408+
override fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): Promise<NitroBillingProgramAvailabilityResultAndroid> {
1409+
return Promise.async {
1410+
RnIapLog.payload("isBillingProgramAvailableAndroid", mapOf("program" to program.name))
1411+
try {
1412+
initConnection(null).await()
1413+
val openIapProgram = mapBillingProgram(program)
1414+
val result = openIapStore.isBillingProgramAvailable(openIapProgram)
1415+
val nitroResult = NitroBillingProgramAvailabilityResultAndroid(
1416+
billingProgram = program,
1417+
isAvailable = result.isAvailable
1418+
)
1419+
RnIapLog.result("isBillingProgramAvailableAndroid", mapOf("isAvailable" to result.isAvailable))
1420+
nitroResult
1421+
} catch (err: Throwable) {
1422+
RnIapLog.failure("isBillingProgramAvailableAndroid", err)
1423+
val errorType = parseOpenIapError(err)
1424+
throw OpenIapException(toErrorJson(errorType, debugMessage = err.message))
1425+
}
1426+
}
1427+
}
1428+
1429+
override fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): Promise<NitroBillingProgramReportingDetailsAndroid> {
1430+
return Promise.async {
1431+
RnIapLog.payload("createBillingProgramReportingDetailsAndroid", mapOf("program" to program.name))
1432+
try {
1433+
initConnection(null).await()
1434+
val openIapProgram = mapBillingProgram(program)
1435+
val result = openIapStore.createBillingProgramReportingDetails(openIapProgram)
1436+
val nitroResult = NitroBillingProgramReportingDetailsAndroid(
1437+
billingProgram = program,
1438+
externalTransactionToken = result.externalTransactionToken
1439+
)
1440+
RnIapLog.result("createBillingProgramReportingDetailsAndroid", mapOf("hasToken" to true))
1441+
nitroResult
1442+
} catch (err: Throwable) {
1443+
RnIapLog.failure("createBillingProgramReportingDetailsAndroid", err)
1444+
val errorType = parseOpenIapError(err)
1445+
throw OpenIapException(toErrorJson(errorType, debugMessage = err.message))
1446+
}
1447+
}
1448+
}
1449+
1450+
override fun launchExternalLinkAndroid(params: NitroLaunchExternalLinkParamsAndroid): Promise<Boolean> {
1451+
return Promise.async {
1452+
RnIapLog.payload("launchExternalLinkAndroid", mapOf(
1453+
"billingProgram" to params.billingProgram.name,
1454+
"launchMode" to params.launchMode.name,
1455+
"linkType" to params.linkType.name,
1456+
"linkUri" to params.linkUri
1457+
))
1458+
try {
1459+
initConnection(null).await()
1460+
1461+
val activity = withContext(Dispatchers.Main) {
1462+
runCatching { context.currentActivity }.getOrNull()
1463+
} ?: throw OpenIapException(toErrorJson(OpenIAPError.DeveloperError, debugMessage = "Activity not available"))
1464+
1465+
val openIapParams = OpenIapLaunchExternalLinkParams(
1466+
billingProgram = mapBillingProgram(params.billingProgram),
1467+
launchMode = mapExternalLinkLaunchMode(params.launchMode),
1468+
linkType = mapExternalLinkType(params.linkType),
1469+
linkUri = params.linkUri
1470+
)
1471+
1472+
val result = withContext(Dispatchers.Main) {
1473+
openIapStore.launchExternalLink(activity, openIapParams)
1474+
}
1475+
RnIapLog.result("launchExternalLinkAndroid", result)
1476+
result
1477+
} catch (err: Throwable) {
1478+
RnIapLog.failure("launchExternalLinkAndroid", err)
1479+
val errorType = parseOpenIapError(err)
1480+
throw OpenIapException(toErrorJson(errorType, debugMessage = err.message))
1481+
}
1482+
}
1483+
}
1484+
1485+
// Billing Programs helper functions
1486+
private fun mapBillingProgram(program: BillingProgramAndroid): OpenIapBillingProgramAndroid {
1487+
return when (program) {
1488+
BillingProgramAndroid.UNSPECIFIED -> OpenIapBillingProgramAndroid.Unspecified
1489+
BillingProgramAndroid.EXTERNAL_CONTENT_LINK -> OpenIapBillingProgramAndroid.ExternalContentLink
1490+
BillingProgramAndroid.EXTERNAL_OFFER -> OpenIapBillingProgramAndroid.ExternalOffer
1491+
}
1492+
}
1493+
1494+
private fun mapExternalLinkLaunchMode(mode: ExternalLinkLaunchModeAndroid): OpenIapExternalLinkLaunchMode {
1495+
return when (mode) {
1496+
ExternalLinkLaunchModeAndroid.UNSPECIFIED -> OpenIapExternalLinkLaunchMode.Unspecified
1497+
ExternalLinkLaunchModeAndroid.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP -> OpenIapExternalLinkLaunchMode.LaunchInExternalBrowserOrApp
1498+
ExternalLinkLaunchModeAndroid.CALLER_WILL_LAUNCH_LINK -> OpenIapExternalLinkLaunchMode.CallerWillLaunchLink
1499+
}
1500+
}
1501+
1502+
private fun mapExternalLinkType(type: ExternalLinkTypeAndroid): OpenIapExternalLinkType {
1503+
return when (type) {
1504+
ExternalLinkTypeAndroid.UNSPECIFIED -> OpenIapExternalLinkType.Unspecified
1505+
ExternalLinkTypeAndroid.LINK_TO_DIGITAL_CONTENT_OFFER -> OpenIapExternalLinkType.LinkToDigitalContentOffer
1506+
ExternalLinkTypeAndroid.LINK_TO_APP_DOWNLOAD -> OpenIapExternalLinkType.LinkToAppDownload
1507+
}
1508+
}
1509+
13441510
// -------------------------------------------------------------------------
13451511
// External Purchase (iOS) - Not supported on Android
13461512
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)