Skip to content

Commit

Permalink
feat: purchase subscriptions (#4)
Browse files Browse the repository at this point in the history
Tested on `iOS` and `Android`.
  • Loading branch information
hyochan committed Jul 26, 2024
1 parent 45a8852 commit 1bdfaed
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 93 deletions.
6 changes: 5 additions & 1 deletion android/src/main/java/expo/modules/iap/ExpoIapModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,11 @@ class ExpoIapModule :
val billingResult = billingClient.launchBillingFlow(currentActivity, flowParams)

if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
throw Exception("Billing error: ${billingResult.debugMessage}")
promise.reject(
"Billing Error",
billingResult.debugMessage,
null,
)
}

promise.resolve(true)
Expand Down
3 changes: 2 additions & 1 deletion example/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"cSpell.words": [
"Pressable"
"Pressable",
"skus"
]
}
221 changes: 154 additions & 67 deletions example/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import {
endConnection,
getProducts,
getSubscriptions,
initConnection,
isProductAndroid,
isProductIos,
isSubscriptionProductAndroid,
isSubscriptionProductIos,
purchaseErrorListener,
purchaseUpdatedListener,
requestPurchase,
requestSubscription,
} from 'expo-iap';
import {useEffect, useState} from 'react';
import {
Expand All @@ -21,38 +25,68 @@ import {
View,
} from 'react-native';

import {Product, ProductPurchase, PurchaseError} from '../src/ExpoIap.types';
import {
Product,
ProductPurchase,
PurchaseError,
SubscriptionProduct,
} from '../src/ExpoIap.types';
import {RequestSubscriptionAndroidProps} from '../src/types/ExpoIapAndroid.types';

const productSkus = [
'com.cooni.point1000',
'com.cooni.point5000',
'com.cooni.con5000',
];

const productSkus = ['com.cooni.point1000', 'com.cooni.point5000', 'com.cooni.con5000'];
const subscriptionSkus = ['com.cooni.subscription1000'];

const operations = ['initConnection', 'getProducts', 'endConnection'];
const operations = [
'initConnection',
'getProducts',
'getSubscriptions',
'endConnection',
];
type Operation = (typeof operations)[number];

export default function App() {
const [items, setItems] = useState<Product[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const [subscriptions, setSubscriptions] = useState<SubscriptionProduct[]>([]);

const handleOperation = async (operation: Operation) => {
if (operation === 'initConnection') {
console.log('Connected', await initConnection());
return;
}
switch (operation) {
case 'initConnection':
if (await initConnection()) setIsConnected(true);
return;

if (operation === 'endConnection') {
const result = await endConnection();
case 'endConnection':
if (await endConnection()) {
setProducts([]);
setIsConnected(false);
}
break;

if (result) {
setItems([]);
}
}
case 'getProducts':
try {
const products = await getProducts(productSkus);
setProducts(products);
} catch (error) {
console.error(error);
}
break;

if (operation === 'getProducts') {
try {
const products = await getProducts(productSkus);
console.log('items', products);
setItems(products);
} catch (error) {
console.error(error);
}
case 'getSubscriptions':
try {
const subscriptions = await getSubscriptions(subscriptionSkus);
setSubscriptions(subscriptions);
} catch (error) {
console.error(error);
}
break;

default:
console.log('Unknown operation');
}
};

Expand All @@ -65,13 +99,11 @@ export default function App() {
},
);

const purchaseErrorSubs = purchaseErrorListener(
(error: PurchaseError) => {
InteractionManager.runAfterInteractions(() => {
Alert.alert('Purchase error', JSON.stringify(error));
});
},
);
const purchaseErrorSubs = purchaseErrorListener((error: PurchaseError) => {
InteractionManager.runAfterInteractions(() => {
Alert.alert('Purchase error', JSON.stringify(error));
});
});

return () => {
purchaseUpdatedSubs.remove();
Expand All @@ -98,44 +130,99 @@ export default function App() {
</ScrollView>
</View>
<View style={styles.content}>
{items.map((item) => {
if (isProductAndroid(item)) {
return (
<View key={item.title}>
<Text>
{item.title} -{' '}
{item.oneTimePurchaseOfferDetails?.formattedPrice}
</Text>
<Button
title="Buy"
onPress={() => {
requestPurchase({
skus: [item.productId],
});
}}
/>
</View>
);
}

if (isProductIos(item)) {
return (
<View key={item.id}>
<Text>
{item.displayName} - {item.displayPrice}
</Text>
<Button
title="Buy"
onPress={() => {
requestPurchase({
sku: item.id,
});
}}
/>
</View>
);
}
})}
{!isConnected ? (
<Text>Not connected</Text>
) : (
<View style={{gap: 12}}>
<Text style={{fontSize: 20}}>Products</Text>
{products.map((item) => {
if (isProductAndroid(item)) {
return (
<View key={item.title} style={{gap: 12}}>
<Text>
{item.title} -{' '}
{item.oneTimePurchaseOfferDetails?.formattedPrice}
</Text>
<Button
title="Buy"
onPress={() => {
requestPurchase({
skus: [item.productId],
});
}}
/>
</View>
);
}

if (isProductIos(item)) {
return (
<View key={item.id} style={{gap: 12}}>
<Text>
{item.displayName} - {item.displayPrice}
</Text>
<Button
title="Buy"
onPress={() => {
requestPurchase({
sku: item.id,
});
}}
/>
</View>
);
}
})}

<Text style={{fontSize: 20}}>Subscriptions</Text>
{subscriptions.map((item) => {
if (isSubscriptionProductAndroid(item)) {
return item.subscriptionOfferDetails?.map((offer) => (
<View key={offer.offerId} style={{gap: 12}}>
<Text>
{item.title} -{' '}
{offer.pricingPhases.pricingPhaseList
.map((ppl) => ppl.billingPeriod)
.join(',')}
</Text>
<Button
title="Subscribe"
onPress={() => {
requestSubscription({
skus: [item.productId],
...(offer.offerToken && {
subscriptionOffers: [
{
sku: item.productId,
offerToken: offer.offerToken,
},
],
}),
} as RequestSubscriptionAndroidProps);
}}
/>
</View>
));
}

if (isSubscriptionProductIos(item)) {
return (
<View key={item.id} style={{gap: 12}}>
<Text>
{item.displayName} - {item.displayPrice}
</Text>
<Button
title="Subscribe"
onPress={() => {
requestSubscription({sku: item.id});
}}
/>
</View>
);
}
})}
</View>
)}
</View>
</SafeAreaView>
);
Expand Down
2 changes: 2 additions & 0 deletions example/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true

# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false

android.extraMavenRepos=[]
4 changes: 2 additions & 2 deletions example/ios/test.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.dooboolab.test;
PRODUCT_NAME = test;
PRODUCT_NAME = "test";
SWIFT_OBJC_BRIDGING_HEADER = "test/test-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
Expand Down Expand Up @@ -389,7 +389,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.dooboolab.test;
PRODUCT_NAME = test;
PRODUCT_NAME = "test";
SWIFT_OBJC_BRIDGING_HEADER = "test/test-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
Expand Down
28 changes: 11 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ import {
SubscriptionPurchase,
} from './ExpoIap.types';
import ExpoIapModule from './ExpoIapModule';
import {RequestPurchaseAndroidProps} from './types/ExpoIapAndroid.types';
import {RequestPurchaseAndroidProps, RequestSubscriptionAndroidProps} from './types/ExpoIapAndroid.types';
import {
PaymentDiscount,
ProductIos,
RequestPurchaseIosProps,
RequestSubscriptionIosProps,
SubscriptionProductIos,
TransactionSk2,
} from './types/ExpoIapIos.types';
Expand Down Expand Up @@ -305,7 +306,7 @@ export const requestSubscription = (
appAccountToken,
quantity,
withOffer,
} = request;
} = request as RequestSubscriptionIosProps;

if (andDangerouslyFinishTransactionAutomaticallyIOS) {
console.warn(
Expand All @@ -327,27 +328,20 @@ export const requestSubscription = (
return Promise.resolve(purchase);
},
android: async () => {
if (
!('subscriptionOffers' in request) ||
request.subscriptionOffers.length === 0
) {
throw new Error(
'subscriptionOffers are required for Google Play subscriptions',
);
}

console.log('requestSubscription', request);
const {
subscriptionOffers,
purchaseTokenAndroid,
replacementModeAndroid = -1,
skus,
isOfferPersonalized,
obfuscatedAccountIdAndroid,
obfuscatedProfileIdAndroid,
isOfferPersonalized,
} = request;
subscriptionOffers,
replacementModeAndroid,
purchaseTokenAndroid,
} = request as RequestSubscriptionAndroidProps;

return ExpoIapModule.buyItemByType({
type: ProductType.Subscription,
skuArr: subscriptionOffers.map((so) => so.sku),
skuArr: skus.map((so) => so),
purchaseToken: purchaseTokenAndroid,
replacementMode: replacementModeAndroid,
obfuscatedAccountId: obfuscatedAccountIdAndroid,
Expand Down
10 changes: 7 additions & 3 deletions src/modules/android.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {Linking} from 'react-native';
import {Product, PurchaseResult} from '../ExpoIap.types';
import {ProductAndroid, ReceiptAndroid} from '../types/ExpoIapAndroid.types';
import {Product, PurchaseResult, SubscriptionProduct} from '../ExpoIap.types';
import {ProductAndroid, ReceiptAndroid, SubscriptionProductAndroid} from '../types/ExpoIapAndroid.types';
import ExpoIapModule from '../ExpoIapModule';

export function isProductAndroid(product: Product): product is ProductAndroid {
return (product as ProductAndroid).title !== undefined;
return (product as ProductAndroid)?.title !== undefined;
}

export function isSubscriptionProductAndroid(product: SubscriptionProduct): product is SubscriptionProductAndroid {
return (product as SubscriptionProductAndroid)?.title !== undefined;
}

/**
Expand Down
Loading

0 comments on commit 1bdfaed

Please sign in to comment.