package expo.modules.iap import android.content.Context import dev.hyo.openiap.DeepLinkOptions import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions import dev.hyo.openiap.InitConnectionConfig import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapProvider import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.ProductRequest import dev.hyo.openiap.Purchase import dev.hyo.openiap.PurchaseOptions import dev.hyo.openiap.RequestPurchaseAndroidProps import dev.hyo.openiap.RequestPurchaseProps import dev.hyo.openiap.RequestPurchasePropsByPlatforms import dev.hyo.openiap.RequestPurchaseResultPurchase import dev.hyo.openiap.RequestPurchaseResultPurchases import dev.hyo.openiap.RequestSubscriptionAndroidProps import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms import dev.hyo.openiap.VerifyPurchaseGoogleOptions import dev.hyo.openiap.VerifyPurchaseHorizonOptions import dev.hyo.openiap.VerifyPurchaseProps import dev.hyo.openiap.VerifyPurchaseWithProviderProps import dev.hyo.openiap.helpers.OpenIapFirstPurchaseNotice import dev.hyo.openiap.helpers.finishPurchaseToken import dev.hyo.openiap.store.OpenIapStore import expo.modules.kotlin.Promise import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean import dev.hyo.openiap.BillingChoiceImageLayoutAndroid as OpenIapBillingChoiceImageLayout import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgram import dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid as OpenIapBillingProgramInformationDialogParams import dev.hyo.openiap.DeveloperBillingTypeAndroid as OpenIapDeveloperBillingType import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunchMode import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType import dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid as OpenIapGetBillingChoiceInfoParams import dev.hyo.openiap.InAppMessageCategoryAndroid as OpenIapInAppMessageCategory import dev.hyo.openiap.InAppMessageParamsAndroid as OpenIapInAppMessageParams import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams import dev.hyo.openiap.utils.redeemOfferCode internal suspend fun endExpoConnectionWithCleanup( endConnection: suspend () -> Boolean, cleanup: () -> Unit, ): Boolean = try { endConnection() } finally { cleanup() } internal suspend fun endExpoConnectionOrReset( provider: Lazy<*>, endConnection: suspend () -> Boolean, cleanup: () -> Unit, ): Boolean { if (!provider.isInitialized()) { cleanup() return true } return endExpoConnectionWithCleanup(endConnection, cleanup) } internal fun endConnectionErrorCode(error: Exception): String = (error as? OpenIapError)?.code ?: OpenIapError.ServiceDisconnected.CODE internal fun deliverPurchaseRequestFailure( reachedOpenIapRequest: Boolean, isCancellation: Boolean = false, errorCode: String, errorEnvelope: String, emitLocalError: () -> Unit, rejectPendingPromises: (String, String) -> Unit, ) { if (!reachedOpenIapRequest && !isCancellation) { emitLocalError() } rejectPendingPromises(errorCode, errorEnvelope) } @Suppress("UNCHECKED_CAST") internal fun verifyPurchasePropsFromMap(params: Map): VerifyPurchaseProps { fun requiredOption(options: Map, scope: String, field: String): String = (options[field] as? String)?.takeIf { it.isNotEmpty() } ?: throw IllegalArgumentException("Missing or empty required parameter: $scope.$field") val googleOptions = (params["google"] as? Map)?.let { options -> VerifyPurchaseGoogleOptions( sku = requiredOption(options, "google", "sku"), accessToken = requiredOption(options, "google", "accessToken"), packageName = requiredOption(options, "google", "packageName"), purchaseToken = requiredOption(options, "google", "purchaseToken"), isSub = options["isSub"] as? Boolean, ) } val horizonOptions = (params["horizon"] as? Map)?.let { options -> VerifyPurchaseHorizonOptions( sku = requiredOption(options, "horizon", "sku"), userId = requiredOption(options, "horizon", "userId"), accessToken = requiredOption(options, "horizon", "accessToken"), ) } return VerifyPurchaseProps(google = googleOptions, horizon = horizonOptions) } class ExpoIapModule : Module() { companion object { const val TAG = "ExpoIapModule" private const val EVENT_PURCHASE_UPDATED = "purchase-updated" private const val EVENT_PURCHASE_ERROR = "purchase-error" private const val EVENT_USER_CHOICE_BILLING = "user-choice-billing-android" private const val EVENT_DEVELOPER_PROVIDED_BILLING = "developer-provided-billing-android" private const val EVENT_SUBSCRIPTION_BILLING_ISSUE = "subscription-billing-issue" private const val MAX_BUFFERED_EVENTS = 200 } private val job = Job() private val scope = CoroutineScope(Dispatchers.Main + job) private val context: Context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() private val currentActivity get() = appContext.activityProvider?.currentActivity ?: throw Exceptions.MissingActivity() private val openIapLazy: Lazy = lazy { OpenIapProvider.create(context) } private val openIap: OpenIapProtocol get() = openIapLazy.value // The bridge and store share one provider instance. private val openIapStore: OpenIapStore by lazy { OpenIapStore(openIap) } private var listenerHandles: ExpoIapHelper.ListenerHandles? = null private val pendingEvents = ConcurrentLinkedQueue>>() private val connectionReady = AtomicBoolean(false) private val connectionMutex = Mutex() override fun definition() = ModuleDefinition { Name("ExpoIap") Constant("ERROR_CODES") { OpenIapError.getAllErrorCodes() } Events( EVENT_PURCHASE_UPDATED, EVENT_PURCHASE_ERROR, EVENT_USER_CHOICE_BILLING, EVENT_DEVELOPER_PROVIDED_BILLING, EVENT_SUBSCRIPTION_BILLING_ISSUE, ) AsyncFunction("initConnection") { config: Map?, promise: Promise -> ExpoIapLog.payload("initConnection", config) scope.launch { connectionMutex.withLock { try { // Set the Activity before initConnection: the Horizon SDK needs it to // initialize OVRPlatform with the proper returnComponent. // https://github.com/meta-quest/Meta-Spatial-SDK-Samples/issues/82#issuecomment-3452577530 runCatching { currentActivity } .onSuccess { ExpoIapLog.debug("Activity available: ${it.javaClass.name}") openIap.setActivity(it) }.onFailure { ExpoIapLog.warning("Activity not available during initConnection") } // Attach listeners early to avoid races during init if (listenerHandles == null) { listenerHandles = ExpoIapHelper.setupListeners( openIap, this@ExpoIapModule, scope, connectionReady, pendingEvents, EVENT_PURCHASE_UPDATED, EVENT_PURCHASE_ERROR, EVENT_USER_CHOICE_BILLING, EVENT_DEVELOPER_PROVIDED_BILLING, EVENT_SUBSCRIPTION_BILLING_ISSUE, ) } // Parse config from Map to InitConnectionConfig val parsedConfig = config?.let { InitConnectionConfig.fromJson(it) } val ok = openIap.initConnection.invoke(parsedConfig) if (!ok) { // Clear any buffered events from a failed init pendingEvents.clear() ExpoIapLog.failure("initConnection", IllegalStateException("Failed to initialize connection")) val error = OpenIapError.InitConnection.forProvider(context) promise.reject( error.code, error.message, null, ) return@withLock } // Mark ready then flush any buffered events connectionReady.set(true) while (true) { val ev = pendingEvents.poll() ?: break // Already on main dispatcher here; emit directly runCatching { sendEvent(ev.first, ev.second) } .onFailure { ExpoIapLog.failure("flush buffered event ${ev.first}", it) } } ExpoIapLog.result("initConnection", true) promise.resolve(true) } catch (e: Exception) { ExpoIapLog.failure("initConnection", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.InitConnection.CODE, e.message, e) } } } } AsyncFunction("endConnection") { promise: Promise -> ExpoIapLog.payload("endConnection", null) scope.launch { connectionMutex.withLock { try { val result = endExpoConnectionOrReset( provider = openIapLazy, endConnection = { openIap.endConnection() }, cleanup = { listenerHandles?.let { ExpoIapHelper.cleanupListeners(openIap, it) } listenerHandles = null PromiseUtils.rejectAllPendingPromises() connectionReady.set(false) pendingEvents.clear() }, ) ExpoIapLog.result("endConnection", result) promise.resolve(result) } catch (error: Exception) { ExpoIapLog.failure("endConnection", error) promise.reject( endConnectionErrorCode(error), error.message, error, ) } } } } AsyncFunction("fetchProducts") { type: String, skus: Array, promise: Promise -> ExpoIapLog.payload( "fetchProducts", mapOf("type" to type, "skus" to skus.toList()), ) scope.launch { try { val queryType = ExpoIapHelper.parseProductQueryType(type) val request = ProductRequest(skus.toList(), queryType) val result = openIap.fetchProducts(request) val payload = when (result) { is FetchProductsResultAll -> result.value.orEmpty().map { it.toJson() } is FetchProductsResultProducts -> result.value.orEmpty().map { it.toJson() } is FetchProductsResultSubscriptions -> result.value.orEmpty().map { it.toJson() } } ExpoIapLog.result("fetchProducts", payload) promise.resolve(payload) } catch (e: Exception) { ExpoIapLog.failure("fetchProducts", e) val errorMap = if (e is OpenIapError) { ExpoIapHelper.serializeOpenIapError(e) } else { mapOf( "code" to OpenIapError.QueryProduct.CODE, "message" to "Failed to query products", "debugMessage" to e.message, "platform" to "android", "productIds" to skus.toList(), "productType" to type, ) } val errorCode = errorMap["code"] as? String ?: OpenIapError.QueryProduct.CODE promise.reject( errorCode, ExpoIapHelper.serializeErrorEnvelope(errorMap), null, ) } } } AsyncFunction("getAvailableItems") { options: Map?, promise: Promise -> ExpoIapLog.payload("getAvailableItems", options) scope.launch { try { val purchaseOptions = options?.let { PurchaseOptions.fromJson(it) } val purchases = openIap.getAvailablePurchases(purchaseOptions) val payload = purchases.map { it.toJson() } ExpoIapLog.result("getAvailableItems", payload) promise.resolve(payload) } catch (e: Exception) { ExpoIapLog.failure("getAvailableItems", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, null) } } } // Deep link to Manage Subscriptions screen (Android) AsyncFunction("deepLinkToSubscriptionsAndroid") { params: Map, promise: Promise -> val deepLinkParams = ExpoIapHelper.parseDeepLinkSubscriptionParams(params) val sku = deepLinkParams.sku val packageName = deepLinkParams.packageName ExpoIapLog.payload("deepLinkToSubscriptionsAndroid", mapOf("sku" to sku, "packageName" to packageName)) scope.launch { try { openIap.deepLinkToSubscriptions(DeepLinkOptions(packageNameAndroid = packageName, skuAndroid = sku)) ExpoIapLog.result("deepLinkToSubscriptionsAndroid", true) promise.resolve(null) } catch (e: Exception) { ExpoIapLog.failure("deepLinkToSubscriptionsAndroid", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, null) } } } AsyncFunction("openRedeemOfferCode") { promise: Promise -> ExpoIapLog.payload("openRedeemOfferCode", null) scope.launch { try { val purchase = redeemOfferCode(openIap, runCatching { currentActivity }.getOrNull()) ExpoIapLog.result("openRedeemOfferCode", purchase != null) promise.resolve(purchase?.toJson()) } catch (e: Exception) { ExpoIapLog.failure("openRedeemOfferCode", e) val errorMap = if (e is OpenIapError) { ExpoIapHelper.serializeOpenIapError(e) } else { mapOf( "code" to OpenIapError.ServiceUnavailable.CODE, "message" to (e.message ?: OpenIapError.ServiceUnavailable.MESSAGE), "platform" to "android", ) } val errorCode = errorMap["code"] as? String ?: OpenIapError.ServiceUnavailable.CODE promise.reject( errorCode, ExpoIapHelper.serializeErrorEnvelope(errorMap), e, ) } } } // Get storefront country code (Android implementation) AsyncFunction("getStorefront") { promise: Promise -> ExpoIapLog.payload("getStorefront", null) scope.launch { try { val code = openIap.getStorefront() ExpoIapLog.result("getStorefront", code) promise.resolve(code) } catch (e: Exception) { ExpoIapLog.failure("getStorefront", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } AsyncFunction("requestPurchase") { params: Map, promise: Promise -> ExpoIapLog.payload("requestPurchase", params) val parsedParams = ExpoIapHelper.parseRequestPurchaseParams(params) val productType = when (ExpoIapHelper.parsePurchaseProductQueryType(parsedParams.type)) { ProductQueryType.Subs -> ProductQueryType.Subs else -> ProductQueryType.InApp } val subscriptionOffers = parsedParams.explicitSubscriptionOffers.takeIf { it.isNotEmpty() } val requestProps = when (productType) { ProductQueryType.Subs -> { val android = RequestSubscriptionAndroidProps( isOfferPersonalized = parsedParams.isOfferPersonalized, obfuscatedAccountId = parsedParams.obfuscatedAccountId, obfuscatedProfileId = parsedParams.obfuscatedProfileId, developerBillingOption = parsedParams.developerBillingOption, originalExternalTransactionId = parsedParams.originalExternalTransactionId, purchaseToken = parsedParams.purchaseToken, skus = parsedParams.skus, subscriptionOffers = subscriptionOffers, subscriptionProductReplacementParams = parsedParams.subscriptionProductReplacementParams, ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Subscription( RequestSubscriptionPropsByPlatforms(google = android), ), type = ProductQueryType.Subs, ) } else -> { val android = RequestPurchaseAndroidProps( isOfferPersonalized = parsedParams.isOfferPersonalized, obfuscatedAccountId = parsedParams.obfuscatedAccountId, obfuscatedProfileId = parsedParams.obfuscatedProfileId, developerBillingOption = parsedParams.developerBillingOption, offerToken = parsedParams.offerToken, skus = parsedParams.skus, ) RequestPurchaseProps( request = RequestPurchaseProps.Request.Purchase( RequestPurchasePropsByPlatforms(google = android), ), type = ProductQueryType.InApp, ) } } ExpoIapHelper.addPurchasePromise(promise) scope.launch { var reachedOpenIapRequest = false try { val activity = currentActivity openIap.setActivity(activity) reachedOpenIapRequest = true val result = openIap.requestPurchase(requestProps) val purchases = when (result) { is RequestPurchaseResultPurchases -> result.value.orEmpty() is RequestPurchaseResultPurchase -> result.value?.let(::listOf).orEmpty() else -> emptyList() } ExpoIapLog.result( "requestPurchase", purchases.map { it.toJson() }, ) ExpoIapHelper.resolvePurchasePromises(purchases.map { it.toJson() }) } catch (e: Exception) { ExpoIapLog.failure("requestPurchase", e) val errorMap = if (e is OpenIapError) { ExpoIapHelper.serializeOpenIapError(e) } else { mapOf( "code" to OpenIapError.PurchaseFailed.CODE, "message" to (e.message ?: "Purchase failed"), "platform" to "android", ) } val errorCode = errorMap["code"] as? String ?: OpenIapError.PurchaseFailed.CODE val errorEnvelope = ExpoIapHelper.serializeErrorEnvelope(errorMap) deliverPurchaseRequestFailure( reachedOpenIapRequest = reachedOpenIapRequest, isCancellation = e is CancellationException, errorCode = errorCode, errorEnvelope = errorEnvelope, emitLocalError = { runCatching { ExpoIapHelper.emitOrQueue( this@ExpoIapModule, scope, connectionReady, pendingEvents, EVENT_PURCHASE_ERROR, errorMap, ) }.onFailure { ex -> ExpoIapLog.failure("send PURCHASE_ERROR event requestPurchase", ex) } }, rejectPendingPromises = { code, message -> ExpoIapHelper.rejectPurchasePromises(code, message, null) }, ) if (e is CancellationException) { throw e } } } } AsyncFunction("restorePurchases") { promise: Promise -> scope.launch { try { openIap.restorePurchases() promise.resolve(true) } catch (e: Exception) { ExpoIapLog.failure("restorePurchases", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, null) } } } AsyncFunction("finishTransaction") { purchase: Map, isConsumable: Boolean, promise: Promise -> scope.launch { try { openIap.finishTransaction(dev.hyo.openiap.PurchaseAndroid.fromJson(purchase), isConsumable) promise.resolve(null) } catch (e: Exception) { ExpoIapLog.failure("finishTransaction", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.DeveloperError.CODE, e.message, null) } } } AsyncFunction("acknowledgePurchaseAndroid") { token: String, promise: Promise -> ExpoIapLog.payload("acknowledgePurchaseAndroid", mapOf("token" to token)) scope.launch { try { openIap.finishPurchaseToken(token, isConsumable = false) val response = mapOf("responseCode" to 0) ExpoIapLog.result("acknowledgePurchaseAndroid", response) promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("acknowledgePurchaseAndroid", e) ExpoIapHelper.rejectWithErrorEnvelope(promise, e) } } } AsyncFunction("consumePurchaseAndroid") { token: String, promise: Promise -> ExpoIapLog.payload("consumePurchaseAndroid", mapOf("token" to token)) scope.launch { try { openIap.finishPurchaseToken(token, isConsumable = true) val response = mapOf("responseCode" to 0, "purchaseToken" to token) ExpoIapLog.result( "consumePurchaseAndroid", response, ) promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("consumePurchaseAndroid", e) ExpoIapHelper.rejectWithErrorEnvelope(promise, e) } } } // Internal: backs the JS first-purchase notice; not app API. AsyncFunction("claimFirstPurchaseNotice") { OpenIapFirstPurchaseNotice.claim(context) } AsyncFunction("verifyAppOwnership") { params: Map, promise: Promise -> scope.launch { try { val options = dev.hyo.openiap.VerifyAppOwnershipProps.fromJson(params) promise.resolve(openIap.verifyAppOwnership(options).toJson()) } catch (e: CancellationException) { throw e } catch (e: Exception) { val code = when (e) { is OpenIapError -> e.code is IllegalArgumentException -> OpenIapError.DeveloperError.CODE else -> OpenIapError.ServiceUnavailable.CODE } promise.reject(code, e.message, e) } } } AsyncFunction("verifyPurchase") { params: Map, promise: Promise -> ExpoIapLog.payload( "verifyPurchase", mapOf( "hasGoogle" to (params["google"] != null), "hasHorizon" to (params["horizon"] != null), ), ) scope.launch { try { val props = verifyPurchasePropsFromMap(params) val result = openIap.verifyPurchase(props) val resultMap = result.toJson() ExpoIapLog.result("verifyPurchase", resultMap) promise.resolve(resultMap) } catch (e: Exception) { ExpoIapLog.failure("verifyPurchase", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.VerificationFailed.CODE, e.message, e) } } } AsyncFunction("verifyPurchaseWithProvider") { params: Map, promise: Promise -> ExpoIapLog.payload( "verifyPurchaseWithProvider", mapOf( "provider" to params["provider"], "hasIapkit" to (params["iapkit"] != null), ), ) scope.launch { try { val props = VerifyPurchaseWithProviderProps.fromJson(params) ?: throw IllegalArgumentException("Invalid verifyPurchaseWithProvider params") val result = openIap.verifyPurchaseWithProvider(props) val resultMap = result.toJson() ExpoIapLog.result("verifyPurchaseWithProvider", resultMap) promise.resolve(resultMap) } catch (e: Exception) { ExpoIapLog.failure("verifyPurchaseWithProvider", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.VerificationFailed.CODE, e.message, e) } } } AsyncFunction("getActiveSubscriptions") { subscriptionIds: List?, promise: Promise -> ExpoIapLog.payload( "getActiveSubscriptions", subscriptionIds?.let { mapOf("subscriptionIds" to it) } ?: emptyMap(), ) scope.launch { try { val subscriptions = openIap.getActiveSubscriptions(subscriptionIds) val result = subscriptions.map { it.toJson() } ExpoIapLog.result("getActiveSubscriptions", result) promise.resolve(result) } catch (e: Exception) { ExpoIapLog.failure("getActiveSubscriptions", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } AsyncFunction("hasActiveSubscriptions") { subscriptionIds: List?, promise: Promise -> ExpoIapLog.payload( "hasActiveSubscriptions", subscriptionIds?.let { mapOf("subscriptionIds" to it) } ?: emptyMap(), ) scope.launch { try { val hasActive = openIap.hasActiveSubscriptions(subscriptionIds) ExpoIapLog.result("hasActiveSubscriptions", hasActive) promise.resolve(hasActive) } catch (e: Exception) { ExpoIapLog.failure("hasActiveSubscriptions", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } // ------------------------------------------------------------------------- // Billing Programs API (Android 8.2.0+) // ------------------------------------------------------------------------- AsyncFunction("isBillingProgramAvailableAndroid") { program: String, promise: Promise -> ExpoIapLog.payload("isBillingProgramAvailableAndroid", mapOf("program" to program)) scope.launch { try { val openIapProgram = mapBillingProgram(program) val result = openIapStore.isBillingProgramAvailable(openIapProgram) val response = mapOf( "billingProgram" to result.billingProgram.toJson(), "choiceScreenType" to result.choiceScreenType?.toJson(), "isAvailable" to result.isAvailable, "isExternalLinkAvailable" to result.isExternalLinkAvailable, ) ExpoIapLog.result("isBillingProgramAvailableAndroid", response) promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("isBillingProgramAvailableAndroid", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } AsyncFunction("getBillingChoiceInfoAndroid") { params: Map, promise: Promise -> ExpoIapLog.payload("getBillingChoiceInfoAndroid", params) scope.launch { try { val request = OpenIapGetBillingChoiceInfoParams( billingProgram = mapBillingProgram(params["billingProgram"] as? String ?: "billing-choice"), playBillingChoiceImageLayout = mapBillingChoiceImageLayout( params["playBillingChoiceImageLayout"] as? String ?: "rectangular-four-by-one", ), userLocale = params["userLocale"] as? String, ) val result = openIapStore.getBillingChoiceInfo(request) ExpoIapLog.result( "getBillingChoiceInfoAndroid", mapOf("hasImageUrl" to result.playBillingChoiceImageUrl.isNotBlank()), ) promise.resolve(result.toJson()) } catch (e: Exception) { ExpoIapLog.failure("getBillingChoiceInfoAndroid", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } AsyncFunction( "createBillingProgramReportingDetailsAndroid", ) { program: String, developerBillingType: String?, promise: Promise -> ExpoIapLog.payload( "createBillingProgramReportingDetailsAndroid", mapOf("program" to program, "developerBillingType" to developerBillingType), ) scope.launch { try { val openIapProgram = mapBillingProgram(program) val result = openIapStore.createBillingProgramReportingDetails( openIapProgram, mapDeveloperBillingType(developerBillingType), ) val response = mapOf( "billingProgram" to program, "externalTransactionToken" to result.externalTransactionToken, ) ExpoIapLog.result( "createBillingProgramReportingDetailsAndroid", response, ) promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("createBillingProgramReportingDetailsAndroid", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } AsyncFunction("showBillingProgramInformationDialogAndroid") { params: Map, promise: Promise -> ExpoIapLog.payload("showBillingProgramInformationDialogAndroid", params) scope.launch { try { val activity = runCatching { currentActivity } .onFailure { ExpoIapLog.failure("showBillingProgramInformationDialogAndroid activity", it) }.getOrNull() ?: run { promise.reject(OpenIapError.ServiceUnavailable.CODE, "Activity not available", null) return@launch } val token = params["externalTransactionToken"] as? String if (token.isNullOrBlank()) { promise.reject(OpenIapError.DeveloperError.CODE, "`externalTransactionToken` is a required parameter.", null) return@launch } val result = openIapStore.showBillingProgramInformationDialog( activity, OpenIapBillingProgramInformationDialogParams( billingProgram = mapBillingProgram(params["billingProgram"] as? String ?: "billing-choice"), externalTransactionToken = token, ), ) ExpoIapLog.result("showBillingProgramInformationDialogAndroid", result.toJson()) promise.resolve(result.toJson()) } catch (e: Exception) { ExpoIapLog.failure("showBillingProgramInformationDialogAndroid", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } AsyncFunction("showInAppMessagesAndroid") { params: Map?, promise: Promise -> ExpoIapLog.payload("showInAppMessagesAndroid", params ?: emptyMap()) scope.launch { try { val activity = runCatching { currentActivity } .onFailure { ExpoIapLog.failure("showInAppMessagesAndroid activity", it) }.getOrNull() ?: run { promise.reject(OpenIapError.ServiceUnavailable.CODE, "Activity not available", null) return@launch } val categories = (params?.get("categories") as? List<*>) ?.map { entry -> val category = entry as? String ?: throw IllegalArgumentException("In-app message category must be a string: $entry") mapInAppMessageCategory(category) ?: throw IllegalArgumentException("Unknown in-app message category: $category") } val result = openIapStore.showInAppMessages( activity, OpenIapInAppMessageParams(categories = categories), ) ExpoIapLog.result("showInAppMessagesAndroid", result.toJson()) promise.resolve(result.toJson()) } catch (e: Exception) { ExpoIapLog.failure("showInAppMessagesAndroid", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } AsyncFunction("launchExternalLinkAndroid") { params: Map, promise: Promise -> ExpoIapLog.payload("launchExternalLinkAndroid", params) scope.launch { try { val activity = runCatching { currentActivity } .onFailure { ExpoIapLog.failure("launchExternalLinkAndroid activity", it) }.getOrNull() ?: run { promise.reject(OpenIapError.ServiceUnavailable.CODE, "Activity not available", null) return@launch } val billingProgram = params["billingProgram"] as? String val externalTransactionToken = params["externalTransactionToken"] as? String val launchMode = params["launchMode"] as? String ?: "unspecified" val linkType = params["linkType"] as? String ?: "unspecified" val linkUri = params["linkUri"] as? String if (billingProgram.isNullOrBlank()) { promise.reject(OpenIapError.DeveloperError.CODE, "`billingProgram` is a required parameter.", null) return@launch } if (linkUri.isNullOrBlank()) { promise.reject(OpenIapError.DeveloperError.CODE, "`linkUri` is a required and non-empty parameter.", null) return@launch } val openIapParams = OpenIapLaunchExternalLinkParams( billingProgram = mapBillingProgram(billingProgram), externalTransactionToken = externalTransactionToken, launchMode = mapExternalLinkLaunchMode(launchMode), linkType = mapExternalLinkType(linkType), linkUri = linkUri, ) val result = openIapStore.launchExternalLink(activity, openIapParams) ExpoIapLog.result("launchExternalLinkAndroid", result) promise.resolve(result) } catch (e: Exception) { ExpoIapLog.failure("launchExternalLinkAndroid", e) promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) } } } OnDestroy { try { listenerHandles?.let { ExpoIapHelper.cleanupListeners(openIap, it) } } finally { listenerHandles = null connectionReady.set(false) pendingEvents.clear() try { PromiseUtils.rejectAllPendingPromises() } finally { job.cancel() } } } } // ------------------------------------------------------------------------- // Billing Programs API Helper Functions // ------------------------------------------------------------------------- private fun mapBillingProgram(program: String): OpenIapBillingProgram = when (program) { "external-offer" -> OpenIapBillingProgram.ExternalOffer "external-content-link" -> OpenIapBillingProgram.ExternalContentLink "external-payments" -> OpenIapBillingProgram.ExternalPayments "user-choice-billing" -> OpenIapBillingProgram.UserChoiceBilling "billing-choice" -> OpenIapBillingProgram.BillingChoice else -> OpenIapBillingProgram.Unspecified } private fun mapBillingChoiceImageLayout(layout: String): OpenIapBillingChoiceImageLayout = when (layout) { "rectangular-three-by-one" -> OpenIapBillingChoiceImageLayout.RectangularThreeByOne "rectangular-two-by-two" -> OpenIapBillingChoiceImageLayout.RectangularTwoByTwo else -> OpenIapBillingChoiceImageLayout.RectangularFourByOne } private fun mapDeveloperBillingType(type: String?): OpenIapDeveloperBillingType? = when (type) { "in-app" -> OpenIapDeveloperBillingType.InApp "external-link" -> OpenIapDeveloperBillingType.ExternalLink else -> null } private fun mapInAppMessageCategory(category: String): OpenIapInAppMessageCategory? = when (category) { "unknown-in-app-message-category-id" -> OpenIapInAppMessageCategory.UnknownInAppMessageCategoryId "transactional" -> OpenIapInAppMessageCategory.Transactional else -> null } private fun mapExternalLinkLaunchMode(mode: String): OpenIapExternalLinkLaunchMode = when (mode) { "launch-in-external-browser-or-app" -> OpenIapExternalLinkLaunchMode.LaunchInExternalBrowserOrApp "caller-will-launch-link" -> OpenIapExternalLinkLaunchMode.CallerWillLaunchLink else -> OpenIapExternalLinkLaunchMode.Unspecified } private fun mapExternalLinkType(type: String): OpenIapExternalLinkType = when (type) { "link-to-digital-content-offer" -> OpenIapExternalLinkType.LinkToDigitalContentOffer "link-to-app-download" -> OpenIapExternalLinkType.LinkToAppDownload else -> OpenIapExternalLinkType.Unspecified } }