@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.
- package/README.md +121 -0
- package/android/build.gradle +46 -0
- package/android/gradle.properties +1 -0
- package/android/libs/.gitkeep +0 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
- package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
- package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
- package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
- package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
- package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
- package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
- package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
- package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
- package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
- package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
- package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
- package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
- package/dist/index.cjs +56 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.mjs +45 -0
- package/expo-module.config.json +9 -0
- package/ios/Errors.swift +178 -0
- package/ios/ExpoNamiIap.podspec +14 -0
- package/ios/ExpoNamiIapModule.swift +81 -0
- package/ios/ProductMapper.swift +130 -0
- package/ios/PromotionalOfferSigner.swift +78 -0
- package/ios/StoreKit2Helper.swift +252 -0
- package/ios/Tests/ErrorsTests.swift +189 -0
- package/ios/Tests/ProductMapperTests.swift +165 -0
- package/ios/Tests/Resources/Products.storekit +210 -0
- package/ios/Tests/StoreKit2HelperTests.swift +360 -0
- package/ios/TransactionObserver.swift +54 -0
- package/package.json +64 -0
- package/src/ExpoNamiIapModule.kepler.ts +200 -0
- package/src/ExpoNamiIapModule.ts +18 -0
- package/src/index.ts +63 -0
- package/src/types.ts +46 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package ml.nami.expo.iap
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertNotNull
|
|
4
|
+
import org.junit.Assert.assertTrue
|
|
5
|
+
import org.junit.Assert.fail
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Amazon-flavor smoke tests for [ExpoNamiIapModule] backend resolution.
|
|
10
|
+
*
|
|
11
|
+
* Under the `amazon` product flavor, `PlayBillingBackend` is absent from
|
|
12
|
+
* the classpath while `AmazonIapBackend` is present. The
|
|
13
|
+
* [ExpoNamiIapModule.buildBackend] method tries Google first, catches
|
|
14
|
+
* [ClassNotFoundException], and falls back to Amazon. These tests verify
|
|
15
|
+
* that fall-through path.
|
|
16
|
+
*/
|
|
17
|
+
class ExpoNamiIapModuleAmazonFlavorTest {
|
|
18
|
+
/**
|
|
19
|
+
* Google's [PlayBillingBackend] must NOT be on the classpath in the
|
|
20
|
+
* `amazon` flavor — confirming the first `Class.forName` in
|
|
21
|
+
* [buildBackend] will throw.
|
|
22
|
+
*/
|
|
23
|
+
@Test
|
|
24
|
+
fun `PlayBillingBackend is absent from the amazon flavor classpath`() {
|
|
25
|
+
try {
|
|
26
|
+
Class.forName("ml.nami.expo.iap.google.PlayBillingBackend")
|
|
27
|
+
fail("PlayBillingBackend should NOT be on the amazon-flavor classpath")
|
|
28
|
+
} catch (_: ClassNotFoundException) {
|
|
29
|
+
// Expected — Google class is not compiled into the amazon flavor
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Amazon's [AmazonIapBackend] must be discoverable on the classpath
|
|
35
|
+
* and instantiable via its no-arg constructor — the same path
|
|
36
|
+
* [buildBackend] takes after the Google fallback.
|
|
37
|
+
*/
|
|
38
|
+
@Test
|
|
39
|
+
fun `AmazonIapBackend is on the amazon flavor classpath`() {
|
|
40
|
+
val cls = Class.forName("ml.nami.expo.iap.amazon.AmazonIapBackend")
|
|
41
|
+
assertNotNull("AmazonIapBackend class should be on the classpath", cls)
|
|
42
|
+
|
|
43
|
+
val instance = cls.getDeclaredConstructor().newInstance()
|
|
44
|
+
assertTrue(
|
|
45
|
+
"Resolved backend should be an IapBackend",
|
|
46
|
+
instance is IapBackend,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Replicate the full [buildBackend] resolution logic: try Google
|
|
52
|
+
* first (fails with [ClassNotFoundException]), fall back to Amazon
|
|
53
|
+
* (succeeds), and verify the result is an [IapBackend].
|
|
54
|
+
*/
|
|
55
|
+
@Test
|
|
56
|
+
fun `buildBackend resolution falls through to AmazonIapBackend`() {
|
|
57
|
+
val cls =
|
|
58
|
+
try {
|
|
59
|
+
Class.forName("ml.nami.expo.iap.google.PlayBillingBackend")
|
|
60
|
+
} catch (_: ClassNotFoundException) {
|
|
61
|
+
Class.forName("ml.nami.expo.iap.amazon.AmazonIapBackend")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
val backend = cls.getDeclaredConstructor().newInstance() as IapBackend
|
|
65
|
+
assertNotNull("Backend instance should be non-null", backend)
|
|
66
|
+
assertTrue(
|
|
67
|
+
"Backend should be AmazonIapBackend",
|
|
68
|
+
backend::class.java.simpleName == "AmazonIapBackend",
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var expoModulesCore = require('expo-modules-core');
|
|
4
|
+
|
|
5
|
+
const NativeModule = expoModulesCore.requireNativeModule('ExpoNamiIapModule');
|
|
6
|
+
const emitter = new expoModulesCore.EventEmitter(NativeModule);
|
|
7
|
+
|
|
8
|
+
function configure(options) {
|
|
9
|
+
NativeModule.configure(options);
|
|
10
|
+
}
|
|
11
|
+
function initConnection() {
|
|
12
|
+
return NativeModule.initConnection();
|
|
13
|
+
}
|
|
14
|
+
function endConnection() {
|
|
15
|
+
return NativeModule.endConnection();
|
|
16
|
+
}
|
|
17
|
+
function getProducts(skuIds) {
|
|
18
|
+
return NativeModule.getProducts(skuIds);
|
|
19
|
+
}
|
|
20
|
+
function checkEligibility(skuIds) {
|
|
21
|
+
return NativeModule.checkEligibility(skuIds);
|
|
22
|
+
}
|
|
23
|
+
function purchase(options) {
|
|
24
|
+
return NativeModule.purchase(options);
|
|
25
|
+
}
|
|
26
|
+
function restorePurchases() {
|
|
27
|
+
return NativeModule.restorePurchases();
|
|
28
|
+
}
|
|
29
|
+
function finishTransaction(transactionId) {
|
|
30
|
+
return NativeModule.finishTransaction(transactionId);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Subscribe to real-time transaction updates from the native layer.
|
|
34
|
+
* Returns a subscription object with a `remove()` method to unsubscribe.
|
|
35
|
+
*/
|
|
36
|
+
function addTransactionListener(cb) {
|
|
37
|
+
return emitter.addListener('onTransactionUpdate', cb);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Subscribe to error events from the native layer.
|
|
41
|
+
* Returns a subscription object with a `remove()` method to unsubscribe.
|
|
42
|
+
*/
|
|
43
|
+
function addErrorListener(cb) {
|
|
44
|
+
return emitter.addListener('onError', cb);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
exports.addErrorListener = addErrorListener;
|
|
48
|
+
exports.addTransactionListener = addTransactionListener;
|
|
49
|
+
exports.checkEligibility = checkEligibility;
|
|
50
|
+
exports.configure = configure;
|
|
51
|
+
exports.endConnection = endConnection;
|
|
52
|
+
exports.finishTransaction = finishTransaction;
|
|
53
|
+
exports.getProducts = getProducts;
|
|
54
|
+
exports.initConnection = initConnection;
|
|
55
|
+
exports.purchase = purchase;
|
|
56
|
+
exports.restorePurchases = restorePurchases;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { NamiProductDetails } from '@namiml/sdk-core';
|
|
2
|
+
|
|
3
|
+
interface ExpoNamiIapProduct extends NamiProductDetails {
|
|
4
|
+
platform_raw?: unknown;
|
|
5
|
+
}
|
|
6
|
+
interface ExpoNamiIapTransaction {
|
|
7
|
+
skuId: string;
|
|
8
|
+
transactionId: string;
|
|
9
|
+
originalTransactionId?: string;
|
|
10
|
+
receipt?: string;
|
|
11
|
+
purchaseToken?: string;
|
|
12
|
+
purchaseTime?: number;
|
|
13
|
+
isAcknowledged?: boolean;
|
|
14
|
+
}
|
|
15
|
+
type ExpoNamiIapErrorCode = 'user_cancelled' | 'network' | 'item_unavailable' | 'already_owned' | 'not_allowed' | 'unknown';
|
|
16
|
+
interface ExpoNamiIapError {
|
|
17
|
+
code: ExpoNamiIapErrorCode;
|
|
18
|
+
message: string;
|
|
19
|
+
nativeCode?: string;
|
|
20
|
+
}
|
|
21
|
+
interface EligibilityResult {
|
|
22
|
+
skuId: string;
|
|
23
|
+
introEligible: boolean;
|
|
24
|
+
promoEligible: boolean;
|
|
25
|
+
}
|
|
26
|
+
interface ConfigureOptions {
|
|
27
|
+
amazon?: {
|
|
28
|
+
autoRegisterListener?: boolean;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
interface PurchaseOptions {
|
|
32
|
+
skuId: string;
|
|
33
|
+
offerId?: string;
|
|
34
|
+
offerSignature?: string;
|
|
35
|
+
appAccountToken?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
declare function configure(options: ConfigureOptions): void;
|
|
39
|
+
declare function initConnection(): Promise<void>;
|
|
40
|
+
declare function endConnection(): Promise<void>;
|
|
41
|
+
declare function getProducts(skuIds: string[]): Promise<ExpoNamiIapProduct[]>;
|
|
42
|
+
declare function checkEligibility(skuIds: string[]): Promise<EligibilityResult[]>;
|
|
43
|
+
declare function purchase(options: PurchaseOptions): Promise<ExpoNamiIapTransaction>;
|
|
44
|
+
declare function restorePurchases(): Promise<ExpoNamiIapTransaction[]>;
|
|
45
|
+
declare function finishTransaction(transactionId: string): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to real-time transaction updates from the native layer.
|
|
48
|
+
* Returns a subscription object with a `remove()` method to unsubscribe.
|
|
49
|
+
*/
|
|
50
|
+
declare function addTransactionListener(cb: (tx: ExpoNamiIapTransaction) => void): {
|
|
51
|
+
remove: () => void;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Subscribe to error events from the native layer.
|
|
55
|
+
* Returns a subscription object with a `remove()` method to unsubscribe.
|
|
56
|
+
*/
|
|
57
|
+
declare function addErrorListener(cb: (err: ExpoNamiIapError) => void): {
|
|
58
|
+
remove: () => void;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { addErrorListener, addTransactionListener, checkEligibility, configure, endConnection, finishTransaction, getProducts, initConnection, purchase, restorePurchases };
|
|
62
|
+
export type { ConfigureOptions, EligibilityResult, ExpoNamiIapError, ExpoNamiIapErrorCode, ExpoNamiIapProduct, ExpoNamiIapTransaction, PurchaseOptions };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { requireNativeModule, EventEmitter } from 'expo-modules-core';
|
|
2
|
+
|
|
3
|
+
const NativeModule = requireNativeModule('ExpoNamiIapModule');
|
|
4
|
+
const emitter = new EventEmitter(NativeModule);
|
|
5
|
+
|
|
6
|
+
function configure(options) {
|
|
7
|
+
NativeModule.configure(options);
|
|
8
|
+
}
|
|
9
|
+
function initConnection() {
|
|
10
|
+
return NativeModule.initConnection();
|
|
11
|
+
}
|
|
12
|
+
function endConnection() {
|
|
13
|
+
return NativeModule.endConnection();
|
|
14
|
+
}
|
|
15
|
+
function getProducts(skuIds) {
|
|
16
|
+
return NativeModule.getProducts(skuIds);
|
|
17
|
+
}
|
|
18
|
+
function checkEligibility(skuIds) {
|
|
19
|
+
return NativeModule.checkEligibility(skuIds);
|
|
20
|
+
}
|
|
21
|
+
function purchase(options) {
|
|
22
|
+
return NativeModule.purchase(options);
|
|
23
|
+
}
|
|
24
|
+
function restorePurchases() {
|
|
25
|
+
return NativeModule.restorePurchases();
|
|
26
|
+
}
|
|
27
|
+
function finishTransaction(transactionId) {
|
|
28
|
+
return NativeModule.finishTransaction(transactionId);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Subscribe to real-time transaction updates from the native layer.
|
|
32
|
+
* Returns a subscription object with a `remove()` method to unsubscribe.
|
|
33
|
+
*/
|
|
34
|
+
function addTransactionListener(cb) {
|
|
35
|
+
return emitter.addListener('onTransactionUpdate', cb);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Subscribe to error events from the native layer.
|
|
39
|
+
* Returns a subscription object with a `remove()` method to unsubscribe.
|
|
40
|
+
*/
|
|
41
|
+
function addErrorListener(cb) {
|
|
42
|
+
return emitter.addListener('onError', cb);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { addErrorListener, addTransactionListener, checkEligibility, configure, endConnection, finishTransaction, getProducts, initConnection, purchase, restorePurchases };
|
package/ios/Errors.swift
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Errors.swift
|
|
3
|
+
// ExpoNamiIap
|
|
4
|
+
//
|
|
5
|
+
// Copyright (c) 2024 Nami ML Inc. All rights reserved.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import StoreKit
|
|
9
|
+
|
|
10
|
+
// MARK: - ExpoNamiIapError
|
|
11
|
+
|
|
12
|
+
/// Typed error enum mirroring the Android `ExpoNamiIapException` sealed class.
|
|
13
|
+
/// Each case carries an optional `nativeCode` for debugging and a `message`.
|
|
14
|
+
@available(iOS 15.0, tvOS 15.0, *)
|
|
15
|
+
enum ExpoNamiIapError: Error {
|
|
16
|
+
case userCancelled(nativeCode: String? = nil)
|
|
17
|
+
case network(message: String, nativeCode: String? = nil)
|
|
18
|
+
case itemUnavailable(message: String, nativeCode: String? = nil)
|
|
19
|
+
case alreadyOwned(nativeCode: String? = nil)
|
|
20
|
+
case notAllowed(message: String, nativeCode: String? = nil)
|
|
21
|
+
case unknown(message: String, nativeCode: String? = nil)
|
|
22
|
+
|
|
23
|
+
/// The normalized error code sent to JavaScript.
|
|
24
|
+
var code: String {
|
|
25
|
+
switch self {
|
|
26
|
+
case .userCancelled: return "user_cancelled"
|
|
27
|
+
case .network: return "network"
|
|
28
|
+
case .itemUnavailable: return "item_unavailable"
|
|
29
|
+
case .alreadyOwned: return "already_owned"
|
|
30
|
+
case .notAllowed: return "not_allowed"
|
|
31
|
+
case .unknown: return "unknown"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Human-readable message.
|
|
36
|
+
var message: String {
|
|
37
|
+
switch self {
|
|
38
|
+
case .userCancelled: return "User cancelled"
|
|
39
|
+
case .network(let message, _): return message
|
|
40
|
+
case .itemUnavailable(let message, _): return message
|
|
41
|
+
case .alreadyOwned: return "Already owned"
|
|
42
|
+
case .notAllowed(let message, _): return message
|
|
43
|
+
case .unknown(let message, _): return message
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Raw platform error code for debugging (nil when not available).
|
|
48
|
+
var nativeCode: String? {
|
|
49
|
+
switch self {
|
|
50
|
+
case .userCancelled(let nc): return nc
|
|
51
|
+
case .network(_, let nc): return nc
|
|
52
|
+
case .itemUnavailable(_, let nc): return nc
|
|
53
|
+
case .alreadyOwned(let nc): return nc
|
|
54
|
+
case .notAllowed(_, let nc): return nc
|
|
55
|
+
case .unknown(_, let nc): return nc
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Convert to a dictionary matching the `ExpoNamiIapError` TypeScript shape:
|
|
60
|
+
/// `{ code: string, message: string, nativeCode?: string }`.
|
|
61
|
+
func toDict() -> [String: Any] {
|
|
62
|
+
var dict: [String: Any] = [
|
|
63
|
+
"code": code,
|
|
64
|
+
"message": message,
|
|
65
|
+
]
|
|
66
|
+
if let nativeCode {
|
|
67
|
+
dict["nativeCode"] = nativeCode
|
|
68
|
+
}
|
|
69
|
+
return dict
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// MARK: - Errors (namespace)
|
|
74
|
+
|
|
75
|
+
/// Static helpers that translate StoreKit errors and `Product.PurchaseResult` into
|
|
76
|
+
/// `ExpoNamiIapError` values.
|
|
77
|
+
@available(iOS 15.0, tvOS 15.0, *)
|
|
78
|
+
enum Errors {
|
|
79
|
+
|
|
80
|
+
// MARK: - From generic Error
|
|
81
|
+
|
|
82
|
+
/// Map any `Error` to `ExpoNamiIapError`.
|
|
83
|
+
///
|
|
84
|
+
/// - If the error is already an `ExpoNamiIapError`, it passes through unchanged.
|
|
85
|
+
/// - `StoreKitError` cases are pattern-matched to the normalized codes.
|
|
86
|
+
/// - Anything else falls through to `.unknown`.
|
|
87
|
+
static func toExpoNamiIapError(_ error: Error) -> ExpoNamiIapError {
|
|
88
|
+
// Pass-through if already our type.
|
|
89
|
+
if let iapError = error as? ExpoNamiIapError {
|
|
90
|
+
return iapError
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// StoreKit 2 errors.
|
|
94
|
+
if let skError = error as? StoreKitError {
|
|
95
|
+
return fromStoreKitError(skError)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fallback — any unknown Error.
|
|
99
|
+
return .unknown(
|
|
100
|
+
message: error.localizedDescription,
|
|
101
|
+
nativeCode: String(describing: error)
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MARK: - From Product.PurchaseResult
|
|
106
|
+
|
|
107
|
+
/// Translate a `Product.PurchaseResult` into an error, or `nil` if the result is `.success`.
|
|
108
|
+
///
|
|
109
|
+
/// - `.userCancelled` → `ExpoNamiIapError.userCancelled`
|
|
110
|
+
/// - `.pending` → `ExpoNamiIapError.unknown` (transaction pending approval)
|
|
111
|
+
/// - `.success` → `nil`
|
|
112
|
+
/// - `@unknown default` → `ExpoNamiIapError.unknown`
|
|
113
|
+
static func fromPurchaseResult(_ result: Product.PurchaseResult) -> ExpoNamiIapError? {
|
|
114
|
+
switch result {
|
|
115
|
+
case .success:
|
|
116
|
+
return nil
|
|
117
|
+
case .userCancelled:
|
|
118
|
+
return .userCancelled()
|
|
119
|
+
case .pending:
|
|
120
|
+
return .unknown(message: "Transaction pending approval")
|
|
121
|
+
@unknown default:
|
|
122
|
+
return .unknown(message: "Unrecognized purchase result")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// MARK: - item_unavailable helper
|
|
127
|
+
|
|
128
|
+
/// Convenience factory for the `item_unavailable` case, used by `StoreKit2Helper.getProducts`
|
|
129
|
+
/// when a product fetch returns empty results.
|
|
130
|
+
static func itemUnavailable(_ message: String) -> ExpoNamiIapError {
|
|
131
|
+
.itemUnavailable(message: message)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// MARK: - Private
|
|
135
|
+
|
|
136
|
+
private static func fromStoreKitError(_ error: StoreKitError) -> ExpoNamiIapError {
|
|
137
|
+
switch error {
|
|
138
|
+
case .userCancelled:
|
|
139
|
+
return .userCancelled(nativeCode: "StoreKitError.userCancelled")
|
|
140
|
+
|
|
141
|
+
case .networkError(let urlError):
|
|
142
|
+
return .network(
|
|
143
|
+
message: urlError.localizedDescription,
|
|
144
|
+
nativeCode: "StoreKitError.networkError(\(urlError.code.rawValue))"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
case .notAvailableInStorefront:
|
|
148
|
+
return .notAllowed(
|
|
149
|
+
message: "Not available in storefront",
|
|
150
|
+
nativeCode: "StoreKitError.notAvailableInStorefront"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
case .notEntitled:
|
|
154
|
+
return .notAllowed(
|
|
155
|
+
message: "Not entitled",
|
|
156
|
+
nativeCode: "StoreKitError.notEntitled"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
case .systemError(let underlying):
|
|
160
|
+
return .unknown(
|
|
161
|
+
message: underlying.localizedDescription,
|
|
162
|
+
nativeCode: "StoreKitError.systemError(\(String(describing: underlying)))"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
case .unknown:
|
|
166
|
+
return .unknown(
|
|
167
|
+
message: "Unknown StoreKit error",
|
|
168
|
+
nativeCode: "StoreKitError.unknown"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@unknown default:
|
|
172
|
+
return .unknown(
|
|
173
|
+
message: "Unrecognized StoreKit error",
|
|
174
|
+
nativeCode: String(describing: error)
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'ExpoNamiIap'
|
|
3
|
+
s.version = '3.4.0'
|
|
4
|
+
s.summary = 'Nami Expo IAP module — StoreKit 2'
|
|
5
|
+
s.description = 'Native IAP primitives for Nami Expo SDK'
|
|
6
|
+
s.author = 'Nami ML Inc.'
|
|
7
|
+
s.homepage = 'https://www.nami.ml'
|
|
8
|
+
s.platforms = { :ios => '15.0', :tvos => '15.0' }
|
|
9
|
+
s.source = { git: '' }
|
|
10
|
+
s.static_framework = true
|
|
11
|
+
s.swift_version = '5.9'
|
|
12
|
+
s.dependency 'ExpoModulesCore'
|
|
13
|
+
s.source_files = "**/*.{h,m,swift}"
|
|
14
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import StoreKit
|
|
3
|
+
|
|
4
|
+
public class ExpoNamiIapModule: Module {
|
|
5
|
+
public func definition() -> ModuleDefinition {
|
|
6
|
+
Name("ExpoNamiIapModule")
|
|
7
|
+
|
|
8
|
+
Events("onTransactionUpdate", "onError")
|
|
9
|
+
|
|
10
|
+
OnCreate {
|
|
11
|
+
if #available(iOS 15.0, tvOS 15.0, *) {
|
|
12
|
+
Task { [weak self] in
|
|
13
|
+
await StoreKit2Helper.shared.initConnection { dict in
|
|
14
|
+
self?.sendEvent("onTransactionUpdate", dict)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
OnDestroy {
|
|
21
|
+
if #available(iOS 15.0, tvOS 15.0, *) {
|
|
22
|
+
Task {
|
|
23
|
+
await StoreKit2Helper.shared.endConnection()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Function("configure") { (_: [String: Any]) -> Void in
|
|
29
|
+
// No-op on iOS. This function exists for the Android Amazon IAP mode
|
|
30
|
+
// toggle. iOS StoreKit 2 requires no runtime configuration.
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
AsyncFunction("initConnection") {
|
|
34
|
+
if #available(iOS 15.0, tvOS 15.0, *) {
|
|
35
|
+
await StoreKit2Helper.shared.initConnection { [weak self] dict in
|
|
36
|
+
self?.sendEvent("onTransactionUpdate", dict)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
AsyncFunction("endConnection") {
|
|
42
|
+
if #available(iOS 15.0, tvOS 15.0, *) {
|
|
43
|
+
await StoreKit2Helper.shared.endConnection()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
AsyncFunction("getProducts") { (skuIds: [String]) -> [[String: Any]] in
|
|
48
|
+
guard #available(iOS 15.0, tvOS 15.0, *) else { return [] }
|
|
49
|
+
return try await StoreKit2Helper.shared.getProducts(skuIds)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
AsyncFunction("checkEligibility") { (skuIds: [String]) -> [[String: Any]] in
|
|
53
|
+
guard #available(iOS 15.0, tvOS 15.0, *) else { return [] }
|
|
54
|
+
return try await StoreKit2Helper.shared.checkEligibility(skuIds)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
AsyncFunction("purchase") { (options: [String: Any]) -> [String: Any] in
|
|
58
|
+
guard #available(iOS 15.0, tvOS 15.0, *) else { return [:] }
|
|
59
|
+
let skuId = options["skuId"] as? String ?? ""
|
|
60
|
+
let offerId = options["offerId"] as? String
|
|
61
|
+
let offerSignature = options["offerSignature"] as? String
|
|
62
|
+
let appAccountToken = options["appAccountToken"] as? String
|
|
63
|
+
return try await StoreKit2Helper.shared.purchase(
|
|
64
|
+
skuId: skuId,
|
|
65
|
+
offerId: offerId,
|
|
66
|
+
offerSignature: offerSignature,
|
|
67
|
+
appAccountToken: appAccountToken
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
AsyncFunction("restorePurchases") { () -> [[String: Any]] in
|
|
72
|
+
guard #available(iOS 15.0, tvOS 15.0, *) else { return [] }
|
|
73
|
+
return try await StoreKit2Helper.shared.restorePurchases()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
AsyncFunction("finishTransaction") { (transactionId: String) -> Void in
|
|
77
|
+
guard #available(iOS 15.0, tvOS 15.0, *) else { return }
|
|
78
|
+
try await StoreKit2Helper.shared.finishTransaction(transactionId: transactionId)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ProductMapper.swift
|
|
3
|
+
// ExpoNamiIap
|
|
4
|
+
//
|
|
5
|
+
// Copyright (c) 2024 Nami ML Inc. All rights reserved.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import StoreKit
|
|
9
|
+
|
|
10
|
+
/// Maps a StoreKit `Product` into a dictionary matching the `ExpoNamiIapProduct` TypeScript type.
|
|
11
|
+
///
|
|
12
|
+
/// The output dictionary conforms to the `NamiProductDetails` shape defined in
|
|
13
|
+
/// `sdk/core/src/types/externals/product.ts` and extended by `ExpoNamiIapProduct` in
|
|
14
|
+
/// `sdk/expo/modules/expo-nami-iap/src/types.ts`.
|
|
15
|
+
@available(iOS 15.0, tvOS 15.0, *)
|
|
16
|
+
struct ProductMapper {
|
|
17
|
+
|
|
18
|
+
/// Convert a StoreKit `Product` to a dictionary suitable for bridging to TypeScript.
|
|
19
|
+
///
|
|
20
|
+
/// Keys emitted:
|
|
21
|
+
/// - `product_ref_id` (String)
|
|
22
|
+
/// - `name` (String)
|
|
23
|
+
/// - `description` (String)
|
|
24
|
+
/// - `product_type` ("one_time_purchase" | "subscription")
|
|
25
|
+
/// - `offers` ([[String: Any]])
|
|
26
|
+
/// - `subscription_period` ([String: Any]) — only present for subscriptions
|
|
27
|
+
static func toDict(_ product: Product) async -> [String: Any] {
|
|
28
|
+
var result: [String: Any] = [
|
|
29
|
+
"product_ref_id": product.id,
|
|
30
|
+
"name": product.displayName,
|
|
31
|
+
"description": product.description,
|
|
32
|
+
"product_type": mapProductType(product),
|
|
33
|
+
"offers": await buildOffers(product),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
if let sub = product.subscription {
|
|
37
|
+
result["subscription_period"] = periodDict(sub.subscriptionPeriod)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MARK: - Product Type
|
|
44
|
+
|
|
45
|
+
private static func mapProductType(_ product: Product) -> String {
|
|
46
|
+
switch product.type {
|
|
47
|
+
case .consumable, .nonConsumable:
|
|
48
|
+
return "one_time_purchase"
|
|
49
|
+
case .autoRenewable, .nonRenewable:
|
|
50
|
+
return "subscription"
|
|
51
|
+
default:
|
|
52
|
+
return "unknown"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// MARK: - Offers
|
|
57
|
+
|
|
58
|
+
private static func buildOffers(_ product: Product) async -> [[String: Any]] {
|
|
59
|
+
let currency = currencyCode(for: product)
|
|
60
|
+
|
|
61
|
+
// Default offer (base price)
|
|
62
|
+
var offers: [[String: Any]] = [[
|
|
63
|
+
"offer_type": "default",
|
|
64
|
+
"price": NSDecimalNumber(decimal: product.price).doubleValue,
|
|
65
|
+
"currency": currency,
|
|
66
|
+
]]
|
|
67
|
+
|
|
68
|
+
// Intro offer (if any)
|
|
69
|
+
if let sub = product.subscription, let intro = sub.introductoryOffer {
|
|
70
|
+
offers.append(introOfferDict(intro, currency: currency))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Promotional offers
|
|
74
|
+
if let sub = product.subscription {
|
|
75
|
+
for promo in sub.promotionalOffers {
|
|
76
|
+
offers.append(promoOfferDict(promo, currency: currency))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return offers
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: - Period
|
|
84
|
+
|
|
85
|
+
static func periodDict(_ period: Product.SubscriptionPeriod) -> [String: Any] {
|
|
86
|
+
let type: String = switch period.unit {
|
|
87
|
+
case .day: "day"
|
|
88
|
+
case .week: "week"
|
|
89
|
+
case .month: "month"
|
|
90
|
+
case .year: "year"
|
|
91
|
+
@unknown default: "unknown"
|
|
92
|
+
}
|
|
93
|
+
return ["type": type, "count": period.value]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// MARK: - Intro Offer
|
|
97
|
+
|
|
98
|
+
private static func introOfferDict(
|
|
99
|
+
_ offer: Product.SubscriptionOffer,
|
|
100
|
+
currency: String
|
|
101
|
+
) -> [String: Any] {
|
|
102
|
+
[
|
|
103
|
+
"offer_type": offer.paymentMode == .freeTrial ? "free_trial" : "intro",
|
|
104
|
+
"price": NSDecimalNumber(decimal: offer.price).doubleValue,
|
|
105
|
+
"currency": currency,
|
|
106
|
+
"promo_period": periodDict(offer.period),
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MARK: - Promo Offer
|
|
111
|
+
|
|
112
|
+
private static func promoOfferDict(
|
|
113
|
+
_ offer: Product.SubscriptionOffer,
|
|
114
|
+
currency: String
|
|
115
|
+
) -> [String: Any] {
|
|
116
|
+
[
|
|
117
|
+
"offer_type": "promo",
|
|
118
|
+
"offer_ref_id": offer.id ?? NSNull(),
|
|
119
|
+
"price": NSDecimalNumber(decimal: offer.price).doubleValue,
|
|
120
|
+
"currency": currency,
|
|
121
|
+
"promo_period": periodDict(offer.period),
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// MARK: - Currency
|
|
126
|
+
|
|
127
|
+
private static func currencyCode(for product: Product) -> String {
|
|
128
|
+
product.priceFormatStyle.locale.currency?.identifier ?? "USD"
|
|
129
|
+
}
|
|
130
|
+
}
|