Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Flutterにおけるアプリ内課金実装 -Android/iOS完全なる統一 -

nacatl
September 15, 2023

Flutterにおけるアプリ内課金実装 -Android/iOS完全なる統一 -

This slide is made for the DroidKaigi 2023.
https://2023.droidkaigi.jp/

date: 09/15/2023
room: Arctic Fox

nacatl

September 15, 2023
Tweet

More Decks by nacatl

Other Decks in Programming

Transcript

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

    View Slide

  2. 自己紹介
    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

    View Slide

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

    View Slide

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

    4

    View Slide

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

    View Slide

  6. 6

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  20. 利用ライブラリについて
    ● 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  24. 利用ライブラリについて
    ● インスタンスの取得
    ○ IapPresenter ≒ BillingClientLifecycle相当の処理クラス

    /// [InAppPurchase]でストア処理を行うPresenter
    class IapPresenter extends AsyncNotifier {
    IapPresenter() : super();
    final _inAppPurchase = InAppPurchase.instance;
    @override
    FutureOr build() async {
    ~~~~
    24

    View Slide

  25. 利用ライブラリについて
    ● インスタンスの取得
    ○ IapPresenter ≒ BillingClientLifecycle相当の処理クラス

    /// [InAppPurchase]でストア処理を行うPresenter
    class IapPresenter extends AsyncNotifier {
    IapPresenter() : super();
    final _inAppPurchase = InAppPurchase.instance;
    @override
    FutureOr build() async {
    ~~~~
    25
    後ほど説明します

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  29. 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

    View Slide

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

    View Slide

  31. 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();
    }

    View Slide

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

    View Slide

  33. 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処理
    }

    View Slide

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

    View Slide

  35. 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))
    ~~~

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  39. 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: () => [], // 読み込み中
    );

    View Slide

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

    View Slide

  41. AndroidネイティブAPIとの対応
    ● queryPurchases(params: QueryPurchasesParam)
    ○ 復元目的ならこれで OK

    41
    /// 内部的にAndroid分岐内でqueryPurchasesを利用している
    await _inAppPurchase.restorePurchases();

    View Slide

  42. 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),
    ),
    ]);

    View Slide

  43. 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());
    }

    View Slide

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

    View Slide

  45. 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);

    View Slide

  46. 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);
    ここ!!

    View Slide

  47. 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);

    View Slide

  48. 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);
    要約: サーバー上で保持してそれを取得すべき

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  54. 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;
    },
    );

    View Slide

  55. 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;
    },
    );

    View Slide

  56. AndroidネイティブAPIとの対応
    ● consumePurchase(params: ConsumeParam)
    ○ こちらもプラットフォーム特有の処理なので PlatformAdditionを利用する

    56
    /// Android専用なのでPlatformAddition経由
    final platformAddition = inAppPurchase.getPlatformAddition()
    as InAppPurchaseAndroidPlatformAddition;
    final result = await platformAddition.consumePurchases(
    purchase: purchaseDetail,
    );

    View Slide

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

    View Slide

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

    View Slide

  59. AndroidネイティブAPIとの対応
    ● Purchase
    ○ プラットフォームごとに違うので各種 PurchaseDetailsにキャストして取得するといい

    /// 共通
    class PurchaseDetails {
    final String? purchaseID;
    final String productID;
    final PurchaseVerificationData verificationData;
    final String? transactionDate;
    PurchaseStatus status;
    59

    View Slide

  60. 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

    View Slide

  61. 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

    View Slide

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

    View Slide

  63. AndroidネイティブAPIとの対応
    ● Purchase
    ○ VerificationData(購入検証用データ)の内訳


    /// AppStorePurchaseDetails.verificationData
    PurchaseVerificationData(
    localVerificationData: base64EncodedReceipt,
    serverVerificationData: base64EncodedReceipt,
    source: kIAPSource, // 'app_store';
    ),
    63

    View Slide

  64. 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

    View Slide

  65. ● 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

    View Slide

  66. ● Purchase
    ○ 購入検証用データまとめ

    AndroidネイティブAPIとの対応
    /// App Store
    final base64EncodedReceipt
    = appStorePurchaseDetails.verificationData.localVerificationData;
    = appStorePurchaseDetails.verificationData.serverVerificationData;
    66

    View Slide

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

    View Slide

  68. 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;

    View Slide

  69. 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;

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  74. 74

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  80. 購入/復元
    ● 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,
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  88. 購入/復元
    ● InAppPurchaseState
    ○ InAppPurchaseで必要な状態を保持するデータクラス

    88
    @freezed
    class InAppPurchaseState with _$InAppPurchaseState {
    const factory InAppPurchaseState({
    @Default(false) bool isAvailable,
    @Default(InAppPurchaseFlow.none) InAppPurchaseFlow currentFlow,
    }) = _InAppPurchaseState;
    }

    View Slide

  89. 購入/復元
    ● 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;
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  97. 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

    View Slide

  98. 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

    View Slide

  99. 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

    View Slide

  100. 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

    View Slide

  101. 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

    View Slide

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

    View Slide

  103. 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

    View Slide

  104. 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

    View Slide

  105. 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

    View Slide

  106. 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  110. © DroidKaigi
    110
    Thank You For Listening

    View Slide