Flutter(Android) 定期購入詳細なテスト

前提

キャンセルしても有効期限までは使える

Integrate the Google Play Billing Library into your app

払った分のお金を日割りで返すわけじゃないので、正しいと言える。

日割りで返す場合はどうすればいいのか?今回の要件にはないからとりあえずは考えない。

価格がいきなり変更になったらどうするのか?アイテムが増えたら?

価格変更でpurchaseStreamが動いてる様子はなし

価格を変更するときにplay consoleで表示される

f:id:ta_watanabe:20210805142746p:plain

細かいステータスの変更はpub/subで通知されるようです

https://developer.android.com/google/play/billing/rtdn-reference

既存システムとの連携での懸念

今回の要件で、既存システムの会員はそのシステムのID, passを使ってログインできるというのがあるのですが、 既存のシステムで使ってるメールアドレスでAndroidにログインしたくない人もいそう。。。

verifyPurchaseが実行される前にhandlePlayStoreServerEventが実行されることがある

初回登録時は必ずそうなります。

予想ですが、購入ボタンでbuyNonConsumable()するときに、 handlePlayStoreServerEventに通知が行き、purchaseStreamが動き出してverifyが実行されているような気がします。

で、handlePlayStoreServerEventはverifyした直後にまた通知(pub/sub)がいくので問題ないようです。

また、

play-billing-samples/TvMainActivity.kt at f9ae2d55c3699474e26ca0185a5ff38afb9df153 · android/play-billing-samples · GitHub

Google Play Billing Library をアプリに統合する  |  Google Play の課金システム

この辺りの実装を見ても、listenerにverifyするための処理を登録している(or する)ようなので、 同じ結果になりそう。

ストアからの再度定期購入

定期購入を販売する  |  Google Play の課金システム  |  Android Developers

「アプリ外での購入と見なされる」のでかなり厄介

この場合は、アプリを開かないと、verifyができない(= purchaseStreamが動かない)のでfirestoreのdocumentは作られません。 それでも、その間ユーザには定期購入が続きます。

ただ、ユーザは明示的に再度定期購入ボタンを押しているので、 問題なさそうですが、これを人に説明するのが厄介そう。

そして、本当はこの場合は、 https://developer.android.com/google/play/billing/integrate?hl=ja#fetch で書かれている通り、queryPurchasesAsync()を実行するみたいですが、 v4から追加されたメソッドになるようで、

https://developer.android.com/google/play/billing/release-notes?hl=ja#4-0

でもflutterのpluginはv3

plugins/build.gradle at master · flutter/plugins · GitHub

ということで使えませんが、purchaseStream(裏はPurchasesUpdatedListener?) でも自分が検証した感じ(今回の要件内)だと、問題なく同期は取れてるみたいです。

そもそも有料コンテンツを実際にアプリ上で使えるかどうかの判定を、 firestoreで行う場合はそこまで気にする必要がないかもしれません。
この場合は、functionsでusersの様なcollectionを更新して最新のsubscriptionの状態を記録しておくというような方法になると思います。

InAppPurchase.purchaseStreamってそもそも裏側はどうなってるの?

ソースを見たところ、StreamControllerに適宜addしている様に見えて、

インスタンス化するとき
plugins/in_app_purchase_android_platform.dart at 1a4cee78a1fd2eb9eb7377239399678115de437a · flutter/plugins · GitHub

restorePurchases()した時
plugins/in_app_purchase_android_platform.dart at 1a4cee78a1fd2eb9eb7377239399678115de437a · flutter/plugins · GitHub

の二箇所しか見つけられない。

また、restorePurchases()は内部ではqueryPurchases()を実行している様子 https://github.com/flutter/plugins/blob/1a4cee78a1fd2eb9eb7377239399678115de437a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart#L184

_inAppPurchase.restorePurchases()するボタンをつけてみたけど、 解約してから押しても変化なし(定期購読中のまま)。 単純にアプリを起動しなおした方がいい。

(その他)アプリで定期購入してすぐアプリを閉じるとplay storeでエラーが表示される

次の更新までにアプリを開かないと期限切れになる。この辺りもお問い合わせのポイントとしてはそこそこありそう。 この時、google playの購入履歴は「払い戻し済み」になる。

Flutter stream周りをもう一度ちゃんと理解する

2秒ごとに無限に数字が出力し続ける

import 'dart:async';

void main() {
  // 最後にtake()を追加すると指定した回数で止まる
  var counterStream =
    Stream<int>.periodic(const Duration(seconds: 2), (x) => x);
  counterStream.forEach(print);
}

上記とほぼ同等のコードがawait forで書ける

import 'dart:async';

Future<void> main() async {
  var counterStream =
    Stream<int>.periodic(const Duration(seconds: 2), (x) => x);
  await for(int n in counterStream){
    print(n);
  }
}

https://dart.dev/guides/libraries/library-tour#stream

じゃあ2つのstreamがある場合は

import 'dart:async';

void main() {
  var counterStream =
    Stream<int>.periodic(const Duration(seconds: 2), (x) => x*2);
  counterStream.forEach(print);

    var counterStream2 =
    Stream<int>.periodic(const Duration(seconds: 2), (x) => x*2-1);
  counterStream2.forEach(print);
}

2つのストリームが非同期的に出力される

上記をawait forで書き換えても同じ結果にはならない。 counterStreamがendless streamsなのでいつまで経っても二つ目のストリームが実行されない

import 'dart:async';

Future<void> main() async {
  var counterStream =
    Stream<int>.periodic(const Duration(seconds: 2), (x) => x*2);
  await for(int n in counterStream) {
    print(n);
  } 

    var counterStream2 =
    Stream<int>.periodic(const Duration(seconds: 2), (x) => x*2-1);
  await for(int n in counterStream2) {
    print(n);
  } 
}

yieldを理解する

import 'dart:async';

void main() {
  func1().listen((e)=>print(e));
  // 2
  // 4
  // 6
}

Stream<int> func1() async* {
  // *をつけると別のgenerator functionに委譲できる
  // 再帰処理はこれで書くとパフォーマンスが良くなるらしい
  yield* func2();
}

Stream<int> func2() async* {
  yield 2;
  yield 4;
  yield 6;
}

https://dart.dev/guides/language/language-tour#generators

StreamSubscription

この辺からが本題

in_app_purchaseで登場するので使い方を押さえたい
https://pub.dev/packages/in_app_purchase#listening-to-purchase-updates

いろいろテストしてもonDoneの部分は通らないが、どういう場面実行されるのだろうか。

https://github.com/flutter/plugins/blob/c1c46531c77f7c7007cfe31071f34a4b1fbbdf51/packages/in_app_purchase/in_app_purchase/lib/in_app_purchase.dart#L53

This stream will never close as long as the app is active.

と書いてあるけども。onDoneのリファレンスにも

onDone method - StreamSubscription class - dart:async library - Dart API

The handleDone function is called when the stream closes.

と書いてある。

StreamSubscription自体のリファレンスは下記
StreamSubscription class - dart:async library - Dart API

リファレンスを読むとpause, resume, cancelなどができる。cancelしたものはresumeできないそうです。

firestoreでサンプル作ってみました。

import 'dart:async';

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late StreamSubscription<DocumentSnapshot<Map<String, dynamic>>> _subscription;
  Map<String, dynamic> _public = {};

  @override
  void initState() {
    final firestore = FirebaseFirestore.instance;
    _subscription = firestore
        .collection('publics')
        .doc('763fznZQsyE0DmiUzeMZ')
        .snapshots()
        .listen((event) {
      print('on data.');
      setState(() {
        _public = event.data() ?? {};
      });
    }, onDone: () => print('on done.'));
    // firestoreだとdoneが実行されることはなさそう
    // onDoneが実行された後の再復活みたいなことはあるのだろうか?

    super.initState();
  }

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'description: ${_public['description']}',
              style: Theme.of(context).textTheme.headline4,
            ),
            ElevatedButton(
                onPressed: _subscription.cancel,
                child: Text('subscription cancel')),
            ElevatedButton(
                onPressed: _subscription.pause,
                child: Text('subscription pause')),
            ElevatedButton(
                onPressed: _subscription.resume,
                child: Text('subscription resume')),
          ],
        ),
      ),
    );
  }
}

cancelもしくはpauseするとconsole.firebaseから変更しても画面には反映されない。 pauseした場合はresumeすると最新の情報が同期される。

flutter DartPad埋め込んでみた

https://api.flutter.dev/flutter/widgets/Dismissible-class.html

スワイプでListTileを削除する上のサンプルにアイコンを追加したもの。iframeを使えば簡単にできる。

それと

Sharing Guide · dart-lang/dart-pad Wiki · GitHub

を読むとgist IDをdartpadのURLに指定すると自動で読み込んでくれることがわかる。

Closureとは?kotlin, swiftエンジニアとflutter導入することの難しい点

始まり

メンバーの一人がこんなflutterのソースを書きました。

import 'package:flutter/material.dart';

// ツッコミどころ。
// 引数も戻り値もないからVoidCallbackでいいと思うし、
// グローバルに宣言するほどでもない
// それよりもこれを利用している箇所のメンバ名をわかりやすくすればいいと思う
typedef RegisterCallback = void Function();
typedef FinishCallback = void Function();

class RegisterWidget extends StatelessWidget {
  final RegisterCallback callback;

  const RegisterWidget({Key? key, required this.callback}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      child: Text(
        "登録する",
      ),
      onPressed: callback,
    );
  }
}

class FinishWidget extends StatelessWidget {
  final FinishCallback callback;

  const FinishWidget({Key? key, required this.callback}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      child: Text(
        "完了する",
      ),
      onPressed: callback,
    );
  }
}

それでそのメンバーは普段はswiftを書いてるのですが、 typedef RegisterCallback = void Function();の部分をクロージャと呼んでいました。

RegisterWidgetを初期化するときの引数にクロージャを入れるのは確かだと思いますが。。。

そこで、swiftに詳しくなかったので調べてみました。

swiftのリファレンス

https://docs.swift.org/swift-book/LanguageGuide/Closures.html

クロージャならボディ部分がないとダメっすね。 冷静に考えればそんなに難しいことでもありませんでした。

swiftにtypedefと同等の機能あるのか?

typealiasが似ているみたいですが、functionには使えないみたいです。

swift - How to create typealias of a function type which refers to a particular function - Stack Overflow

https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#grammar_typealias-declaration

余談

flutter導入すると、kotlin使い、swift使い、typescript使いなんかがプロジェクトに集められると思いますが、 共通認識を持つのが少し苦労します。
dartのこの部分のソースはkotlinではこうです。みたいな会話をswift使いの人に言っても通じません。

逆に、それぞれの言語でのやり方をdartにも持ち込もうとしてしまいがちです。

それぞれのエンジニアがまずはdartの勉強から始めてプロジェクトに参加できればいいですが、 そうもいかないことも時々あると思います。

kotlin、swift両方の文法に詳しい人がFlutterのリードエンジニアで参加できたらいいんでしょうね。

Assertion failed: Duplicate bundled template New Kotlin Property Initializer.kt

java.lang.Throwable: Assertion failed: Duplicate bundled template New Kotlin Property Initializer.kt [jar:file:/Applications/Android%20Studio%20Preview.app/Contents/plugins/Kotlin/lib/kotlin-idea.jar!/fileTemplates/code/New Kotlin Property Initializer.kt.ft, jar:file:/Users/takayuki/Library/Application%20Support/Google/AndroidStudio2020.3/plugins/Kotlin/lib/kotlin-idea.jar!/fileTemplates/code/New Kotlin Property Initializer.kt.ft]
    at com.intellij.openapi.diagnostic.Logger.assertTrue(Logger.java:201)
    at com.intellij.ide.fileTemplates.impl.FTManager.createAndStoreBundledTemplate(FTManager.java:210)
    at com.intellij.ide.fileTemplates.impl.FTManager.setDefaultTemplates(FTManager.java:199)
    at com.intellij.ide.fileTemplates.impl.FileTemplatesLoader.loadConfiguration(FileTemplatesLoader.java:170)
    at com.intellij.ide.fileTemplates.impl.FileTemplatesLoader.lambda$new$0(FileTemplatesLoader.java:64)
    at com.intellij.openapi.util.ClearableLazyValue$2.compute(ClearableLazyValue.java:26)
    at com.intellij.openapi.util.ClearableLazyValue.getValue(ClearableLazyValue.java:39)
    at com.intellij.openapi.util.AtomicClearableLazyValue.getValue(AtomicClearableLazyValue.java:9)
    at com.intellij.ide.fileTemplates.impl.FileTemplatesLoader.getAllManagers(FileTemplatesLoader.java:118)
    at com.intellij.ide.fileTemplates.impl.FileTemplateSettings.getState(FileTemplateSettings.java:43)
    at com.intellij.ide.fileTemplates.impl.FileTemplateSettings.getState(FileTemplateSettings.java:20)
    at com.intellij.configurationStore.ComponentStoreImpl.commitComponent(ComponentStoreImpl.kt:326)
    at com.intellij.configurationStore.ComponentStoreImpl.commitComponents$intellij_platform_configurationStore_impl(ComponentStoreImpl.kt:233)
    at com.intellij.configurationStore.ComponentStoreWithExtraComponents.commitComponents$intellij_platform_configurationStore_impl(ComponentStoreWithExtraComponents.kt:96)
    at com.intellij.configurationStore.ComponentStoreImpl$commitComponentsOnEdt$$inlined$withEdtContext$intellij_platform_configurationStore_impl$1.invokeSuspend(ComponentStoreImpl.kt:709)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56)
    at com.intellij.openapi.application.constraints.BaseConstrainedExecution$Companion$scheduleWithinConstraints$1.invoke(BaseConstrainedExecution.kt:68)
    at com.intellij.openapi.application.constraints.BaseConstrainedExecution$Companion.scheduleWithinConstraints(BaseConstrainedExecution.kt:71)
    at com.intellij.openapi.application.constraints.BaseConstrainedExecution.scheduleWithinConstraints(BaseConstrainedExecution.kt:38)
    at com.intellij.openapi.application.impl.BaseExpirableExecutorMixinImpl.access$scheduleWithinConstraints$s1153900543(BaseExpirableExecutorMixinImpl.kt:12)
    at com.intellij.openapi.application.impl.BaseExpirableExecutorMixinImpl$scheduleWithinConstraints$$inlined$Runnable$1.run(Runnable.kt:19)
    at com.intellij.openapi.application.TransactionGuardImpl.runWithWritingAllowed(TransactionGuardImpl.java:216)
    at com.intellij.openapi.application.TransactionGuardImpl.access$200(TransactionGuardImpl.java:24)
    at com.intellij.openapi.application.TransactionGuardImpl$2.run(TransactionGuardImpl.java:199)
    at com.intellij.openapi.application.impl.ApplicationImpl.runIntendedWriteActionOnCurrentThread(ApplicationImpl.java:828)
    at com.intellij.openapi.application.impl.ApplicationImpl.lambda$invokeLater$4(ApplicationImpl.java:330)
    at com.intellij.openapi.application.impl.FlushQueue.doRun(FlushQueue.java:85)
    at com.intellij.openapi.application.impl.FlushQueue.runNextEvent(FlushQueue.java:134)
    at com.intellij.openapi.application.impl.FlushQueue.flushNow(FlushQueue.java:47)
    at com.intellij.openapi.application.impl.FlushQueue$FlushNow.run(FlushQueue.java:190)
    at java.desktop/java.awt.event.InvocationEvent.dispatch(InvocationEvent.java:313)
    at java.desktop/java.awt.EventQueue.dispatchEventImpl(EventQueue.java:776)
    at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:727)
    at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:721)
    at java.base/java.security.AccessController.doPrivileged(Native Method)
    at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:85)
    at java.desktop/java.awt.EventQueue.dispatchEvent(EventQueue.java:746)
    at com.intellij.ide.IdeEventQueue.defaultDispatchEvent(IdeEventQueue.java:976)
    at com.intellij.ide.IdeEventQueue._dispatchEvent(IdeEventQueue.java:843)
    at com.intellij.ide.IdeEventQueue.lambda$dispatchEvent$8(IdeEventQueue.java:454)
    at com.intellij.openapi.progress.impl.CoreProgressManager.computePrioritized(CoreProgressManager.java:773)
    at com.intellij.ide.IdeEventQueue.lambda$dispatchEvent$9(IdeEventQueue.java:453)
    at com.intellij.openapi.application.impl.ApplicationImpl.runIntendedWriteActionOnCurrentThread(ApplicationImpl.java:828)
    at com.intellij.ide.IdeEventQueue.dispatchEvent(IdeEventQueue.java:501)
    at java.desktop/java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:203)
    at java.desktop/java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:124)
    at java.desktop/java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:113)
    at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:109)
    at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:101)
    at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:90)

kotlinのplugin 203-1.5.30-M1-release-140-AS7717.8をうっかりインストールするたびにエラーが起こって削除してます。

plugin uninstall後は203-1.5.20-release-289-AS7717.8に戻ります。

uninstallしても完全に消えてなくなるわけじゃなく1つ前?に戻るようです。

1.5.30ってなんだ?と思ったらこういうことみたいです。
Preview of Kotlin 1.5.30 With Native Apple Silicon Support, Improved Kotlin DSL for the CocoaPods Gradle Plugin, and More | The Kotlin Blog

環境

Android Studio Arctic Fox | 2020.3.1
Build #AI-203.7717.56.2031.7583922, built on July 27, 2021
Runtime version: 11.0.10+0-b96-7281165 x86_64
VM: OpenJDK 64-Bit Server VM by JetBrains s.r.o.
macOS 11.5.1
GC: G1 Young Generation, G1 Old Generation
Memory: 2560M
Cores: 8
Registry: external.system.auto.import.disabled=true
Non-Bundled Plugins: Dart, com.thoughtworks.gauge, org.jetbrains.kotlin, io.flutter, org.intellij.plugins.markdown

Firestore purchasesはtopレベルのコレクションにするかusersの配下か

purchasesが一番上のコレクションの場合

DocumentId=orderIdにできる

サーバ通知(pubsub or iosはonRequest)でFunctionsにアクセスがあった場合は、UIDで判別はできないのでこのやり方が便利

ドキュメントにUIDを持つ必要がある

アプリで有料コンテンツにアクセスできるかどうかをwhereで判別する

db.collection("purchases").where('uid' , "==", uid);

セキュリティルールも追加する

users配下の場合

サーバ通知の場合はcollectionGroupでpurchasesを検索

db.collectionGroup("purchases").where('orderId' , "==", orderId);

セキュリティルールとcollectionGroupは別でインデックスを追加する必要がある。 そして、インデックスにはお金がかかる

有料コンテンツ判定

ログインしてればuidは取得できてるので

db.collection("users").doc(uid).collection("purchases");

結論

インデックスの分だけ後者が不利

番外編 usersドキュメントにpurchasesをmapで持つ

  • purchasesが不要な場面でも取得される
  • classとか型を作らずにmapでそのままアクセスするメンバー(エンジニア)が出てきそう
  • フィールドに対するセキュリティルールは少し面倒だったはず

https://firebase.google.com/docs/firestore/manage-data/structure-data

上記も読み直してみたが、mapのメリットが見当たらない。。。

basic-android-kotlin-training-intro-room-flow#7 Cannot access database on the main thread

https://developer.android.com/codelabs/basic-android-kotlin-training-intro-room-flow#7

上のページの最後でアプリを起動させることになってるが、起動しない。原因は下記。

github.com

なので、次のページのFlowを導入するところまでやり切ってから起動させた方が慣れてない人にはいいと思います。 それと上のissueを見ると、このcodelabをやる前にやった方がいいcodelabがあるようです。

Roomのcodelabsはsqlについても少し解説してくれてるので、企業の新人教育にも使えるくらいだと 思っていたけど、問題にぶつかった時にgithubのissuesを検索する手順も教えておかないといけないかもしれない。

ただ、そんなことは実際の開発ではよくあることなので、このタイミングで教えてもいいのかもしれない。