Slide 1

Slide 1 text

Flutterにおけるアプリ内課金実装 -Android/iOS 完全なる統一- created by @nacatl - 2023/09/15

Slide 2

Slide 2 text

自己紹介 Introduce ● Name: 中島譲 / Yuzuru Nakashima ● Github: @nacatl ● X(Twitter): @affinity_robots ● Job: Android/Flutter Engineer ● Affiliation: Studyplus Inc. (~2023/06) Connehito Inc.(2023/07~) ● Hobby: Magic: The Gathering 2

Slide 3

Slide 3 text

人の生活に なくてはならないものを つくる ● ママ向けサービス「ママリ」 ● ママになる3人に1人が利用 ● 先輩ママの商品/サービス紹介 「ママリ口コミ大賞」 ● データ可視化のtoBサービス 「家族ノート」 ● ママに寄り添い 日々の育児問題を解決 3

Slide 4

Slide 4 text

ユーザー同士で悩みを相談しあう Q&A機能 月間投稿数130万件、回答率95%、一つ目の回答がくるまで 2-3分(最頻値) Q&Aコミュニティ 生活に役立つ記事を毎日配信。専門家監修の記事も多数 メディア インスタハッシュタグ「 #ママリ」の投稿数が約 1,030万件超で #ママ(投稿数:約570万件)よりも投稿されている SNS ママになる3人に1人が利用中! ママ向けNo.1サービス「ママリ」 ※「ママリ」で 2021年内に出産予定と設定したユーザー数と、厚生労働省発表「人口動態統計」の出生数から算出 ※ 4

Slide 5

Slide 5 text

学ぶ喜びを 全てのひとへ ● 学習管理SNS ● 勉強記録の管理、可視化 ● 学習のモチベ維持、 継続のサポート ● 大学受験生2人に1人が利用 ● Flutterによる開発進行中 5

Slide 6

Slide 6 text

6

Slide 7

Slide 7 text

7 ご協力していただいた方々に感謝を🙇

Slide 8

Slide 8 text

Agenda ● 本セッションについて ● 利用ライブラリについて ● AndroidネイティブAPIとの対応 ● 購入と復元 ● iOS特有の処理について ● まとめ 8

Slide 9

Slide 9 text

Agenda ● 本セッションについて ● 利用ライブラリについて ● AndroidネイティブAPIとの対応 ● 購入と復元 ● iOS特有の処理について ● まとめ 9

Slide 10

Slide 10 text

本セッションについて Flutter/Riverpodについてアンケート 10

Slide 11

Slide 11 text

本セッションについて StudyplusのFlutterアプリ内課金実装から得た知見についてお話しします ● Google Playがメインです、StoreKitは実装面で少し触れる程度 ● Studyplus AndroidではBilling Library v5を利用 ● 定期購読についてフォーカスを当てています 11

Slide 12

Slide 12 text

本セッションについて StudyplusのFlutterアプリ内課金実装から得た知見についてお話しします ● FlutterのアーキテクチャはRiverpod利用のMVP想定です ● Riverpodとは ○ https://pub.dev/packages/riverpod ○ Daggerによるインスタンス配布と Flow/LiveDataによるデータの入出力機能が近い感覚 12

Slide 13

Slide 13 text

本セッションについて Studyplusのサブスクプランと変更時の比例配分モード 13 ベーシックプラン プレミアムプラン アップグレード ダウングレード IMMEDIATE_AND_CHARGE_FULL_PRICE DEFERRED

Slide 14

Slide 14 text

本セッションについて Studyplusアプリ内課金準備フロー 1. 有効アイテムIDを取得 2. ライブラリのinit 3. ストアからアイテム情報取得 4. 課金アイテムリストの表示 14

Slide 15

Slide 15 text

本セッションについて Studyplusアプリ内課金準備フロー 1. 有効アイテムIDを取得 2. ライブラリのinit 3. ストアからアイテム情報取得 4. 課金アイテムリストの表示 15

Slide 16

Slide 16 text

本セッションについて Studyplusアプリ内課金準備フロー 1. 有効アイテムIDを取得 2. ライブラリのinit 3. ストアからアイテム情報取得 4. 課金アイテムリストの表示 16

Slide 17

Slide 17 text

本セッションについて Studyplusアプリ内課金準備フロー 1. 有効アイテムIDを取得 2. ライブラリのinit 3. ストアからアイテム情報取得 4. 課金アイテムリストの表示 17

Slide 18

Slide 18 text

本セッションについて Studyplusアプリ内課金準備フロー 1. 有効アイテムIDを取得 2. ライブラリのinit 3. ストアからアイテム情報取得 4. 課金アイテムリストの表示 18

Slide 19

Slide 19 text

Agenda ● 本セッションについて ● 利用ライブラリについて ● AndroidネイティブAPIとの対応 ● 購入と復元 ● iOS特有の処理について ● まとめ 19

Slide 20

Slide 20 text

利用ライブラリについて ● https://pub.dev/packages/in_app_purchase (以下引用) ● A storefront-independent API for purchases in Flutter apps. This plugin supports in-app purchases (IAP) through an underlying store, which can be the App Store (on iOS and macOS) or Google Play (on Android). ● Features ○ Use this plugin in your Flutter app to: ○ Show in-app products that are available for sale from the underlying store. Products can include consumables, permanent upgrades, and subscriptions. ○ Load in-app products that the user owns. ○ Send the user to the underlying store to purchase products. ○ Present a UI for redeeming subscription offer codes. (iOS 14 only) 20

Slide 21

Slide 21 text

利用ライブラリについて ● flutter.dev公式のアプリ内課金ライブラリ ○ スター数も最も多い ○ 2023/09/15 時点最新 v3.1.10 では BillingLibrary 6.0.1 も対応済み ● 内部的にネイティブ接続のライブラリを内包している ○ in_app_purchase_android ○ in_app_purchase_storekit 21

Slide 22

Slide 22 text

利用ライブラリについて ● インスタンスの取得 /// [InAppPurchase] インスタンス取得 InAppPurchase.instance; 22

Slide 23

Slide 23 text

利用ライブラリについて ● インスタンスの取得 ○ IapPresenter ≒ BillingClientLifecycle相当の処理クラス /// [InAppPurchase]でストア処理を行うPresenter class IapPresenter extends AsyncNotifier { IapPresenter() : super(); final _inAppPurchase = InAppPurchase.instance; @override FutureOr build() async { ~~~~ 23

Slide 24

Slide 24 text

利用ライブラリについて ● インスタンスの取得 ○ IapPresenter ≒ BillingClientLifecycle相当の処理クラス ○ /// [InAppPurchase]でストア処理を行うPresenter class IapPresenter extends AsyncNotifier { IapPresenter() : super(); final _inAppPurchase = InAppPurchase.instance; @override FutureOr build() async { ~~~~ 24

Slide 25

Slide 25 text

利用ライブラリについて ● インスタンスの取得 ○ IapPresenter ≒ BillingClientLifecycle相当の処理クラス ○ /// [InAppPurchase]でストア処理を行うPresenter class IapPresenter extends AsyncNotifier { IapPresenter() : super(); final _inAppPurchase = InAppPurchase.instance; @override FutureOr build() async { ~~~~ 25 後ほど説明します

Slide 26

Slide 26 text

利用ライブラリについて ● ViewからはPresenter経由で操作する ○ ~~Provider はDaggerのようにインスタンスを管理配布する役割 /// [InAppPurchase]でストア処理を行うPresenterのProvider final iapPresenterProvider = AsyncNotifierProvider( IapPresenter.new, ); 26

Slide 27

Slide 27 text

利用ライブラリについて ● ViewからはPresenter経由で操作する ○ ホーム画面の生成時に Presenterを参照、InAppPurchaseのインスタンスが生成される /// ログイン後のHomeScreenで呼び出してインスタンス準備 @override Widget build(BuildContext context, WidgetRef ref) { ~~~~ // IapPresenterの参照(インスタンス生成) ref.watch(iapPresenterProvider); 27

Slide 28

Slide 28 text

Agenda ● 本セッションについて ● 利用ライブラリについて ● AndroidネイティブAPIとの対応 ● 購入と復元 ● iOS特有の処理について ● まとめ 28

Slide 29

Slide 29 text

AndroidネイティブAPIとの対応 ● startConnection(listener: BillingClientStateListener) ● queryProductDetails(params: QueryProductDetailsParams) ● launchBillingFlow(activity: Activity, params: BillingFlowParams) ● PurchasesUpdatedListener { billingResult, purchaseList -> } ● queryPurchases(params: QueryPurchasesParams) ● consumePurchase(params: ConsumeParam) ● acknowledgePurchase(params: AcknowledgePurchaseParam) ● Purchase ● Purchase.isAcknowledged 29

Slide 30

Slide 30 text

AndroidネイティブAPIとの対応 ● startConnection(listener: BillingClientStateListener) ○ 基本的にInAppPurchase.instanceを呼ぶだけで開始される 30 /// [InAppPurchase] インスタンス取得 InAppPurchase.instance;

Slide 31

Slide 31 text

AndroidネイティブAPIとの対応 ● startConnection(listener: BillingClientStateListener) ○ 基本的にInAppPurchase.instanceを呼ぶだけで開始される 31 /// InAppPurchase.getInstance()内部 if (defaultTargetPlatform == TargetPlatform.android) { InAppPurchaseAndroidPlatform.registerPlatform(); } else if (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS) { InAppPurchaseStoreKitPlatform.registerPlatform(); }

Slide 32

Slide 32 text

AndroidネイティブAPIとの対応 ● startConnection(listener: BillingClientStateListener) ○ 基本的にInAppPurchase.instanceを呼ぶだけで開始される 32 /// InAppPurchase.getInstance()内部 /// 深掘っていくと…… BillingClientManagerで見つかる _readyFuture = Future.sync(() async { await client.startConnection( onBillingServiceDisconnected: _connect, ); _isConnecting = false; });

Slide 33

Slide 33 text

AndroidネイティブAPIとの対応 ● startConnection(listener: BillingClientStateListener) ○ IapPresenterのbuild時にBillingClientStateListener相当の処理を行う 33 @override FutureOr build() async { /// BillingClientのconnection処理を待機 final isAvailable = await _inAppPurchase.isAvailable(); if (isAvailable) { /// Stateの初期化、PurchasesUpdatedListener登録 /// iOS特有のSKPaymentTransactionObserver処理 }

Slide 34

Slide 34 text

AndroidネイティブAPIとの対応 ● queryProductDetails(params: QueryProductDetailsParams) ○ Param型ではなくアイテムIDをSet型で渡す 34 /// IDリストをSet型で受け付けてるのだけ気にすればOK final productDetailResponse = await _inAppPurchase.queryProductDetails(productIdSet);

Slide 35

Slide 35 text

AndroidネイティブAPIとの対応 ● queryProductDetails(params: QueryProductDetailsParams) ○ inapp/subs両方取得できる 35 billingClientManager.runWithClient( (BillingClient client) => client.queryProductDetails( productList: identifiers .map((String productId) => ProductWrapper( productId: productId, productType: ProductType.inapp)) .toList(), ), ), billingClientManager.runWithClient( ~~~ productId: productId, productType: ProductType.subs)) ~~~

Slide 36

Slide 36 text

AndroidネイティブAPIとの対応 ● launchBillingFlow(activity: Activity, params: BillingFlowParams) 36 await _inAppPurchase /// .buyConsumable( .buyNonConsumable( purchaseParam: PurchaseParam( productDetails: productDetails, ), );

Slide 37

Slide 37 text

AndroidネイティブAPIとの対応 ● launchBillingFlow(activity: Activity, params: BillingFlowParams) ○ プラン切り替えのコントロールは GooglePlay特有なので、継承クラスを使う 37 /// プラン切り替えの場合のPurchaseParam final purchaseParam = GooglePlayPurchaseParam( productDetails: productDetails, changeSubscriptionParam: ChangeSubscriptionParam( oldPurchaseDetails: pastPurchases, // 切り替え前のpurchase prorationMode: prorationMode, // 比例配分モード ), );

Slide 38

Slide 38 text

AndroidネイティブAPIとの対応 ● PurchasesUpdatedListener { billingResult, purchaseList -> } ○ Streamが準備されているので Providerで配布する 38 /// 購入情報通知を受け取るStreamのProvider final iapPurchaseStreamProvider = StreamProvider( (ref) => InAppPurchase.instance.purchaseStream, );

Slide 39

Slide 39 text

AndroidネイティブAPIとの対応 ● PurchasesUpdatedListener { billingResult, purchaseList -> } ○ 先述したIapPresenterのbuild時に以下を記述 ○ 39 /// Streamをlistenして結果を受け取る。purchaseの結果に応じて処理 ref.listen( iapPurchaseStreamProvider, (_, purchaseListAsync) { // AsyncValue(Riverpodの非同期処理状態クラス) final purchaseList = purchaseListAsync.maybeWhen( data: (list) => list, error: (e, trace) // エラー処理とかは実装に応じて => []; orElse: () => [], // 読み込み中 );

Slide 40

Slide 40 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ 購入済みのアイテムを取得する ○ Studyplusでは復元やプラン切り替えのために利用 40

Slide 41

Slide 41 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ 復元目的ならこれで OK ○ 41 /// 内部的にAndroid分岐内でqueryPurchasesを利用している await _inAppPurchase.restorePurchases();

Slide 42

Slide 42 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ 復元目的ならこれで OK 42 /// 内部的にAndroid分岐内でqueryPurchasesを利用している await _inAppPurchase.restorePurchases(); /// BillingClient @override Future restorePurchases({ String? applicationUserName, }) async { List responses; responses = await Future.wait(>[ billingClientManager.runWithClient( (BillingClient client) => client.queryPurchases(ProductType.inapp), ), billingClientManager.runWithClient( (BillingClient client) => client.queryPurchases(ProductType.subs), ), ]);

Slide 43

Slide 43 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ 復元目的ならこれで OK 43 /// 内部的にAndroid分岐内でqueryPurchasesを利用している await _inAppPurchase.restorePurchases(); /// Storekit @override Future restorePurchases({String? applicationUserName}) async { return _observer .restoreTransactions( queue: _skPaymentQueueWrapper, applicationUserName: applicationUserName) .whenComplete(() => _observer.cleanUpRestoredTransactions()); }

Slide 44

Slide 44 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラン切り替えのために購入済みの PurchaseDetailsが欲しいんだけど……?? 44 /// プラン切り替えの場合のPurchaseParam final purchaseParam = GooglePlayPurchaseParam( productDetails: productDetails, changeSubscriptionParam: ChangeSubscriptionParam( oldPurchaseDetails: pastPurchases, // 切り替え前のpurchase prorationMode: prorationMode, // 比例配分モード ), );

Slide 45

Slide 45 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラン切り替えのために購入済みの PurchaseDetailsが欲しいんだけど……?? ○ 45 /// READMEのサンプルコード final oldPurchaseDetails = ...; final purchaseParam = GooglePlayPurchaseParam( productDetails: productDetails, changeSubscriptionParam: ChangeSubscriptionParam( oldPurchaseDetails: oldPurchaseDetails, prorationMode: ProrationMode.immediateWithTimeProration)); _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);

Slide 46

Slide 46 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラン切り替えのために購入済みの PurchaseDetailsが欲しいんだけど……?? ○ 46 /// READMEのサンプルコード final oldPurchaseDetails = ...; final purchaseParam = GooglePlayPurchaseParam( productDetails: productDetails, changeSubscriptionParam: ChangeSubscriptionParam( oldPurchaseDetails: oldPurchaseDetails, prorationMode: ProrationMode.immediateWithTimeProration)); _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam); ここ!!

Slide 47

Slide 47 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラン切り替えのために購入済みの PurchaseDetailsが欲しいんだけど……?? ○ 47 /// Githubのサンプルコード if (Platform.isAndroid) { // NOTE: If you are making a subscription purchase/upgrade/downgrade, // we recommend you to verify the latest status of you your subscription // by using server side receipt validation and update the UI accordingly. // The subscription purchase status shown inside the app may not be accurate. final GooglePlayPurchaseDetails? oldSubscription = _getOldSubscription(productDetails, purchases);

Slide 48

Slide 48 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラン切り替えのために購入済みの PurchaseDetailsが欲しいんだけど……?? ○ 48 /// Githubのサンプルコード if (Platform.isAndroid) { // NOTE: If you are making a subscription purchase/upgrade/downgrade, // we recommend you to verify the latest status of you your subscription // by using server side receipt validation and update the UI accordingly. // The subscription purchase status shown inside the app may not be accurate. final GooglePlayPurchaseDetails? oldSubscription = _getOldSubscription(productDetails, purchases); 要約: サーバー上で保持してそれを取得すべき

Slide 49

Slide 49 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラン切り替えのために購入済みの PurchaseDetailsが欲しいんだけど……?? ■ ない 49

Slide 50

Slide 50 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラン切り替えのために購入済みの PurchaseDetailsが欲しいんだけど……?? ■ × ない ■ ○ InAppPurchaseのインスタンスから直接は取れない 50

Slide 51

Slide 51 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラン切り替えのために購入済みの PurchaseDetailsが欲しいんだけど……?? ■ × ない ■ ○ InAppPurchaseのインスタンスから直接は取れない ● PlatformAdditionを利用する 51

Slide 52

Slide 52 text

AndroidネイティブAPIとの対応 ● AndroidPlatformAddition ○ queryPurchase ○ consumePurchase ○ isFeatureSupported 52 ● StorekitPlatformAddition ○ presentCodeRedemptionSheet ○ refreshPurchaseVerificationData ○ setDelegate ○ showPriceConsentIfNeeded

Slide 53

Slide 53 text

AndroidネイティブAPIとの対応 ● AndroidPlatformAddition ○ queryPurchase ○ consumePurchase ○ isFeatureSupported 53 ● StorekitPlatformAddition ○ presentCodeRedemptionSheet ○ refreshPurchaseVerificationData ○ setDelegate ○ showPriceConsentIfNeeded

Slide 54

Slide 54 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラットフォーム特有の処理なので PlatformAdditionを利用する 54 /// Google Play上の現在購入中アイテム情報 [GooglePlayPurchaseDetails]を取得保持 final iapGoogleOwnedPurchaseProvider = FutureProvider.autoDispose( (ref) async { if (!Platform.isAndroid) return null; final platformAddition = inAppPurchase.getPlatformAddition() as InAppPurchaseAndroidPlatformAddition; final response = await platformAddition.queryPastPurchases(); return response.pastPurchases.firstOrNull; }, );

Slide 55

Slide 55 text

AndroidネイティブAPIとの対応 ● queryPurchases(params: QueryPurchasesParam) ○ プラットフォーム特有の処理なので PlatformAdditionを利用する ○ を利用してプラットフォーム特有の処理をする 55 /// Google Play上の現在購入中アイテム情報 [GooglePlayPurchaseDetails]を取得保持 final iapGoogleOwnedPurchaseProvider = FutureProvider.autoDispose( (ref) async { if (!Platform.isAndroid) return null; final platformAddition = inAppPurchase.getPlatformAddition() as InAppPurchaseAndroidPlatformAddition; final response = await platformAddition.queryPastPurchases(); return response.pastPurchases.firstOrNull; }, );

Slide 56

Slide 56 text

AndroidネイティブAPIとの対応 ● consumePurchase(params: ConsumeParam) ○ こちらもプラットフォーム特有の処理なので PlatformAdditionを利用する ○ 56 /// Android専用なのでPlatformAddition経由 final platformAddition = inAppPurchase.getPlatformAddition() as InAppPurchaseAndroidPlatformAddition; final result = await platformAddition.consumePurchases( purchase: purchaseDetail, );

Slide 57

Slide 57 text

AndroidネイティブAPIとの対応 ● acknowledgePurchase(params: AcknowledgePurchaseParam) 57 /// 内部的にAndroid分岐内でacknowledgePurchaseを利用している await _inAppPurchase.completePurchase(purchaseDetails);

Slide 58

Slide 58 text

AndroidネイティブAPIとの対応 ● acknowledgePurchase(params: AcknowledgePurchaseParam) 58 /// 内部的にAndroid分岐内でacknowledgePurchaseを利用している await _inAppPurchase.completePurchase(purchaseDetails); return billingClientManager.runWithClient( (BillingClient client) => client.acknowledgePurchase( purchase.verificationData.serverVerificationData, ), );

Slide 59

Slide 59 text

AndroidネイティブAPIとの対応 ● Purchase ○ プラットフォームごとに違うので各種 PurchaseDetailsにキャストして取得するといい ○ /// 共通 class PurchaseDetails { final String? purchaseID; final String productID; final PurchaseVerificationData verificationData; final String? transactionDate; PurchaseStatus status; 59

Slide 60

Slide 60 text

AndroidネイティブAPIとの対応 ● Purchase ○ プラットフォームごとに違うので各種 PurchaseDetailsにキャストして取得するといい ○ /// Google Play class GooglePlayPurchaseDetails extends PurchaseDetails { GooglePlayPurchaseDetails({ super.purchaseID, required super.productID, required super.verificationData, required super.transactionDate, required this.billingClientPurchase, required super.status, }) 60

Slide 61

Slide 61 text

AndroidネイティブAPIとの対応 ● Purchase ○ プラットフォームごとに違うので各種 PurchaseDetailsにキャストして取得するといい ○ /// App Store class AppStorePurchaseDetails extends PurchaseDetails { AppStorePurchaseDetails({ super.purchaseID, required super.productID, required super.verificationData, required super.transactionDate, required this.skPaymentTransaction, required PurchaseStatus status, }) 61

Slide 62

Slide 62 text

AndroidネイティブAPIとの対応 ● Purchase ○ VerificationData(購入検証用データ)の内訳 /// GooglePlayPurchaseDetails.verificationData PurchaseVerificationData( localVerificationData: purchase.originalJson, serverVerificationData: purchase.purchaseToken, source: kIAPSource, // 'google_play'; ), 62

Slide 63

Slide 63 text

AndroidネイティブAPIとの対応 ● Purchase ○ VerificationData(購入検証用データ)の内訳 ○ ○ /// AppStorePurchaseDetails.verificationData PurchaseVerificationData( localVerificationData: base64EncodedReceipt, serverVerificationData: base64EncodedReceipt, source: kIAPSource, // 'app_store'; ), 63

Slide 64

Slide 64 text

AndroidネイティブAPIとの対応 ● Purchase ○ billingClientPurchase にはBilling Libraryで見たことあるパラメータが並ぶ /// GooglePlayPurchaseDetails.billingClientPurchase const PurchaseWrapper({ required this.orderId, required this.packageName, required this.purchaseTime, required this.purchaseToken, required this.signature, required this.products, required this.isAutoRenewing, required this.originalJson, this.developerPayload, required this.isAcknowledged, required this.purchaseState, this.obfuscatedAccountId, this.obfuscatedProfileId, }); 64

Slide 65

Slide 65 text

● Purchase ○ 購入検証用データまとめ ○ /// Google Play final originalJson = googlePurchaseDetails.verificationData.localVerificationData; = googlePurchaseDetails.billingClientPurchase.originalJson; final purchaseToken = googlePurchaseDetails.verificationData.serverVerificationData; = googlePurchaseDetails.billingClientPurchase.purchaseToken; final signature = googlePurchaseDetails.billingClientPurchase.signature; AndroidネイティブAPIとの対応 65

Slide 66

Slide 66 text

● Purchase ○ 購入検証用データまとめ ○ AndroidネイティブAPIとの対応 /// App Store final base64EncodedReceipt = appStorePurchaseDetails.verificationData.localVerificationData; = appStorePurchaseDetails.verificationData.serverVerificationData; 66

Slide 67

Slide 67 text

AndroidネイティブAPIとの対応 ● purchase.isAcknowledged 67 final isAcknowledged = !purchaseDetails.pendingCompletePurchase;

Slide 68

Slide 68 text

AndroidネイティブAPIとの対応 ● purchase.isAcknowledged ○ iOSとかなり意味が違うので、利用時はそこだけ分岐が必要 ○ 68 /// Android: !isAcknowledged 承認処理のされていない購入であること class GooglePlayPurchaseDetails extends PurchaseDetails { pendingCompletePurchase = !billingClientPurchase.isAcknowledged; /// iOS: status != PurchaseStatus.pending /// statusが保留中でないこと class AppStorePurchaseDetails extends PurchaseDetails { pendingCompletePurchase = status != PurchaseStatus.pending;

Slide 69

Slide 69 text

AndroidネイティブAPIとの対応 ● purchase.isAcknowledged ○ 「購入処理中」「ParentalControlなどによる保留中」の場合に pendingとなる 69 /// AppStorePurchaseDetails.status の判定抜粋 PurchaseStatus toPurchaseStatus( SKPaymentTransactionStateWrapper object, SKError? error) { switch (object) { case SKPaymentTransactionStateWrapper.purchasing: case SKPaymentTransactionStateWrapper.deferred: return PurchaseStatus.pending;

Slide 70

Slide 70 text

AndroidネイティブAPIとの対応 ● purchase.isAcknowledged ○ このライブラリでは、 購入処理を始める準備ができているか のbool値と認識するとよさそう 70 /// Android pendingCompletePurchase = !billingClientPurchase.isAcknowledged; /// iOS pendingCompletePurchase = !SKPaymentTransactionStateWrapper.purchasing && !SKPaymentTransactionStateWrapper.deferred;

Slide 71

Slide 71 text

Agenda ● 本セッションについて ● 利用ライブラリについて ● AndroidネイティブAPIとの対応 ● 購入と復元 ● iOS特有の処理について ● まとめ 71

Slide 72

Slide 72 text

購入/復元 ● 購入 ○ サーバーに「課金ができるユーザーか」確認 (プロフィール登録の漏れなど) ○ プラン切り替えのチェック ○ buy(Non)Consumableを呼び出す ○ purchaseStream経由でPurchaseDetailsを受け取る 72

Slide 73

Slide 73 text

購入/復元 ● 復元 ○ サーバーに「課金ができるユーザーか」確認 (プロフィール登録の漏れなど) ○ restorePurchaseを呼び出す ○ purchaseStream経由でPurchaseDetailsを受け取る 73

Slide 74

Slide 74 text

74

Slide 75

Slide 75 text

75 Streamで受け取ること、受け取った後はほぼ同じ

Slide 76

Slide 76 text

76 Streamで受け取ること、受け取った後はほぼ同じ 受け取った後に何をどう判定するか

Slide 77

Slide 77 text

購入/復元 ● 課題は2つ 77

Slide 78

Slide 78 text

購入/復元 ● 課題は2つ 1. 「購入」と「復元」の区別 78

Slide 79

Slide 79 text

購入/復元 ● 課題は2つ 1. 「購入」と「復元」の区別 復元の場合は、すでに一回購入処理をしたことのある購入情報、つまり isAcknowledged == true でも処理したい 79

Slide 80

Slide 80 text

購入/復元 ● PurchaseDetails自身が購入結果なのか復元結果なのかを知っている 80 class PurchaseDetails { /// The status that this [PurchaseDetails] is currently on. PurchaseStatus status; /// Status for a [PurchaseDetails]. enum PurchaseStatus { pending, purchased, error, restored, canceled, }

Slide 81

Slide 81 text

購入/復元 ● 課題は2つ 1. 「購入」と「復元」の区別 => 購入結果の値から判別すれば OK 81

Slide 82

Slide 82 text

購入/復元 ● 課題は2つ 1. 「購入」と「復元」の区別 => 購入結果の値から判別すれば OK 2. 「購入/復元/アップグレード」と「ダウングレード」の区別 82

Slide 83

Slide 83 text

購入/復元 ● 「購入/復元/アップグレード」 ○ IMMEDIATE_AND_CHARGE_FULL_PRICE ○ 即時変更 ○ キャラクターと謝辞表示 ● 「ダウングレード」 ○ DEFERRED ○ 次回更新時にプラン変更が遅延 ○ 簡素にダイアログの表示 83

Slide 84

Slide 84 text

● Androidでの実装では ○ 購入と復元でフローが違ったので購入時だけ判定できればよかった PurchasesUpdatedListener { billingResult, purchaseList -> /** アップグレードはIMMEDIATE、ダウングレードはDEFERRED * DEFERREDの場合は購入が成功した直後に purchaseList が * 「現在有効な product(isAcknowledged = true)」で入ってくる */ if (purchaseList.all { it.isAcknowledged }) { // ダウングレード判定 } else { // アップグレード判定 } 購入/復元 84

Slide 85

Slide 85 text

購入/復元 ● Flutterでの実装では ○ 復元も購入と同様に PurchaseStreamを通ることで、復元時も考慮する必要ができた ○ iOSのことも考えると isAcknowledged( つまり pendingCompletePurchase) による判定では正しく判定できない 85

Slide 86

Slide 86 text

購入/復元 86 ● ここで思い出すInAppPurchaseState /// [InAppPurchase]でストア処理を行うPresenter class IapPresenter extends AsyncNotifier { IapPresenter() : super(); final _inAppPurchase = InAppPurchase.instance; @override FutureOr build() async { ~~~~ 今こそ説明します

Slide 87

Slide 87 text

購入/復元 ● InAppPurchaseState ○ IapPresenterで必要な状態/Stateを保持するデータクラス 87 @freezed class InAppPurchaseState with _$InAppPurchaseState { const factory InAppPurchaseState({ @Default(false) bool isAvailable, @Default(InAppPurchaseFlow.none) InAppPurchaseFlow currentFlow, }) = _InAppPurchaseState; }

Slide 88

Slide 88 text

購入/復元 ● InAppPurchaseState ○ InAppPurchaseで必要な状態を保持するデータクラス ● 88 @freezed class InAppPurchaseState with _$InAppPurchaseState { const factory InAppPurchaseState({ @Default(false) bool isAvailable, @Default(InAppPurchaseFlow.none) InAppPurchaseFlow currentFlow, }) = _InAppPurchaseState; }

Slide 89

Slide 89 text

購入/復元 ● InAppPurchaseFlow ○ 現在の処理フローを示す列挙型を用意 89 enum InAppPurchaseFlow { /// purchase (free -> basic or premium) purchase, /// purchase (basic -> premium) planUpgrade, /// purchase (premium -> basic) planDowngrade, /// restore (free -> basic or premium) restore, /// default (Idling) none; }

Slide 90

Slide 90 text

購入/復元 ● IapPresenterがInAppPurchaseFlowをStateとして所持する 90 /// IapPresenterの復元開始メソッド Future launchRestoreFlow() async { /// 処理開始時に現在のフローを変更 state = InAppPurchaseFlow.restore; // 重要部抜粋 await _inAppPurchase.restorePurchases();

Slide 91

Slide 91 text

購入/復元 ● 処理完了時に「現在の処理が完了した」ことを通知する 91 /// IapPresenterのPurchaseStream受け取り後 /// サーバーへ反映 await ~~; /// Viewへ処理フローが終わったEventを投げる ref.read(iapEventNotifierProvider.notifier).compleat( flow: currentFlow, ); /// Eventを投げた後にリセット state = InAppPurchaseFlow.none; // 重要部抜粋

Slide 92

Slide 92 text

購入/復元 ● IapEventNotifier ○ 処理フローが終わった際に Viewへの反映のために通知する 92 /// [IapPresenter] が各ストアを通して行った処理の結果を流すための [Notifier] class IapEventNotifier extends AutoDisposeNotifier { void compleat({ required InAppPurchaseFlow flow, }) { state = InAppPurchaseEvent.compleat( flow: flow, ); }

Slide 93

Slide 93 text

購入/復元 ● Viewへの反映 93 ref.listen( iapEventNotifierProvider, (_, event) { event.when( compleat: (flow) { switch (flow) { case InAppPurchaseFlow.purchase: /// or restore or planUpgrade /// 購入/復元/アップグレード表示 case InAppPurchaseFlow.planDowngrade: /// ダウングレード表示

Slide 94

Slide 94 text

購入/復元 ● 課題は2つ 1. 「購入」と「復元」の区別 => 購入結果の値から判別すれば OK 2. 「購入/復元/アップグレード」と「ダウングレード」の区別 => 処理フローをPresenterがStateとして保持して完了時に通知 94

Slide 95

Slide 95 text

Agenda ● 本セッションについて ● 利用ライブラリについて ● AndroidネイティブAPIとの対応 ● 購入と復元 ● iOS特有の処理について ● まとめ 95

Slide 96

Slide 96 text

iOS特有の処理について ● 前提として、flutter.devのin_app_purchase_storekitは 現状 StoreKit2 ではなくStoreKit に依存 96

Slide 97

Slide 97 text

iOS特有の処理について ● startConnection成功時の処理 ○ PaymentQueueDelegateの設定/破棄 /// init iOS IAP [SKPaymentQueue.default().add(SKPaymentTransactionObserver instance)] if (Platform.isIOS) { final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition = inAppPurchase.getPlatformAddition(); await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate()); } /// close iOS IAP [SKPaymentQueue.default().remove(SKPaymentTransactionObserver instance)] ref.onDispose( () { if (Platform.isIOS) { final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition = inAppPurchase.getPlatformAddition(); iosPlatformAddition.setDelegate(null); } }, ); 97

Slide 98

Slide 98 text

iOS特有の処理について ● startConnection成功時の処理 ○ StorekitPlatformAdditionが利用されている /// init iOS IAP [SKPaymentQueue.default().add(SKPaymentTransactionObserver instance)] if (Platform.isIOS) { final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition = inAppPurchase.getPlatformAddition(); await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate()); } /// close iOS IAP [SKPaymentQueue.default().remove(SKPaymentTransactionObserver instance)] ref.onDispose( () { if (Platform.isIOS) { final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition = inAppPurchase.getPlatformAddition(); iosPlatformAddition.setDelegate(null); } }, ); 98

Slide 99

Slide 99 text

iOS特有の処理について ● startConnection成功時の処理 ○ 注) サンプルコードの実装ママ /// Example implementation of the /// [`SKPaymentQueueDelegate`] (https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). /// The payment queue delegate can be implemented to provide information needed to complete transactions. class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { /// https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3242935-paymentqueue @override bool shouldContinueTransaction( SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront, ) => true; /// https://developer.apple.com/documentation/storekit/skpaymentqueue/3521327-showpriceconsentifneeded @override bool shouldShowPriceConsent() => false; } 99

Slide 100

Slide 100 text

iOS特有の処理について ● startConnection成功時の処理 ○ 購入トランザクション中に StoreFrontが変更された際にトランザクションを続行するか否か /// Example implementation of the /// [`SKPaymentQueueDelegate`] (https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). /// The payment queue delegate can be implemented to provide information needed to complete transactions. class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { /// https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3242935-paymentqueue @override bool shouldContinueTransaction( SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront, ) => true; /// https://developer.apple.com/documentation/storekit/skpaymentqueue/3521327-showpriceconsentifneeded @override bool shouldShowPriceConsent() => false; } 100

Slide 101

Slide 101 text

iOS特有の処理について ● startConnection成功時の処理 ○ 定期購読の値上げに未応答の場合、価格同意シートを表示するか否か /// Example implementation of the /// [`SKPaymentQueueDelegate`] (https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). /// The payment queue delegate can be implemented to provide information needed to complete transactions. class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { /// https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3242935-paymentqueue @override bool shouldContinueTransaction( SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront, ) => true; /// https://developer.apple.com/documentation/storekit/skpaymentqueue/3521327-showpriceconsentifneeded @override bool shouldShowPriceConsent() => false; } 101

Slide 102

Slide 102 text

iOS特有の処理について ● ReceiptData取得処理 ○ サーバーにreceiptを投げて「無料仕様対象か」を判定してもらうなど 102

Slide 103

Slide 103 text

iOS特有の処理について ● ReceiptData取得処理 ○ StoreKitPlatformAdditionからVerificationDataを取得できる if (!Platform.isIOS) return null; final platformAddition = inAppPurchase.getPlatformAddition() as InAppPurchaseStoreKitPlatformAddition; final verificationData = await platformAddition.refreshPurchaseVerificationData(); final receipt = verificationData?.serverVerificationData; 103

Slide 104

Slide 104 text

iOS特有の処理について ● ReceiptData取得処理 ○ StoreKitPlatformAddition内部 Future refreshPurchaseVerificationData() async { await SKRequestMaker().startRefreshReceiptRequest(); try { final String receipt = await SKReceiptManager.retrieveReceiptData(); return PurchaseVerificationData( localVerificationData: receipt, serverVerificationData: receipt, source: kIAPSource); } catch (e) { print( 'Something is wrong while fetching the receipt, this normally happens when the app is ' 'running on a simulator: $e'); return null; } } 104

Slide 105

Slide 105 text

iOS特有の処理について ● ReceiptData取得処理 ○ リフレッシュした上で Receipt取得している Future refreshPurchaseVerificationData() async { await SKRequestMaker().startRefreshReceiptRequest(); try { final String receipt = await SKReceiptManager.retrieveReceiptData(); return PurchaseVerificationData( localVerificationData: receipt, serverVerificationData: receipt, source: kIAPSource); } catch (e) { print( 'Something is wrong while fetching the receipt, this normally happens when the app is ' 'running on a simulator: $e'); return null; } } 105

Slide 106

Slide 106 text

iOS特有の処理について ● ReceiptData取得処理 ○ nullable回避や例外処理まとめたいなら、 in_app_purchase_storekit直接参照も可能 await SKRequestMaker().startRefreshReceiptRequest(); try { /// 現在ログインしている Appleアカウントの購入情報情報 (Receipt)をAppStoreに問い合わせ final receipt = await SKReceiptManager.retrieveReceiptData(); /// Receiptを使ってサーバーに必要な情報を問い合わせる return await ~~~; }catch (e) { /// 例外処理 } 106

Slide 107

Slide 107 text

Agenda ● 本セッションについて ● 利用ライブラリについて ● AndroidネイティブAPIとの対応 ● 購入と復元 ● iOS特有の処理について ● まとめ 107

Slide 108

Slide 108 text

● https://pub.dev/packages/in_app_purchase を利用すれば、 アプリ内課金も含めてFlutterによる完全なる統一は目指せる ○ もちろんサービスの事情次第で「目指すか」自体は検討すべき ● Flutter公式のライブラリなので安心感はある ● Billing Libraryの追従は問題なさそう ● StoreKit2はこれからに期待 まとめ 108

Slide 109

Slide 109 text

まとめ ● APIがInAppPurchase本体になかったら各Platform用のクラスを調べよう ○ [Android/StoreKit] PlatformAddition ○ [GooglePlay/AppStore] PurchaseDetails ● それでもなかったら依存先の内部を直接見てみよう ○ in_app_purchase_android ○ in_app_purchase_storekit 109

Slide 110

Slide 110 text

© DroidKaigi 110 Thank You For Listening