Skip to content
This repository was archived by the owner on Oct 17, 2025. It is now read-only.

Commit 5dec933

Browse files
committed
feat: support external purchase api with listener
1 parent f0cbddd commit 5dec933

File tree

6 files changed

+201
-27
lines changed

6 files changed

+201
-27
lines changed

Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,20 @@ fun AlternativeBillingScreen(navController: NavController) {
5454

5555
val store = OpenIapStore(appContext, alternativeBillingMode = selectedMode)
5656

57-
// Set user choice listener if in USER_CHOICE mode
57+
// Add event-based listener for User Choice Billing
5858
if (selectedMode == AlternativeBillingMode.USER_CHOICE) {
59-
store.setUserChoiceBillingListener { details ->
60-
android.util.Log.d("UserChoice", "User selected alternative billing")
61-
android.util.Log.d("UserChoice", "Token: ${details.externalTransactionToken}")
62-
android.util.Log.d("UserChoice", "Products: ${details.products}")
59+
store.addUserChoiceBillingListener { details ->
60+
android.util.Log.d("UserChoiceEvent", "=== User Choice Billing Event ===")
61+
android.util.Log.d("UserChoiceEvent", "External Token: ${details.externalTransactionToken}")
62+
android.util.Log.d("UserChoiceEvent", "Products: ${details.products}")
63+
android.util.Log.d("UserChoiceEvent", "==============================")
64+
65+
// Show result in UI
66+
store.postStatusMessage(
67+
message = "User selected alternative billing\nToken: ${details.externalTransactionToken.take(20)}...\nProducts: ${details.products.joinToString()}",
68+
status = dev.hyo.openiap.store.PurchaseResultStatus.Info,
69+
productId = details.products.firstOrNull()
70+
)
6371

6472
// TODO: Process payment with your payment system
6573
// Then create token and report to backend

openiap-versions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"google": "1.2.11",
3-
"gql": "1.0.11"
3+
"gql": "1.0.12"
44
}

openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import dev.hyo.openiap.helpers.restorePurchases as restorePurchasesHelper
5353
import dev.hyo.openiap.helpers.toAndroidPurchaseArgs
5454
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
5555
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
56+
import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener
5657
import dev.hyo.openiap.utils.BillingConverters.toInAppProduct
5758
import dev.hyo.openiap.utils.BillingConverters.toPurchase
5859
import dev.hyo.openiap.utils.BillingConverters.toSubscriptionProduct
@@ -109,6 +110,7 @@ class OpenIapModule(
109110

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

114116
val initConnection: MutationInitConnectionHandler = { config ->
@@ -840,6 +842,14 @@ class OpenIapModule(
840842
purchaseErrorListeners.remove(listener)
841843
}
842844

845+
fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) {
846+
userChoiceBillingListeners.add(listener)
847+
}
848+
849+
fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) {
850+
userChoiceBillingListeners.remove(listener)
851+
}
852+
843853
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<BillingPurchase>?) {
844854
Log.d(TAG, "onPurchasesUpdated: code=${billingResult.responseCode} msg=${billingResult.debugMessage} count=${purchases?.size ?: 0}")
845855
purchases?.forEachIndexed { index, purchase ->
@@ -929,12 +939,20 @@ class OpenIapModule(
929939
OpenIapLog.d("External transaction token: $externalToken", TAG)
930940
OpenIapLog.d("Products: $productIds", TAG)
931941

932-
// Call user's listener
933-
val details = dev.hyo.openiap.listener.UserChoiceDetails(
942+
// Create UserChoiceBillingDetails for the event
943+
val billingDetails = dev.hyo.openiap.UserChoiceBillingDetails(
934944
externalTransactionToken = externalToken,
935945
products = productIds
936946
)
937-
userChoiceBillingListener?.onUserSelectedAlternativeBilling(details)
947+
948+
// Notify all UserChoiceBilling listeners
949+
userChoiceBillingListeners.forEach { listener ->
950+
try {
951+
listener.onUserChoiceBilling(billingDetails)
952+
} catch (e: Exception) {
953+
OpenIapLog.w("UserChoiceBilling listener error: ${e.message}", TAG)
954+
}
955+
}
938956
} else {
939957
OpenIapLog.w("Failed to extract user choice details", TAG)
940958
}

openiap/src/main/java/dev/hyo/openiap/Types.kt

Lines changed: 151 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -156,22 +156,51 @@ public enum class ErrorCode(val rawValue: String) {
156156
fun toJson(): String = rawValue
157157
}
158158

159+
/**
160+
* User actions on external purchase notice sheet (iOS 18.2+)
161+
*/
162+
public enum class ExternalPurchaseNoticeAction(val rawValue: String) {
163+
/**
164+
* User chose to continue to external purchase
165+
*/
166+
Continue("continue"),
167+
/**
168+
* User dismissed the notice sheet
169+
*/
170+
Dismissed("dismissed");
171+
172+
companion object {
173+
fun fromJson(value: String): ExternalPurchaseNoticeAction = when (value) {
174+
"continue" -> ExternalPurchaseNoticeAction.Continue
175+
"CONTINUE" -> ExternalPurchaseNoticeAction.Continue
176+
"Continue" -> ExternalPurchaseNoticeAction.Continue
177+
"dismissed" -> ExternalPurchaseNoticeAction.Dismissed
178+
"DISMISSED" -> ExternalPurchaseNoticeAction.Dismissed
179+
"Dismissed" -> ExternalPurchaseNoticeAction.Dismissed
180+
else -> throw IllegalArgumentException("Unknown ExternalPurchaseNoticeAction value: $value")
181+
}
182+
}
183+
184+
fun toJson(): String = rawValue
185+
}
186+
159187
public enum class IapEvent(val rawValue: String) {
160188
PurchaseUpdated("purchase-updated"),
161189
PurchaseError("purchase-error"),
162-
PromotedProductIos("promoted-product-ios");
190+
PromotedProductIos("promoted-product-ios"),
191+
UserChoiceBillingAndroid("user-choice-billing-android");
163192

164193
companion object {
165194
fun fromJson(value: String): IapEvent = when (value) {
166195
"purchase-updated" -> IapEvent.PurchaseUpdated
167-
"PURCHASE_UPDATED" -> IapEvent.PurchaseUpdated
168196
"PurchaseUpdated" -> IapEvent.PurchaseUpdated
169197
"purchase-error" -> IapEvent.PurchaseError
170-
"PURCHASE_ERROR" -> IapEvent.PurchaseError
171198
"PurchaseError" -> IapEvent.PurchaseError
172199
"promoted-product-ios" -> IapEvent.PromotedProductIos
173-
"PROMOTED_PRODUCT_IOS" -> IapEvent.PromotedProductIos
200+
"PromotedProductIos" -> IapEvent.PromotedProductIos
174201
"PromotedProductIOS" -> IapEvent.PromotedProductIos
202+
"user-choice-billing-android" -> IapEvent.UserChoiceBillingAndroid
203+
"UserChoiceBillingAndroid" -> IapEvent.UserChoiceBillingAndroid
175204
else -> throw IllegalArgumentException("Unknown IapEvent value: $value")
176205
}
177206
}
@@ -186,9 +215,9 @@ public enum class IapPlatform(val rawValue: String) {
186215
companion object {
187216
fun fromJson(value: String): IapPlatform = when (value) {
188217
"ios" -> IapPlatform.Ios
218+
"Ios" -> IapPlatform.Ios
189219
"IOS" -> IapPlatform.Ios
190220
"android" -> IapPlatform.Android
191-
"ANDROID" -> IapPlatform.Android
192221
"Android" -> IapPlatform.Android
193222
else -> throw IllegalArgumentException("Unknown IapPlatform value: $value")
194223
}
@@ -616,6 +645,66 @@ public data class EntitlementIOS(
616645
)
617646
}
618647

648+
/**
649+
* Result of presenting an external purchase link (iOS 18.2+)
650+
*/
651+
public data class ExternalPurchaseLinkResultIOS(
652+
/**
653+
* Optional error message if the presentation failed
654+
*/
655+
val error: String? = null,
656+
/**
657+
* Whether the user completed the external purchase flow
658+
*/
659+
val success: Boolean
660+
) {
661+
662+
companion object {
663+
fun fromJson(json: Map<String, Any?>): ExternalPurchaseLinkResultIOS {
664+
return ExternalPurchaseLinkResultIOS(
665+
error = json["error"] as String?,
666+
success = json["success"] as Boolean,
667+
)
668+
}
669+
}
670+
671+
fun toJson(): Map<String, Any?> = mapOf(
672+
"__typename" to "ExternalPurchaseLinkResultIOS",
673+
"error" to error,
674+
"success" to success,
675+
)
676+
}
677+
678+
/**
679+
* Result of presenting external purchase notice sheet (iOS 18.2+)
680+
*/
681+
public data class ExternalPurchaseNoticeResultIOS(
682+
/**
683+
* Optional error message if the presentation failed
684+
*/
685+
val error: String? = null,
686+
/**
687+
* Notice result indicating user action
688+
*/
689+
val result: ExternalPurchaseNoticeAction
690+
) {
691+
692+
companion object {
693+
fun fromJson(json: Map<String, Any?>): ExternalPurchaseNoticeResultIOS {
694+
return ExternalPurchaseNoticeResultIOS(
695+
error = json["error"] as String?,
696+
result = ExternalPurchaseNoticeAction.fromJson(json["result"] as String),
697+
)
698+
}
699+
}
700+
701+
fun toJson(): Map<String, Any?> = mapOf(
702+
"__typename" to "ExternalPurchaseNoticeResultIOS",
703+
"error" to error,
704+
"result" to result.toJson(),
705+
)
706+
}
707+
619708
public sealed interface FetchProductsResult
620709

621710
public data class FetchProductsResultProducts(val value: List<Product>?) : FetchProductsResult
@@ -1474,6 +1563,37 @@ public data class SubscriptionStatusIOS(
14741563
)
14751564
}
14761565

1566+
/**
1567+
* User Choice Billing event details (Android)
1568+
* Fired when a user selects alternative billing in the User Choice Billing dialog
1569+
*/
1570+
public data class UserChoiceBillingDetails(
1571+
/**
1572+
* Token that must be reported to Google Play within 24 hours
1573+
*/
1574+
val externalTransactionToken: String,
1575+
/**
1576+
* List of product IDs selected by the user
1577+
*/
1578+
val products: List<String>
1579+
) {
1580+
1581+
companion object {
1582+
fun fromJson(json: Map<String, Any?>): UserChoiceBillingDetails {
1583+
return UserChoiceBillingDetails(
1584+
externalTransactionToken = json["externalTransactionToken"] as String,
1585+
products = (json["products"] as List<*>).map { it as String },
1586+
)
1587+
}
1588+
}
1589+
1590+
fun toJson(): Map<String, Any?> = mapOf(
1591+
"__typename" to "UserChoiceBillingDetails",
1592+
"externalTransactionToken" to externalTransactionToken,
1593+
"products" to products.map { it },
1594+
)
1595+
}
1596+
14771597
public typealias VoidResult = Unit
14781598

14791599
// MARK: - Input Objects
@@ -1736,10 +1856,6 @@ public data class RequestPurchaseIosProps(
17361856
* App account token for user tracking
17371857
*/
17381858
val appAccountToken: String? = null,
1739-
/**
1740-
* External purchase URL for alternative billing (iOS)
1741-
*/
1742-
val externalPurchaseUrl: String? = null,
17431859
/**
17441860
* Purchase quantity
17451861
*/
@@ -1758,7 +1874,6 @@ public data class RequestPurchaseIosProps(
17581874
return RequestPurchaseIosProps(
17591875
andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as Boolean?,
17601876
appAccountToken = json["appAccountToken"] as String?,
1761-
externalPurchaseUrl = json["externalPurchaseUrl"] as String?,
17621877
quantity = (json["quantity"] as Number?)?.toInt(),
17631878
sku = json["sku"] as String,
17641879
withOffer = (json["withOffer"] as Map<String, Any?>?)?.let { DiscountOfferInputIOS.fromJson(it) },
@@ -1769,7 +1884,6 @@ public data class RequestPurchaseIosProps(
17691884
fun toJson(): Map<String, Any?> = mapOf(
17701885
"andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically,
17711886
"appAccountToken" to appAccountToken,
1772-
"externalPurchaseUrl" to externalPurchaseUrl,
17731887
"quantity" to quantity,
17741888
"sku" to sku,
17751889
"withOffer" to withOffer?.toJson(),
@@ -1912,10 +2026,6 @@ public data class RequestSubscriptionAndroidProps(
19122026
public data class RequestSubscriptionIosProps(
19132027
val andDangerouslyFinishTransactionAutomatically: Boolean? = null,
19142028
val appAccountToken: String? = null,
1915-
/**
1916-
* External purchase URL for alternative billing (iOS)
1917-
*/
1918-
val externalPurchaseUrl: String? = null,
19192029
val quantity: Int? = null,
19202030
val sku: String,
19212031
val withOffer: DiscountOfferInputIOS? = null
@@ -1925,7 +2035,6 @@ public data class RequestSubscriptionIosProps(
19252035
return RequestSubscriptionIosProps(
19262036
andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as Boolean?,
19272037
appAccountToken = json["appAccountToken"] as String?,
1928-
externalPurchaseUrl = json["externalPurchaseUrl"] as String?,
19292038
quantity = (json["quantity"] as Number?)?.toInt(),
19302039
sku = json["sku"] as String,
19312040
withOffer = (json["withOffer"] as Map<String, Any?>?)?.let { DiscountOfferInputIOS.fromJson(it) },
@@ -1936,7 +2045,6 @@ public data class RequestSubscriptionIosProps(
19362045
fun toJson(): Map<String, Any?> = mapOf(
19372046
"andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically,
19382047
"appAccountToken" to appAccountToken,
1939-
"externalPurchaseUrl" to externalPurchaseUrl,
19402048
"quantity" to quantity,
19412049
"sku" to sku,
19422050
"withOffer" to withOffer?.toJson(),
@@ -2086,6 +2194,14 @@ public interface MutationResolver {
20862194
* Present the App Store code redemption sheet
20872195
*/
20882196
suspend fun presentCodeRedemptionSheetIOS(): Boolean
2197+
/**
2198+
* Present external purchase custom link with StoreKit UI (iOS 18.2+)
2199+
*/
2200+
suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS
2201+
/**
2202+
* Present external purchase notice sheet (iOS 18.2+)
2203+
*/
2204+
suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS
20892205
/**
20902206
* Initiate a purchase flow; rely on events for final state
20912207
*/
@@ -2125,6 +2241,10 @@ public interface MutationResolver {
21252241
* GraphQL root query operations.
21262242
*/
21272243
public interface QueryResolver {
2244+
/**
2245+
* Check if external purchase notice sheet can be presented (iOS 18.2+)
2246+
*/
2247+
suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean
21282248
/**
21292249
* Get current StoreKit 2 entitlements (iOS 15+)
21302250
*/
@@ -2211,6 +2331,11 @@ public interface SubscriptionResolver {
22112331
* Fires when a purchase completes successfully or a pending purchase resolves
22122332
*/
22132333
suspend fun purchaseUpdated(): Purchase
2334+
/**
2335+
* Fires when a user selects alternative billing in the User Choice Billing dialog (Android only)
2336+
* Only triggered when the user selects alternative billing instead of Google Play billing
2337+
*/
2338+
suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails
22142339
}
22152340

22162341
// MARK: - Root Operation Helpers
@@ -2228,6 +2353,8 @@ public typealias MutationEndConnectionHandler = suspend () -> Boolean
22282353
public typealias MutationFinishTransactionHandler = suspend (purchase: PurchaseInput, isConsumable: Boolean?) -> Unit
22292354
public typealias MutationInitConnectionHandler = suspend (config: InitConnectionConfig?) -> Boolean
22302355
public typealias MutationPresentCodeRedemptionSheetIOSHandler = suspend () -> Boolean
2356+
public typealias MutationPresentExternalPurchaseLinkIOSHandler = suspend (url: String) -> ExternalPurchaseLinkResultIOS
2357+
public typealias MutationPresentExternalPurchaseNoticeSheetIOSHandler = suspend () -> ExternalPurchaseNoticeResultIOS
22312358
public typealias MutationRequestPurchaseHandler = suspend (params: RequestPurchaseProps) -> RequestPurchaseResult?
22322359
public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = suspend () -> Boolean
22332360
public typealias MutationRestorePurchasesHandler = suspend () -> Unit
@@ -2248,6 +2375,8 @@ public data class MutationHandlers(
22482375
val finishTransaction: MutationFinishTransactionHandler? = null,
22492376
val initConnection: MutationInitConnectionHandler? = null,
22502377
val presentCodeRedemptionSheetIOS: MutationPresentCodeRedemptionSheetIOSHandler? = null,
2378+
val presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = null,
2379+
val presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = null,
22512380
val requestPurchase: MutationRequestPurchaseHandler? = null,
22522381
val requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = null,
22532382
val restorePurchases: MutationRestorePurchasesHandler? = null,
@@ -2259,6 +2388,7 @@ public data class MutationHandlers(
22592388

22602389
// MARK: - Query Helpers
22612390

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

22802410
public data class QueryHandlers(
2411+
val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null,
22812412
val currentEntitlementIOS: QueryCurrentEntitlementIOSHandler? = null,
22822413
val fetchProducts: QueryFetchProductsHandler? = null,
22832414
val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler? = null,
@@ -2302,9 +2433,11 @@ public data class QueryHandlers(
23022433
public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String
23032434
public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError
23042435
public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase
2436+
public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails
23052437

23062438
public data class SubscriptionHandlers(
23072439
val promotedProductIOS: SubscriptionPromotedProductIOSHandler? = null,
23082440
val purchaseError: SubscriptionPurchaseErrorHandler? = null,
2309-
val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null
2441+
val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null,
2442+
val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null
23102443
)

0 commit comments

Comments
 (0)