アプリ内課金に使用しているパッケージ
https://pub.dev/packages/in_app_purchase
実装例
Skimieでは、課金のメソッドをNotifierProviderで管理しています。
アプリ内課金を実装するためには、Google Play Console とApp Store Connect(Apple)の設定が必要です。
それぞれのプラットフォームの設定は、
Google :
https://developer.android.com/google/play/billing/getting-ready?hl=ja
https://www.revenuecat.com/docs/android-products
Apple:
https://www.revenuecat.com/docs/ios-products
アプリ内購入が利用可能な状態なのか確認する
購入品の製品IDのSetを用意する。
それぞれのストアに問い合わせて製品情報を取得する
上記で取得した製品情報を使って購入処理を行う
購入処理進行を監視して、購入が完了した後にバックエンドでレシートの検証を行う
検証が成功した場合、ジェムをユーザーに付与する(課金の対価を付与する)
返ってきた検証結果に応じて、通知を行う
購入のトランザクションを完了させる
NotifierProviderで持つ状態と上記の順番に沿って説明を進めます。
全体のコードを確認するには、/skimie/lib/services/app_purchase/app_purchase_provider.dart
を参照してください。
Stateとしては、下記の状態を定義しました。
@freezed
class AppPurchaseState with _$AppPurchaseState {
const AppPurchaseState._();
const factory AppPurchaseState({
// 課金利用の可否
@Default(false) bool isAvailable,
// バックエンドから取得した課金商品情報(productIdや購入品Image)
Items? purchaseItems,
// AppleやGoogleのプラットフォームから取得した課金商品の情報
@Default([]) List<ProductDetails> products,
// 課金処理の状態
@Default(false) bool purchasePending,
}) = _AppPurchaseState;
ProductDetails? getProductDetail(Item item) {
final productId = item.code;
return products.firstWhereOrNull((e) => e.id == productId);
}
}
purchasePending 以外は、全てAPIから取得した値を定義しています。
getProductDetailsのメソッドは、OpenAPIで定義されたItemの型の中にある
製品IDに該当するproductDetailsをリストの中から取得するメソッドです。
単回購入処理のときに使います。
// アプリ内購入が利用できるかを確認
final isAvailable = await _inAppPurchase.isAvailable();
// アプリ内購入が利用できない場合
if (!isAvailable) {
Log.e('アプリ内課金が利用できません。');
state = state.copyWith(
isAvailable: isAvailable,
);
return;
}
ストアにアクセスできない、デバイスがGoogleにログインしていないなど、
利用不可の場合は、この先の処理には進むことはないです。
// 課金商品の情報を取得
final purchaseItems = await _purchaseService.getProductIds();
final productIds = purchaseItems.items.map((p0) => p0.code).toSet();
バックエンドから、購入品のItemsのデータを取得し、製品IDのSetを作成します。
// 課金処理に使用する商品一覧を取得
final response = await _inAppPurchase
.queryProductDetails({...productIds, 'android.test.purchased'});
このAPIは、1. で作成した製品IDに該当する購入品の情報を Google Play Console や App Store Connect から List<ProductDetails> という型で取得することができます。
ProductDetailsからは、購入品のid や title、description、price などの情報が取得できます。
この処理はUI側で行われる処理です。
// アプリ内購入品のリスト
List<Widget> purchaseItemList = [];
final purchaseItems = ref.watch(appPurchaseServiceProvider
.select((s) => s.purchaseItems?.items.toList())) ??
[];
final isAvailable =
ref.watch(appPurchaseServiceProvider.select((s) => s.isAvailable));
for (final item in purchaseItems) {
final product =
ref.watch(appPurchaseServiceProvider).getProductDetail(item);
if (product == null) continue;
purchaseItemList.add(
ActionBtn(
label: "",
disabled: !isAvailable,
onPressed: () async {
await ref.read(appPurchaseServiceProvider.notifier).buyProduct(
context,
product: product,
);
},
color: Colors.amber,
width: (width - marginValue * 2 - 80) / 3,
height: (width - marginValue * 2 - 80) / 3,
image: ProductImage(
item: item,
displayCount: product.title.getNumInTarget,
),
),
);
}
appPurchaseServiceProviderのStateから、purchaseItemsを取得し、for in で回します。
各item に該当するProductDetailsを取得して、notifierのbuyProductに渡します。
AppPurchaseServiceクラス内の単回購入のメソッド、
// 単回購入
Future<void> buyProduct(
BuildContext context, {
required ProductDetails product,
}) async {
_context = context;
state = state.copyWith(purchasePending: true);
final purchaseParam = PurchaseParam(productDetails: product);
await _inAppPurchase.buyConsumable(
purchaseParam: purchaseParam,
autoConsume: _kAutoConsume,
);
}
各プラットフォームのアプリ内購入が実行されます。
※ autoConsume について、デフォルトがtrueになっており、
消耗型の購入は、購入と同時にトランザクションが閉じられます。
/// 購入の処理を監視する
Future<void> listenPurchaseUpdated() async {
final Stream<List<PurchaseDetails>> purchaseUpdated =
_inAppPurchase.purchaseStream;
_subs = purchaseUpdated.listen(
(List<PurchaseDetails> purchaseDetailsList) {
// 購入などが行われた場合の処理
_listenToPurchaseUpdated(purchaseDetailsList);
},
onError: (Object error) {
Log.e('Error: ${error.toString()}');
errorNotification(msg: _l10n.errorPurchase);
},
);
}
Streamにて、購入が行われるとpurchaseDetailsList が自動で返される処理になります。
返されたpurchaseDetailsListを_listenToPurchaseUpdatedに渡して、検証する流れになります。
// 購入処理が行われた場合にそのトランザクションを処理する
void _listenToPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList,
) {
purchaseDetailsList.forEach(
(PurchaseDetails purchaseDetails) async {
if (purchaseDetails.status == PurchaseStatus.pending) {
state = state.copyWith(purchasePending: true);
} else {
// 購入処理がエラーの場合
if (purchaseDetails.status == PurchaseStatus.error) {
Log.e('Error: ${purchaseDetails.error}');
errorNotification(msg: _l10n.errorPurchase);
}
// 購入がキャンセルされた場合
if (purchaseDetails.status == PurchaseStatus.canceled) {
successNotification(msg: _l10n.canceledPurchase);
}
// 購入処理が完了、または復元された場合
if (purchaseDetails.status == PurchaseStatus.purchased ||
purchaseDetails.status == PurchaseStatus.restored) {
// レシートの検証をする
final receipt =
purchaseDetails.verificationData.localVerificationData;
final valid = await _purchaseService.postPurchaseReceipt(
receipt: base64Encode(utf8.encode(json.encode(receipt))),
itemMasterCode: purchaseDetails.productID,
);
if (valid) {
// レシートの検証が成功時の処理
successNotification();
// 購入完了の処理
if (purchaseDetails.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchaseDetails);
}
} else {
// レシートの検証が失敗時の処理
errorNotification(msg: _l10n.errorPurchase);
}
state = state.copyWith(purchasePending: false);
}
}
},
);
}
受け取った、purchaseDetailsListは、forEachで回され、個別の状態に応じて処理をする。
purchaseDetails.status == PurchaseStatus.purchased
購入済みの状態であれば、
// レシートの検証をする
final receipt = purchaseDetails.verificationData.localVerificationData;
レシートを取得することができるので、バックエンドに渡して、レシート検証をしてもらう。
バックエンドの処理。
レシート検証から返ってきた値(valid)によって、成功、失敗の通知を行う。
// 購入完了の処理
if (purchaseDetails.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchaseDetails);
}
トランザクションを完了していないpurchaseDetailsは、完了の処理を行う。
https://techblog.booklista.co.jp/entry/2023/09/29/130633
こちらの記事で紹介されている方法で実装。
// 未完了のレシートを検出して再検証する
Future<void> reCheckPendingTransaction() async {
// 未完了のレシートを格納するリスト
final List<PurchaseDetails> pendingReceiptList = [];
// iOSの場合
if (Platform.isIOS) {
final paymentQueueWrapper = SKPaymentQueueWrapper();
final transactions = await paymentQueueWrapper.transactions();
for (final transaction in transactions) {
// TODO:transactionから未完了のPurchaseDetailsを取得する
}
}
// Androidの場合
if (Platform.isAndroid) {
final androidAddition = _inAppPurchase
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
// 過去の購入履歴を取得
final response = await androidAddition.queryPastPurchases();
for (final purchaseDetails in response.pastPurchases) {
if (purchaseDetails.pendingCompletePurchase) {
pendingReceiptList.add(purchaseDetails);
}
}
}
// 未完了の購入トランザクションがあったときにはレシートを再検証する
if (pendingReceiptList.isNotEmpty) {
pendingReceiptList.forEach(
(PurchaseDetails purchaseDetails) async {
// 購入処理が完了、または復元された場合
if (purchaseDetails.status == PurchaseStatus.purchased ||
purchaseDetails.status == PurchaseStatus.restored) {
// レシートの検証をする
final receipt =
purchaseDetails.verificationData.localVerificationData;
final valid = await _purchaseService.postPurchaseReceipt(
receipt: base64Encode(utf8.encode(json.encode(receipt))),
itemMasterCode: purchaseDetails.productID,
);
if (valid) {
// 購入完了の処理
if (purchaseDetails.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchaseDetails);
}
}
}
},
);
}
}
リチェックの処理で参考になるレポジトリ: