Skip to content
This repository was archived by the owner on Oct 17, 2025. It is now read-only.
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 @@ -54,12 +54,20 @@ fun AlternativeBillingScreen(navController: NavController) {

val store = OpenIapStore(appContext, alternativeBillingMode = selectedMode)

// Set user choice listener if in USER_CHOICE mode
// Add event-based listener for User Choice Billing
if (selectedMode == AlternativeBillingMode.USER_CHOICE) {
store.setUserChoiceBillingListener { details ->
android.util.Log.d("UserChoice", "User selected alternative billing")
android.util.Log.d("UserChoice", "Token: ${details.externalTransactionToken}")
android.util.Log.d("UserChoice", "Products: ${details.products}")
store.addUserChoiceBillingListener { details ->
android.util.Log.d("UserChoiceEvent", "=== User Choice Billing Event ===")
android.util.Log.d("UserChoiceEvent", "External Token: ${details.externalTransactionToken}")
android.util.Log.d("UserChoiceEvent", "Products: ${details.products}")
android.util.Log.d("UserChoiceEvent", "==============================")

// Show result in UI
store.postStatusMessage(
message = "User selected alternative billing\nToken: ${details.externalTransactionToken.take(20)}...\nProducts: ${details.products.joinToString()}",
status = dev.hyo.openiap.store.PurchaseResultStatus.Info,
productId = details.products.firstOrNull()
)

// TODO: Process payment with your payment system
// Then create token and report to backend
Expand Down
2 changes: 1 addition & 1 deletion openiap-versions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"google": "1.2.11",
"gql": "1.0.11"
"gql": "1.0.12"
}
24 changes: 21 additions & 3 deletions openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import dev.hyo.openiap.helpers.restorePurchases as restorePurchasesHelper
import dev.hyo.openiap.helpers.toAndroidPurchaseArgs
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener
import dev.hyo.openiap.utils.BillingConverters.toInAppProduct
import dev.hyo.openiap.utils.BillingConverters.toPurchase
import dev.hyo.openiap.utils.BillingConverters.toSubscriptionProduct
Expand Down Expand Up @@ -109,6 +110,7 @@ class OpenIapModule(

private val purchaseUpdateListeners = mutableSetOf<OpenIapPurchaseUpdateListener>()
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
private val userChoiceBillingListeners = mutableSetOf<OpenIapUserChoiceBillingListener>()
private var currentPurchaseCallback: ((Result<List<Purchase>>) -> Unit)? = null

val initConnection: MutationInitConnectionHandler = { config ->
Expand Down Expand Up @@ -840,6 +842,14 @@ class OpenIapModule(
purchaseErrorListeners.remove(listener)
}

fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) {
userChoiceBillingListeners.add(listener)
}

fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) {
userChoiceBillingListeners.remove(listener)
}

override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<BillingPurchase>?) {
Log.d(TAG, "onPurchasesUpdated: code=${billingResult.responseCode} msg=${billingResult.debugMessage} count=${purchases?.size ?: 0}")
purchases?.forEachIndexed { index, purchase ->
Expand Down Expand Up @@ -929,12 +939,20 @@ class OpenIapModule(
OpenIapLog.d("External transaction token: $externalToken", TAG)
OpenIapLog.d("Products: $productIds", TAG)

// Call user's listener
val details = dev.hyo.openiap.listener.UserChoiceDetails(
// Create UserChoiceBillingDetails for the event
val billingDetails = dev.hyo.openiap.UserChoiceBillingDetails(
externalTransactionToken = externalToken,
products = productIds
)
userChoiceBillingListener?.onUserSelectedAlternativeBilling(details)

// Notify all UserChoiceBilling listeners
userChoiceBillingListeners.forEach { listener ->
try {
listener.onUserChoiceBilling(billingDetails)
} catch (e: Exception) {
OpenIapLog.w("UserChoiceBilling listener error: ${e.message}", TAG)
}
}
} else {
OpenIapLog.w("Failed to extract user choice details", TAG)
}
Expand Down
169 changes: 151 additions & 18 deletions openiap/src/main/java/dev/hyo/openiap/Types.kt
Original file line number Diff line number Diff line change
Expand Up @@ -156,22 +156,51 @@ public enum class ErrorCode(val rawValue: String) {
fun toJson(): String = rawValue
}

/**
* User actions on external purchase notice sheet (iOS 18.2+)
*/
public enum class ExternalPurchaseNoticeAction(val rawValue: String) {
/**
* User chose to continue to external purchase
*/
Continue("continue"),
/**
* User dismissed the notice sheet
*/
Dismissed("dismissed");

companion object {
fun fromJson(value: String): ExternalPurchaseNoticeAction = when (value) {
"continue" -> ExternalPurchaseNoticeAction.Continue
"CONTINUE" -> ExternalPurchaseNoticeAction.Continue
"Continue" -> ExternalPurchaseNoticeAction.Continue
"dismissed" -> ExternalPurchaseNoticeAction.Dismissed
"DISMISSED" -> ExternalPurchaseNoticeAction.Dismissed
"Dismissed" -> ExternalPurchaseNoticeAction.Dismissed
else -> throw IllegalArgumentException("Unknown ExternalPurchaseNoticeAction value: $value")
}
}

fun toJson(): String = rawValue
}

public enum class IapEvent(val rawValue: String) {
PurchaseUpdated("purchase-updated"),
PurchaseError("purchase-error"),
PromotedProductIos("promoted-product-ios");
PromotedProductIos("promoted-product-ios"),
UserChoiceBillingAndroid("user-choice-billing-android");

companion object {
fun fromJson(value: String): IapEvent = when (value) {
"purchase-updated" -> IapEvent.PurchaseUpdated
"PURCHASE_UPDATED" -> IapEvent.PurchaseUpdated
"PurchaseUpdated" -> IapEvent.PurchaseUpdated
"purchase-error" -> IapEvent.PurchaseError
"PURCHASE_ERROR" -> IapEvent.PurchaseError
"PurchaseError" -> IapEvent.PurchaseError
"promoted-product-ios" -> IapEvent.PromotedProductIos
"PROMOTED_PRODUCT_IOS" -> IapEvent.PromotedProductIos
"PromotedProductIos" -> IapEvent.PromotedProductIos
"PromotedProductIOS" -> IapEvent.PromotedProductIos
"user-choice-billing-android" -> IapEvent.UserChoiceBillingAndroid
"UserChoiceBillingAndroid" -> IapEvent.UserChoiceBillingAndroid
else -> throw IllegalArgumentException("Unknown IapEvent value: $value")
}
}
Expand All @@ -186,9 +215,9 @@ public enum class IapPlatform(val rawValue: String) {
companion object {
fun fromJson(value: String): IapPlatform = when (value) {
"ios" -> IapPlatform.Ios
"Ios" -> IapPlatform.Ios
"IOS" -> IapPlatform.Ios
"android" -> IapPlatform.Android
"ANDROID" -> IapPlatform.Android
"Android" -> IapPlatform.Android
else -> throw IllegalArgumentException("Unknown IapPlatform value: $value")
}
Expand Down Expand Up @@ -616,6 +645,66 @@ public data class EntitlementIOS(
)
}

/**
* Result of presenting an external purchase link (iOS 18.2+)
*/
public data class ExternalPurchaseLinkResultIOS(
/**
* Optional error message if the presentation failed
*/
val error: String? = null,
/**
* Whether the user completed the external purchase flow
*/
val success: Boolean
) {

companion object {
fun fromJson(json: Map<String, Any?>): ExternalPurchaseLinkResultIOS {
return ExternalPurchaseLinkResultIOS(
error = json["error"] as String?,
success = json["success"] as Boolean,
)
}
}

fun toJson(): Map<String, Any?> = mapOf(
"__typename" to "ExternalPurchaseLinkResultIOS",
"error" to error,
"success" to success,
)
}

/**
* Result of presenting external purchase notice sheet (iOS 18.2+)
*/
public data class ExternalPurchaseNoticeResultIOS(
/**
* Optional error message if the presentation failed
*/
val error: String? = null,
/**
* Notice result indicating user action
*/
val result: ExternalPurchaseNoticeAction
) {

companion object {
fun fromJson(json: Map<String, Any?>): ExternalPurchaseNoticeResultIOS {
return ExternalPurchaseNoticeResultIOS(
error = json["error"] as String?,
result = ExternalPurchaseNoticeAction.fromJson(json["result"] as String),
)
}
}

fun toJson(): Map<String, Any?> = mapOf(
"__typename" to "ExternalPurchaseNoticeResultIOS",
"error" to error,
"result" to result.toJson(),
)
}

public sealed interface FetchProductsResult

public data class FetchProductsResultProducts(val value: List<Product>?) : FetchProductsResult
Expand Down Expand Up @@ -1474,6 +1563,37 @@ public data class SubscriptionStatusIOS(
)
}

/**
* User Choice Billing event details (Android)
* Fired when a user selects alternative billing in the User Choice Billing dialog
*/
public data class UserChoiceBillingDetails(
/**
* Token that must be reported to Google Play within 24 hours
*/
val externalTransactionToken: String,
/**
* List of product IDs selected by the user
*/
val products: List<String>
) {

companion object {
fun fromJson(json: Map<String, Any?>): UserChoiceBillingDetails {
return UserChoiceBillingDetails(
externalTransactionToken = json["externalTransactionToken"] as String,
products = (json["products"] as List<*>).map { it as String },
)
}
}

fun toJson(): Map<String, Any?> = mapOf(
"__typename" to "UserChoiceBillingDetails",
"externalTransactionToken" to externalTransactionToken,
"products" to products.map { it },
)
}

public typealias VoidResult = Unit

// MARK: - Input Objects
Expand Down Expand Up @@ -1736,10 +1856,6 @@ public data class RequestPurchaseIosProps(
* App account token for user tracking
*/
val appAccountToken: String? = null,
/**
* External purchase URL for alternative billing (iOS)
*/
val externalPurchaseUrl: String? = null,
/**
* Purchase quantity
*/
Expand All @@ -1758,7 +1874,6 @@ public data class RequestPurchaseIosProps(
return RequestPurchaseIosProps(
andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as Boolean?,
appAccountToken = json["appAccountToken"] as String?,
externalPurchaseUrl = json["externalPurchaseUrl"] as String?,
quantity = (json["quantity"] as Number?)?.toInt(),
sku = json["sku"] as String,
withOffer = (json["withOffer"] as Map<String, Any?>?)?.let { DiscountOfferInputIOS.fromJson(it) },
Expand All @@ -1769,7 +1884,6 @@ public data class RequestPurchaseIosProps(
fun toJson(): Map<String, Any?> = mapOf(
"andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically,
"appAccountToken" to appAccountToken,
"externalPurchaseUrl" to externalPurchaseUrl,
"quantity" to quantity,
"sku" to sku,
"withOffer" to withOffer?.toJson(),
Expand Down Expand Up @@ -1912,10 +2026,6 @@ public data class RequestSubscriptionAndroidProps(
public data class RequestSubscriptionIosProps(
val andDangerouslyFinishTransactionAutomatically: Boolean? = null,
val appAccountToken: String? = null,
/**
* External purchase URL for alternative billing (iOS)
*/
val externalPurchaseUrl: String? = null,
val quantity: Int? = null,
val sku: String,
val withOffer: DiscountOfferInputIOS? = null
Expand All @@ -1925,7 +2035,6 @@ public data class RequestSubscriptionIosProps(
return RequestSubscriptionIosProps(
andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as Boolean?,
appAccountToken = json["appAccountToken"] as String?,
externalPurchaseUrl = json["externalPurchaseUrl"] as String?,
quantity = (json["quantity"] as Number?)?.toInt(),
sku = json["sku"] as String,
withOffer = (json["withOffer"] as Map<String, Any?>?)?.let { DiscountOfferInputIOS.fromJson(it) },
Expand All @@ -1936,7 +2045,6 @@ public data class RequestSubscriptionIosProps(
fun toJson(): Map<String, Any?> = mapOf(
"andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically,
"appAccountToken" to appAccountToken,
"externalPurchaseUrl" to externalPurchaseUrl,
"quantity" to quantity,
"sku" to sku,
"withOffer" to withOffer?.toJson(),
Expand Down Expand Up @@ -2086,6 +2194,14 @@ public interface MutationResolver {
* Present the App Store code redemption sheet
*/
suspend fun presentCodeRedemptionSheetIOS(): Boolean
/**
* Present external purchase custom link with StoreKit UI (iOS 18.2+)
*/
suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS
/**
* Present external purchase notice sheet (iOS 18.2+)
*/
suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS
/**
* Initiate a purchase flow; rely on events for final state
*/
Expand Down Expand Up @@ -2125,6 +2241,10 @@ public interface MutationResolver {
* GraphQL root query operations.
*/
public interface QueryResolver {
/**
* Check if external purchase notice sheet can be presented (iOS 18.2+)
*/
suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean
/**
* Get current StoreKit 2 entitlements (iOS 15+)
*/
Expand Down Expand Up @@ -2211,6 +2331,11 @@ public interface SubscriptionResolver {
* Fires when a purchase completes successfully or a pending purchase resolves
*/
suspend fun purchaseUpdated(): Purchase
/**
* Fires when a user selects alternative billing in the User Choice Billing dialog (Android only)
* Only triggered when the user selects alternative billing instead of Google Play billing
*/
suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails
}

// MARK: - Root Operation Helpers
Expand All @@ -2228,6 +2353,8 @@ public typealias MutationEndConnectionHandler = suspend () -> Boolean
public typealias MutationFinishTransactionHandler = suspend (purchase: PurchaseInput, isConsumable: Boolean?) -> Unit
public typealias MutationInitConnectionHandler = suspend (config: InitConnectionConfig?) -> Boolean
public typealias MutationPresentCodeRedemptionSheetIOSHandler = suspend () -> Boolean
public typealias MutationPresentExternalPurchaseLinkIOSHandler = suspend (url: String) -> ExternalPurchaseLinkResultIOS
public typealias MutationPresentExternalPurchaseNoticeSheetIOSHandler = suspend () -> ExternalPurchaseNoticeResultIOS
public typealias MutationRequestPurchaseHandler = suspend (params: RequestPurchaseProps) -> RequestPurchaseResult?
public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = suspend () -> Boolean
public typealias MutationRestorePurchasesHandler = suspend () -> Unit
Expand All @@ -2248,6 +2375,8 @@ public data class MutationHandlers(
val finishTransaction: MutationFinishTransactionHandler? = null,
val initConnection: MutationInitConnectionHandler? = null,
val presentCodeRedemptionSheetIOS: MutationPresentCodeRedemptionSheetIOSHandler? = null,
val presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = null,
val presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = null,
val requestPurchase: MutationRequestPurchaseHandler? = null,
val requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = null,
val restorePurchases: MutationRestorePurchasesHandler? = null,
Expand All @@ -2259,6 +2388,7 @@ public data class MutationHandlers(

// MARK: - Query Helpers

public typealias QueryCanPresentExternalPurchaseNoticeIOSHandler = suspend () -> Boolean
public typealias QueryCurrentEntitlementIOSHandler = suspend (sku: String) -> PurchaseIOS?
public typealias QueryFetchProductsHandler = suspend (params: ProductRequest) -> FetchProductsResult
public typealias QueryGetActiveSubscriptionsHandler = suspend (subscriptionIds: List<String>?) -> List<ActiveSubscription>
Expand All @@ -2278,6 +2408,7 @@ public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> Li
public typealias QueryValidateReceiptIOSHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResultIOS

public data class QueryHandlers(
val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null,
val currentEntitlementIOS: QueryCurrentEntitlementIOSHandler? = null,
val fetchProducts: QueryFetchProductsHandler? = null,
val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler? = null,
Expand All @@ -2302,9 +2433,11 @@ public data class QueryHandlers(
public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String
public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError
public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase
public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails

public data class SubscriptionHandlers(
val promotedProductIOS: SubscriptionPromotedProductIOSHandler? = null,
val purchaseError: SubscriptionPurchaseErrorHandler? = null,
val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null
val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null,
val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null
)
Loading
Loading