@namiml/expo-nami-iap 3.4.0-dev.202605060437

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +121 -0
  2. package/android/build.gradle +46 -0
  3. package/android/gradle.properties +1 -0
  4. package/android/libs/.gitkeep +0 -0
  5. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
  6. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
  7. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
  8. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
  9. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
  10. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
  11. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
  12. package/android/src/main/AndroidManifest.xml +1 -0
  13. package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
  14. package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
  15. package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
  16. package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
  17. package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
  18. package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
  19. package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
  20. package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
  21. package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
  22. package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
  23. package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
  24. package/dist/index.cjs +56 -0
  25. package/dist/index.d.ts +62 -0
  26. package/dist/index.mjs +45 -0
  27. package/expo-module.config.json +9 -0
  28. package/ios/Errors.swift +178 -0
  29. package/ios/ExpoNamiIap.podspec +14 -0
  30. package/ios/ExpoNamiIapModule.swift +81 -0
  31. package/ios/ProductMapper.swift +130 -0
  32. package/ios/PromotionalOfferSigner.swift +78 -0
  33. package/ios/StoreKit2Helper.swift +252 -0
  34. package/ios/Tests/ErrorsTests.swift +189 -0
  35. package/ios/Tests/ProductMapperTests.swift +165 -0
  36. package/ios/Tests/Resources/Products.storekit +210 -0
  37. package/ios/Tests/StoreKit2HelperTests.swift +360 -0
  38. package/ios/TransactionObserver.swift +54 -0
  39. package/package.json +64 -0
  40. package/src/ExpoNamiIapModule.kepler.ts +200 -0
  41. package/src/ExpoNamiIapModule.ts +18 -0
  42. package/src/index.ts +63 -0
  43. package/src/types.ts +46 -0
@@ -0,0 +1,104 @@
1
+ package ml.nami.expo.iap.google
2
+
3
+ import android.content.Context
4
+ import com.android.billingclient.api.BillingClient
5
+ import com.android.billingclient.api.BillingClientStateListener
6
+ import com.android.billingclient.api.BillingResult
7
+ import com.android.billingclient.api.PendingPurchasesParams
8
+ import com.android.billingclient.api.PurchasesUpdatedListener
9
+ import kotlinx.coroutines.suspendCancellableCoroutine
10
+ import kotlin.coroutines.resume
11
+ import kotlin.coroutines.resumeWithException
12
+
13
+ /**
14
+ * Wraps [BillingClient] construction and connection lifecycle.
15
+ *
16
+ * [ensureConnected] suspends until the client is connected. If the client
17
+ * disconnects with [BillingClient.BillingResponseCode.SERVICE_DISCONNECTED],
18
+ * it retries the connection once automatically.
19
+ */
20
+ internal class PlayBillingConnection(
21
+ context: Context,
22
+ builder: BillingClient.Builder,
23
+ ) {
24
+ /**
25
+ * Convenience constructor that builds a default [BillingClient.Builder].
26
+ */
27
+ constructor(
28
+ context: Context,
29
+ purchasesUpdatedListener: PurchasesUpdatedListener,
30
+ ) : this(
31
+ context,
32
+ BillingClient
33
+ .newBuilder(context)
34
+ .setListener(purchasesUpdatedListener)
35
+ .enablePendingPurchases(
36
+ PendingPurchasesParams
37
+ .newBuilder()
38
+ .enableOneTimeProducts()
39
+ .build(),
40
+ ),
41
+ )
42
+
43
+ internal val billingClient: BillingClient = builder.build()
44
+
45
+ /** Whether we have already spent our single automatic retry. */
46
+ private var hasRetried = false
47
+
48
+ /**
49
+ * Suspend until the [BillingClient] is connected and ready.
50
+ *
51
+ * If the client is already ready, returns immediately.
52
+ * On [BillingClient.BillingResponseCode.SERVICE_DISCONNECTED], retries once.
53
+ *
54
+ * @return the connected [BillingClient] instance.
55
+ * @throws IllegalStateException if the connection fails after retry.
56
+ */
57
+ suspend fun ensureConnected(): BillingClient {
58
+ if (billingClient.isReady) return billingClient
59
+ return connect()
60
+ }
61
+
62
+ /**
63
+ * Disconnect the underlying [BillingClient] and release resources.
64
+ */
65
+ fun close() {
66
+ billingClient.endConnection()
67
+ }
68
+
69
+ // ── Internals ──────────────────────────────────────────────────────
70
+
71
+ private suspend fun connect(): BillingClient =
72
+ suspendCancellableCoroutine { cont ->
73
+ billingClient.startConnection(
74
+ object : BillingClientStateListener {
75
+ override fun onBillingSetupFinished(result: BillingResult) {
76
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
77
+ if (cont.isActive) cont.resume(billingClient)
78
+ } else if (
79
+ result.responseCode ==
80
+ BillingClient.BillingResponseCode.SERVICE_DISCONNECTED &&
81
+ !hasRetried
82
+ ) {
83
+ // Retry once
84
+ hasRetried = true
85
+ billingClient.startConnection(this)
86
+ } else {
87
+ if (cont.isActive) {
88
+ cont.resumeWithException(
89
+ IllegalStateException(
90
+ "BillingClient connection failed: " +
91
+ "${result.responseCode} ${result.debugMessage}",
92
+ ),
93
+ )
94
+ }
95
+ }
96
+ }
97
+
98
+ override fun onBillingServiceDisconnected() {
99
+ // No-op: reconnection is handled by ensureConnected on next call
100
+ }
101
+ },
102
+ )
103
+ }
104
+ }
@@ -0,0 +1 @@
1
+ <manifest />
@@ -0,0 +1,94 @@
1
+ package ml.nami.expo.iap
2
+
3
+ import com.android.billingclient.api.BillingClient.BillingResponseCode
4
+
5
+ /**
6
+ * Maps Google Play Billing `BillingResponseCode` integers to normalized error
7
+ * codes that match the cross-platform `ExpoNamiIapException` hierarchy defined
8
+ * in [IapBackend.kt].
9
+ *
10
+ * Two projections are provided:
11
+ * - [billingResponseToCode]: normalized code string (`"user_cancelled"`, `"network"`, etc.)
12
+ * - [billingResponseToNativeCode]: the Play Billing constant name (e.g. `"USER_CANCELED"`)
13
+ */
14
+ object Errors {
15
+ // ── Normalized code mapping ─────────────────────────────────────────
16
+
17
+ /**
18
+ * Convert a `BillingResponseCode` integer to a normalized string code.
19
+ *
20
+ * | Normalized code | BillingResponseCode constants |
21
+ * |--------------------|----------------------------------------------------------|
22
+ * | user_cancelled | USER_CANCELED |
23
+ * | network | SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE, NETWORK_ERROR |
24
+ * | item_unavailable | ITEM_UNAVAILABLE |
25
+ * | already_owned | ITEM_ALREADY_OWNED |
26
+ * | not_allowed | FEATURE_NOT_SUPPORTED, DEVELOPER_ERROR, BILLING_UNAVAILABLE |
27
+ * | unknown | ERROR, ITEM_NOT_OWNED, anything else |
28
+ */
29
+ fun billingResponseToCode(code: Int): String =
30
+ when (code) {
31
+ BillingResponseCode.USER_CANCELED -> "user_cancelled"
32
+ BillingResponseCode.SERVICE_DISCONNECTED,
33
+ BillingResponseCode.SERVICE_UNAVAILABLE,
34
+ BillingResponseCode.NETWORK_ERROR,
35
+ -> "network"
36
+ BillingResponseCode.ITEM_UNAVAILABLE -> "item_unavailable"
37
+ BillingResponseCode.ITEM_ALREADY_OWNED -> "already_owned"
38
+ BillingResponseCode.FEATURE_NOT_SUPPORTED,
39
+ BillingResponseCode.DEVELOPER_ERROR,
40
+ BillingResponseCode.BILLING_UNAVAILABLE,
41
+ -> "not_allowed"
42
+ else -> "unknown"
43
+ }
44
+
45
+ // ── Native code (constant name) mapping ─────────────────────────────
46
+
47
+ /**
48
+ * Map from `BillingResponseCode` integer to the constant name.
49
+ * Used to populate `nativeCode` in `ExpoNamiIapException`.
50
+ */
51
+ private val nativeCodeMap: Map<Int, String> =
52
+ mapOf(
53
+ BillingResponseCode.OK to "OK",
54
+ BillingResponseCode.USER_CANCELED to "USER_CANCELED",
55
+ BillingResponseCode.SERVICE_UNAVAILABLE to "SERVICE_UNAVAILABLE",
56
+ BillingResponseCode.BILLING_UNAVAILABLE to "BILLING_UNAVAILABLE",
57
+ BillingResponseCode.ITEM_UNAVAILABLE to "ITEM_UNAVAILABLE",
58
+ BillingResponseCode.DEVELOPER_ERROR to "DEVELOPER_ERROR",
59
+ BillingResponseCode.ERROR to "ERROR",
60
+ BillingResponseCode.ITEM_ALREADY_OWNED to "ITEM_ALREADY_OWNED",
61
+ BillingResponseCode.ITEM_NOT_OWNED to "ITEM_NOT_OWNED",
62
+ BillingResponseCode.SERVICE_DISCONNECTED to "SERVICE_DISCONNECTED",
63
+ BillingResponseCode.FEATURE_NOT_SUPPORTED to "FEATURE_NOT_SUPPORTED",
64
+ BillingResponseCode.NETWORK_ERROR to "NETWORK_ERROR",
65
+ )
66
+
67
+ /**
68
+ * Return the constant name for a `BillingResponseCode` integer.
69
+ * Falls back to `"UNKNOWN(<code>)"` for unmapped values.
70
+ */
71
+ fun billingResponseToNativeCode(code: Int): String = nativeCodeMap[code] ?: "UNKNOWN($code)"
72
+
73
+ // ── Factory helper ──────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Convert a `BillingResponseCode` integer into the appropriate
77
+ * [ExpoNamiIapException] subclass.
78
+ */
79
+ fun toException(
80
+ code: Int,
81
+ message: String? = null,
82
+ ): ExpoNamiIapException {
83
+ val nativeCode = billingResponseToNativeCode(code)
84
+ val msg = message ?: "Billing error: $nativeCode"
85
+ return when (billingResponseToCode(code)) {
86
+ "user_cancelled" -> ExpoNamiIapException.UserCancelled(nativeCode)
87
+ "network" -> ExpoNamiIapException.Network(msg, nativeCode)
88
+ "item_unavailable" -> ExpoNamiIapException.ItemUnavailable(msg, nativeCode)
89
+ "already_owned" -> ExpoNamiIapException.AlreadyOwned(nativeCode)
90
+ "not_allowed" -> ExpoNamiIapException.NotAllowed(msg, nativeCode)
91
+ else -> ExpoNamiIapException.Unknown(msg, nativeCode)
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,56 @@
1
+ package ml.nami.expo.iap
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+
6
+ class ExpoNamiIapModule : Module() {
7
+ internal val backend: IapBackend by lazy { buildBackend() }
8
+
9
+ override fun definition() =
10
+ ModuleDefinition {
11
+ Name("ExpoNamiIapModule")
12
+ Events("onTransactionUpdate", "onError")
13
+
14
+ OnCreate {
15
+ backend.onTransactionUpdate { dict ->
16
+ sendEvent("onTransactionUpdate", dict)
17
+ }
18
+ backend.onError { dict ->
19
+ sendEvent("onError", dict)
20
+ }
21
+ }
22
+
23
+ Function("configure") { options: Map<String, Any?> -> backend.configure(options) }
24
+
25
+ AsyncFunction("initConnection") {
26
+ backend.initConnection(appContext.reactContext ?: error("No React context"))
27
+ }
28
+ AsyncFunction("endConnection") { backend.endConnection() }
29
+ AsyncFunction("getProducts") { skuIds: List<String> -> backend.getProducts(skuIds) }
30
+ AsyncFunction("checkEligibility") { skuIds: List<String> -> backend.checkEligibility(skuIds) }
31
+ AsyncFunction("purchase") { options: Map<String, Any?> ->
32
+ val skuId = options["skuId"] as String
33
+ val activity = appContext.currentActivity ?: error("No current activity")
34
+ backend.purchase(
35
+ activity,
36
+ skuId,
37
+ options["offerId"] as String?,
38
+ options["offerSignature"] as String?,
39
+ options["appAccountToken"] as String?,
40
+ )
41
+ }
42
+ AsyncFunction("restorePurchases") { backend.restorePurchases() }
43
+ AsyncFunction("finishTransaction") { id: String -> backend.finishTransaction(id) }
44
+ }
45
+
46
+ internal fun buildBackend(): IapBackend {
47
+ // Flavor-specific implementation resolved by classpath
48
+ val cls =
49
+ try {
50
+ Class.forName("ml.nami.expo.iap.google.PlayBillingBackend")
51
+ } catch (_: ClassNotFoundException) {
52
+ Class.forName("ml.nami.expo.iap.amazon.AmazonIapBackend")
53
+ }
54
+ return cls.getDeclaredConstructor().newInstance() as IapBackend
55
+ }
56
+ }
@@ -0,0 +1,72 @@
1
+ package ml.nami.expo.iap
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+
6
+ interface IapBackend {
7
+ suspend fun initConnection(context: Context)
8
+
9
+ suspend fun endConnection()
10
+
11
+ suspend fun getProducts(skuIds: List<String>): List<Map<String, Any?>>
12
+
13
+ suspend fun checkEligibility(skuIds: List<String>): List<Map<String, Any?>>
14
+
15
+ suspend fun purchase(
16
+ activity: Activity,
17
+ skuId: String,
18
+ offerId: String?,
19
+ offerSignature: String?,
20
+ appAccountToken: String?,
21
+ ): Map<String, Any?>
22
+
23
+ suspend fun restorePurchases(): List<Map<String, Any?>>
24
+
25
+ suspend fun finishTransaction(transactionId: String)
26
+
27
+ fun onTransactionUpdate(callback: (Map<String, Any?>) -> Unit)
28
+
29
+ fun onError(callback: (Map<String, Any?>) -> Unit)
30
+
31
+ /**
32
+ * Apply store-specific configuration options.
33
+ *
34
+ * Called from `ExpoNamiIapModule.configure(...)`. The default
35
+ * implementation is a no-op; store backends override as needed.
36
+ */
37
+ fun configure(options: Map<String, Any?>) {}
38
+ }
39
+
40
+ sealed class ExpoNamiIapException(
41
+ message: String,
42
+ val code: String,
43
+ val nativeCode: String?,
44
+ ) : Exception(message) {
45
+ class UserCancelled(
46
+ nativeCode: String? = null,
47
+ ) : ExpoNamiIapException("User cancelled", "user_cancelled", nativeCode)
48
+
49
+ class Network(
50
+ msg: String,
51
+ nativeCode: String? = null,
52
+ ) : ExpoNamiIapException(msg, "network", nativeCode)
53
+
54
+ class ItemUnavailable(
55
+ msg: String,
56
+ nativeCode: String? = null,
57
+ ) : ExpoNamiIapException(msg, "item_unavailable", nativeCode)
58
+
59
+ class AlreadyOwned(
60
+ nativeCode: String? = null,
61
+ ) : ExpoNamiIapException("Already owned", "already_owned", nativeCode)
62
+
63
+ class NotAllowed(
64
+ msg: String,
65
+ nativeCode: String? = null,
66
+ ) : ExpoNamiIapException(msg, "not_allowed", nativeCode)
67
+
68
+ class Unknown(
69
+ msg: String,
70
+ nativeCode: String? = null,
71
+ ) : ExpoNamiIapException(msg, "unknown", nativeCode)
72
+ }
@@ -0,0 +1,191 @@
1
+ package ml.nami.expo.iap
2
+
3
+ import com.android.billingclient.api.BillingClient.ProductType
4
+ import com.android.billingclient.api.ProductDetails
5
+
6
+ /**
7
+ * Maps a Google Play Billing [ProductDetails] into a [Map] matching the
8
+ * `NamiProductDetails` / `ExpoNamiIapProduct` TypeScript type.
9
+ *
10
+ * Output keys:
11
+ * - `product_ref_id` (String)
12
+ * - `name` (String)
13
+ * - `description` (String)
14
+ * - `product_type` (`"one_time_purchase"` | `"subscription"`)
15
+ * - `offers` (List<Map<String, Any?>>)
16
+ * - `subscription_period` (Map<String, Any>) — only for subscriptions
17
+ */
18
+ object ProductMapper {
19
+ /**
20
+ * Convert a [ProductDetails] to a dictionary suitable for bridging to TypeScript.
21
+ */
22
+ fun toMap(pd: ProductDetails): Map<String, Any?> {
23
+ val result =
24
+ mutableMapOf<String, Any?>(
25
+ "product_ref_id" to pd.productId,
26
+ "name" to pd.name,
27
+ "description" to pd.description,
28
+ "product_type" to mapProductType(pd),
29
+ "offers" to buildOffers(pd),
30
+ )
31
+
32
+ // subscription_period — only for subscriptions
33
+ if (pd.productType == ProductType.SUBS) {
34
+ basePlanPeriod(pd)?.let { result["subscription_period"] = it }
35
+ }
36
+
37
+ return result
38
+ }
39
+
40
+ // ── Product type ────────────────────────────────────────────────────
41
+
42
+ private fun mapProductType(pd: ProductDetails): String =
43
+ when (pd.productType) {
44
+ ProductType.INAPP -> "one_time_purchase"
45
+ ProductType.SUBS -> "subscription"
46
+ else -> "unknown"
47
+ }
48
+
49
+ // ── Offers ──────────────────────────────────────────────────────────
50
+
51
+ private fun buildOffers(pd: ProductDetails): List<Map<String, Any?>> {
52
+ val offers = mutableListOf<Map<String, Any?>>()
53
+
54
+ // INAPP — single default offer from OneTimePurchaseOfferDetails
55
+ pd.oneTimePurchaseOfferDetails?.let { otp ->
56
+ offers.add(
57
+ mapOf(
58
+ "offer_type" to "default",
59
+ "price" to (otp.priceAmountMicros / 1_000_000.0),
60
+ "currency" to otp.priceCurrencyCode,
61
+ ),
62
+ )
63
+ }
64
+
65
+ // SUBS — derive default + promotional offers from SubscriptionOfferDetails
66
+ pd.subscriptionOfferDetails?.let { subOffers ->
67
+ // The base plan is the entry without an offerId
68
+ val basePlan = subOffers.firstOrNull { it.offerId == null }
69
+ val basePricingPhase =
70
+ basePlan
71
+ ?.pricingPhases
72
+ ?.pricingPhaseList
73
+ ?.firstOrNull { it.priceAmountMicros > 0 }
74
+
75
+ // Default offer from base plan
76
+ if (basePricingPhase != null) {
77
+ offers.add(
78
+ mapOf(
79
+ "offer_type" to "default",
80
+ "price" to (basePricingPhase.priceAmountMicros / 1_000_000.0),
81
+ "currency" to basePricingPhase.priceCurrencyCode,
82
+ ),
83
+ )
84
+ }
85
+
86
+ // Promotional / intro / free-trial offers from other subscription offers
87
+ for (subOffer in subOffers) {
88
+ if (subOffer.offerId == null) continue // skip the base plan
89
+ for (phase in subOffer.pricingPhases.pricingPhaseList) {
90
+ if (phase.priceAmountMicros == 0L) {
91
+ // Free trial phase
92
+ offers.add(
93
+ buildTrialOffer(phase, subOffer.offerId),
94
+ )
95
+ } else if (isIntroPhase(phase, basePricingPhase)) {
96
+ // Intro price phase (lower than base price)
97
+ offers.add(
98
+ buildIntroOffer(phase, subOffer.offerId),
99
+ )
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ return offers
106
+ }
107
+
108
+ private fun buildTrialOffer(
109
+ phase: ProductDetails.PricingPhase,
110
+ offerId: String?,
111
+ ): Map<String, Any?> =
112
+ mapOf(
113
+ "offer_type" to "free_trial",
114
+ "offer_ref_id" to offerId,
115
+ "price" to 0.0,
116
+ "currency" to phase.priceCurrencyCode,
117
+ "promo_period" to parsePeriod(phase.billingPeriod),
118
+ )
119
+
120
+ private fun buildIntroOffer(
121
+ phase: ProductDetails.PricingPhase,
122
+ offerId: String?,
123
+ ): Map<String, Any?> =
124
+ mapOf(
125
+ "offer_type" to "intro",
126
+ "offer_ref_id" to offerId,
127
+ "price" to (phase.priceAmountMicros / 1_000_000.0),
128
+ "currency" to phase.priceCurrencyCode,
129
+ "promo_period" to parsePeriod(phase.billingPeriod),
130
+ "promo_price" to
131
+ mapOf(
132
+ "price" to (phase.priceAmountMicros / 1_000_000.0),
133
+ "currency" to phase.priceCurrencyCode,
134
+ ),
135
+ )
136
+
137
+ /**
138
+ * Determine whether a pricing phase is an intro (discounted) phase.
139
+ * An intro phase has a price > 0 but lower than the base plan price.
140
+ */
141
+ private fun isIntroPhase(
142
+ phase: ProductDetails.PricingPhase,
143
+ basePricingPhase: ProductDetails.PricingPhase?,
144
+ ): Boolean {
145
+ if (basePricingPhase == null) return false
146
+ return phase.priceAmountMicros in 1 until basePricingPhase.priceAmountMicros
147
+ }
148
+
149
+ // ── Period parsing ──────────────────────────────────────────────────
150
+
151
+ /**
152
+ * The base plan's billing period, derived from the first non-free pricing
153
+ * phase of the base-plan subscription offer (the entry with offerId == null).
154
+ */
155
+ private fun basePlanPeriod(pd: ProductDetails): Map<String, Any>? {
156
+ val basePlan =
157
+ pd.subscriptionOfferDetails
158
+ ?.firstOrNull { it.offerId == null }
159
+ val phase =
160
+ basePlan
161
+ ?.pricingPhases
162
+ ?.pricingPhaseList
163
+ ?.firstOrNull { it.priceAmountMicros > 0 }
164
+ return phase?.let { parsePeriod(it.billingPeriod) }
165
+ }
166
+
167
+ /**
168
+ * Parse an ISO 8601 duration string from Play Billing into a period dict.
169
+ *
170
+ * Play Billing uses simplified durations: `P1D`, `P1W`, `P1M`, `P3M`, `P1Y`.
171
+ *
172
+ * Returns `{ "type": "month", "count": 1 }` etc.
173
+ */
174
+ internal fun parsePeriod(iso8601: String): Map<String, Any> {
175
+ val match =
176
+ PERIOD_REGEX.find(iso8601)
177
+ ?: return mapOf("type" to "unknown", "count" to 0)
178
+ val count = match.groupValues[1].toIntOrNull() ?: 0
179
+ val unit =
180
+ when (match.groupValues[2]) {
181
+ "D" -> "day"
182
+ "W" -> "week"
183
+ "M" -> "month"
184
+ "Y" -> "year"
185
+ else -> "unknown"
186
+ }
187
+ return mapOf("type" to unit, "count" to count)
188
+ }
189
+
190
+ private val PERIOD_REGEX = Regex("""P(\d+)([DWMY])""")
191
+ }