diff --git a/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt index 67c18a0..afa16d2 100644 --- a/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -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 diff --git a/openiap-versions.json b/openiap-versions.json index a08280a..cbfde36 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,4 +1,4 @@ { "google": "1.2.11", - "gql": "1.0.11" + "gql": "1.0.12" } diff --git a/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt b/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt index 66f2fb9..39437cd 100644 --- a/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt +++ b/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt @@ -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 @@ -109,6 +110,7 @@ class OpenIapModule( private val purchaseUpdateListeners = mutableSetOf() private val purchaseErrorListeners = mutableSetOf() + private val userChoiceBillingListeners = mutableSetOf() private var currentPurchaseCallback: ((Result>) -> Unit)? = null val initConnection: MutationInitConnectionHandler = { config -> @@ -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?) { Log.d(TAG, "onPurchasesUpdated: code=${billingResult.responseCode} msg=${billingResult.debugMessage} count=${purchases?.size ?: 0}") purchases?.forEachIndexed { index, purchase -> @@ -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) } diff --git a/openiap/src/main/java/dev/hyo/openiap/Types.kt b/openiap/src/main/java/dev/hyo/openiap/Types.kt index 681607e..b2fcd07 100644 --- a/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -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") } } @@ -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") } @@ -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): ExternalPurchaseLinkResultIOS { + return ExternalPurchaseLinkResultIOS( + error = json["error"] as String?, + success = json["success"] as Boolean, + ) + } + } + + fun toJson(): Map = 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): ExternalPurchaseNoticeResultIOS { + return ExternalPurchaseNoticeResultIOS( + error = json["error"] as String?, + result = ExternalPurchaseNoticeAction.fromJson(json["result"] as String), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "ExternalPurchaseNoticeResultIOS", + "error" to error, + "result" to result.toJson(), + ) +} + public sealed interface FetchProductsResult public data class FetchProductsResultProducts(val value: List?) : FetchProductsResult @@ -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 +) { + + companion object { + fun fromJson(json: Map): UserChoiceBillingDetails { + return UserChoiceBillingDetails( + externalTransactionToken = json["externalTransactionToken"] as String, + products = (json["products"] as List<*>).map { it as String }, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "UserChoiceBillingDetails", + "externalTransactionToken" to externalTransactionToken, + "products" to products.map { it }, + ) +} + public typealias VoidResult = Unit // MARK: - Input Objects @@ -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 */ @@ -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?)?.let { DiscountOfferInputIOS.fromJson(it) }, @@ -1769,7 +1884,6 @@ public data class RequestPurchaseIosProps( fun toJson(): Map = mapOf( "andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically, "appAccountToken" to appAccountToken, - "externalPurchaseUrl" to externalPurchaseUrl, "quantity" to quantity, "sku" to sku, "withOffer" to withOffer?.toJson(), @@ -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 @@ -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?)?.let { DiscountOfferInputIOS.fromJson(it) }, @@ -1936,7 +2045,6 @@ public data class RequestSubscriptionIosProps( fun toJson(): Map = mapOf( "andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically, "appAccountToken" to appAccountToken, - "externalPurchaseUrl" to externalPurchaseUrl, "quantity" to quantity, "sku" to sku, "withOffer" to withOffer?.toJson(), @@ -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 */ @@ -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+) */ @@ -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 @@ -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 @@ -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, @@ -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?) -> List @@ -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, @@ -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 ) diff --git a/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt b/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt index 1105f58..26cf383 100644 --- a/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt +++ b/openiap/src/main/java/dev/hyo/openiap/listener/OpenIapListener.kt @@ -2,6 +2,7 @@ package dev.hyo.openiap.listener import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.Purchase +import dev.hyo.openiap.UserChoiceBillingDetails /** * Listener for purchase updates @@ -25,6 +26,18 @@ fun interface OpenIapPurchaseErrorListener { fun onPurchaseError(error: OpenIapError) } +/** + * Listener for User Choice Billing selection (Android) + * Fires when user selects alternative billing in the User Choice Billing dialog + */ +fun interface OpenIapUserChoiceBillingListener { + /** + * Called when user selects alternative billing + * @param details The user choice billing details + */ + fun onUserChoiceBilling(details: UserChoiceBillingDetails) +} + /** * Combined listener interface for convenience */ diff --git a/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index cb0d521..c62ec3f 100644 --- a/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -359,6 +359,8 @@ class OpenIapStore(private val module: OpenIapModule) { fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) = module.removePurchaseUpdateListener(listener) fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) = module.addPurchaseErrorListener(listener) fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) = module.removePurchaseErrorListener(listener) + fun addUserChoiceBillingListener(listener: dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener) = module.addUserChoiceBillingListener(listener) + fun removeUserChoiceBillingListener(listener: dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener) = module.removeUserChoiceBillingListener(listener) // ------------------------------------------------------------------------- // Status helpers