Zenn
💴

Androidの定期購入(サブスクリプション)導入で躓いたポイント

2025/02/24に公開

「知っていたら大幅に工数削減できたのに」な部分と、「私はこうしました」の記録です。

はじめに

初めての実装だったのと、Functions第二世代のネット情報を多くは見つけられず、手探りで進めて相当苦労しました。
クライアントアプリ側の実装は公式ガイドなどの情報をもとにあまり躓かなかったのでサーバーサイドでのお話が中心になります。

用語

Google Play Billing Library: 購入処理に関わるクライアントアプリで使用するライブラリ
Google Play Developer API: Play Billing Libraryと比べ、こちらはデベロッパーのサーバーサイドAPI。Google Play ConsoleをAPI操作できるということなので、購入処理に限らずアプリ公開関連の操作なども出来る。

アプリの実装構成など

ツール系アプリ:ログイン機能無し
開発言語:Kotlin

サブスク

月額プラン
プリペイド対応

バックエンド

Cloud Functions v2, TypeScript, Node.js
Firestore

認証関連

おそらく、UNAUTHENTICATEDエラーなどの認証の解決が一番時間がかかったように思います。
匿名認証とAppCheckを導入していますが、AppCheckはアプリの配布用署名をGoogleに任せている場合は、Google経由でインストールしたアプリでないと認証に失敗します。つまり、内部テストなどにアップロードしてからのテストを何度もすることになって時間がかかってしまいましたが、App Checkは最後の方で有効にしたほうがいいように思えました。AppCheckを外しておくとローカルでインストールしたアプリでFunctionsの呼び出しテストは出来ますし、実機ならGooglePlayでの購入テストもできます(エミュレーターでは購入フローが出来ない)

クライアントアプリからFunctionsのメソッドを呼べない問題

ローカルのエミュレート環境ではテスト呼び出しが出来ていましたが、本番環境でデプロイすると認証エラーになって調査に時間がかかりました。
同一プロジェクト内のアプリなら認証は通るものだと勘違いしていたのが原因の一つでした。
同一プロジェクトのアプリだとしても、アプリからのアクセスはパブリック、つまり不特定多数からのアクセスが、まずは許可になっていないといけないということのようです。そのままではセキュリティがよろしくないので他の認証手段(ログイン機能やApp Check)で弾くようにすればいいようです。

参考情報

モバイルアプリからの呼び出し:
https://stackoverflow.com/questions/78720384/firebase-authentication-with-cloud-functions-fails-the-request-was-not-authent
Webアプリのケースだが解決方法がわかる:
https://stackoverflow.com/questions/77896808/firebase-oncall-functions-and-google-cloud-functions-authentication-showing-call

対処

今回の問題はIAMとFunctionsの許可ルールで弾かれていた。これをパブリックアクセス可能にする。
・Cloud RunにあるFunctionsの該当メソッドへ行き、セキュリティータブで「未承認の呼び出しを許可」します。

・IAMでメソッドにアクセスする該当サービスアカウントに「Cloud Run 起動元」ロールが付いていることが必要

・[任意]Functionsのメソッドで認証チェックしてセキュリティ強化
匿名認証とApp Checkの例:

index.ts
export const registerPurchase = onCall<PurchaseData>(
    { 
        enforceAppCheck: true,//App Check適用
    },
    async (request) => {

    // 匿名認証のチェック
    const userId = request.auth?.uid;
    if (!userId) { throw new HttpsError("permission-denied", "User authentication failed."); }

購入登録処理

バックエンドに渡すパラメータ

バックエンド処理に最低限必要なものを挙げると、まずpurchaseTokenが最重要な情報として必須。署名検証するのならば、レシートとSignature。
これらの情報はレシートと呼ばれているのであろうJSON文字列に含まれているようなので、最小構成ならレシートだけ渡せば済むと考えていました。つまり、Purchase.originalJsonだけ渡しておけばいいよね、と。
そして、バックエンドでパースするためにレシートの構造を公式情報で探した。見つけられない。ありそうで見つからない。デバッグ情報として出力すれば構造自体を知ることは出来はしますが・・・
「Google Play Developer API」のpurchases.subscriptionsv2.getで得られるJSONの公式情報は簡単にみつかりますが、扱っている情報はよく似ていても構造は微妙に違います。
「Google Play Billing Library」で得ることになるレシートはオブジェクトのプロパティから要素を得る手段が用意されています。このレシートに関してはJSONをパースするのではなく、クライアントサイドで必要な要素を取り出しておくことが想定された設計なのではないか?と考えました。
レシートの構造が公表されていないのなら、今後、レシートの構造が変更される可能性の高さを示唆していそうなのと、課金まわりは更新頻度が高い傾向があるので、オブジェクトのプロパティでアクセスしておく方法にしておいたほうがメンテの手間が少なくて済むのでは?との考えで実装しました。

SampleBilling.kt
/* パラメータ作成メソッド例 */
private fun createRequestBody(purchase: Purchase): Map<String, String> {
    val purchaseToken = purchase.purchaseToken
    val signature = purchase.signature

    /* これがレシートと解釈できるが、
        公式ドキュメントでJSONの仕様を見つけられない事からも解析してデータを使うべきではない? */
    val originalJson = purchase.originalJson

    val requestBody = mapOf(
        "receipt" to originalJson, //署名検証にレシート自体が必要
        "signature" to signature,
        "purchaseToken" to purchaseToken,
    )
    return requestBody
}
index.ts
/* 登録処理例 */
interface PurchaseData {
    receipt: string;
    signature: string;
    purchaseToken: string;
}
export const registerPurchase = onCall<PurchaseData>(
    { 
        enforceAppCheck: true, //App Check適用
    }, async (request) => {

    const { receipt, signature, purchaseToken } = request.data;

    if (!verifySignature(receipt, signature)) {
        return { status: "error_invalid", message: "Purchase signature verification failed."}
    }

    // 以降の登録処理    
});

レシートに含まれている同じ文字列を重複送信するのは通信の無駄に感じられて気持ち悪いですが、レシート登録の頻度を考えると気にならないということにして納得させました。

購入の復元機能

どのように実装するのだろう?と考えていましたが、BillingClient.queryPurchasesAsync()で新規購入時のレシート相当のものが取得できるので、購入の初回登録と同じような形でバックエンドを呼べばいいのですね。
サーバーサイドで署名検証>Playストアから直接最新情報を取得>必要なら承認>Firestore更新>購入状態をクライアントへ返す、という処理の流れは初回登録と共通する部分が多い。別のメソッドで受け付けるかどうか迷いましたが、共通メソッドにしてみました。

サブスクの一時停止機能について

結論から:一時停止機能はサポートしませんでした。
一時停止機能をサポートするとサブスク継続の状態が維持されるというメリットは当アプリの状況から考えると低くなっていると思えました。
以前はサブスク継続2年目以降が手数料半額の15%となっていたため、デベロッパー側に継続率のメリットがはっきりとあったようです。しかし、大きな収益を上げるようになるまではこのメリットは現状なさそうに思えます。私を含め、多くのデベロッパーにとって上限が最初から15%となっているからです。
[参考]サブスク料金の変遷の過程がわかりやすい:
https://gigazine.net/news/20211022-google-play-subscription-fee/
一時停止機能をサポートすることは現状で考えている仕様で出来なくもなさそうでしたが、実装がかなり複雑になりそうなことと、サポートすることによる利便性の向上がほとんど感じられないことが決め手でした。実装の面では特に、一時停止期間中はqueryPurchasesAsync()で状態を取得できないことから管理するべき状態が複数のシステム的な部分で増えるのが辛い。
デベロッパーが任意でサブスクの一時停止機能をオフに設定できる仕様になっていることと、ユーザー側にとってもいったん解約したあと、再開したいときには新たに購入することで不便はないと考えました。
一時停止をサポートする場合でも、現状で考えている仕様では、一時停止状態でデバイスを変更したときには購入の復元機能が機能できない(一時停止の手続きをしていてもサブスク期間が残っている場合は情報を取得できるので復元できると思われます) この復元できないケースはまれな状況だと思われるので、ユーザーサポート対応でいいかもしれないですが、そもそも復元できずとも再開したいならユーザーが再購入するので問題がないように思えるところです。解約後の再購入をテストで試してみたところ、有効期間が残っている状態で新規に同じサブスクを購入すれば新規購入のサブスクの支払いが有効期間を使い終わってからになるように自動で調整されるようですので、一時停止をサポートしなくても問題ないと判断[1]しました。

Functionsでのlog出力

consoleを使っても汎用的にlog出力できますが、

index.ts
console.debug("message");

この出力は次のように重大度が全てデフォルト(*マーク)になってしまい、重大度のフィルターで絞り込むことが出来ません。

ということで、専用のloggerを使ったほうが便利です

index.ts
import { logger } from 'firebase-functions/v2';
//(略)
{
    logger.debug("message");
    logger.error("message");
}

デプロイ時のトラブル

デプロイは出来ているようだがなんだかエラーが表示されている

次のような表示
「Unhandled error cleaning up build images. This could result in a small monthly bill if not corrected. You can attempt to delete these images by redeploying or you can delete them manually at https://console.cloud.google.com/gcr/images/[プロジェクト名]/~

参考情報:
https://stackoverflow.com/questions/68611817/cant-deploy-cloud-functions-because-of-unhandled-error-cleaning-up-build-image
要は、デプロイのための一時ファイルが後始末処理の途中にエラーで中断されたので削除されずに残っているからほんの少しコストかかるかもしれませんよ、手動で削除することも出来ますよ、というこのようです。
エラーの原因は定まったものではなくいろんなケースがあるようです。上記Stack Overflowでも案内があるように、デバッグ情報オプションを付けてデプロイすれば原因が特定できるかもしれません。

firebase deploy --only functions --debug

私は、何か(失念)が見つからない旨のようなエラーを確認することは出来ましたが、原因の特定には至らなかったので、表示されているURLにアクセスして手動削除する対応で済ませることにしました。

「Error: User code failed to load. Cannot determine backend specification」の表示でデプロイに失敗する

ネット検索すると、公式ガイドでも案内されているように、対処として下記二つを実行して更新することがいくつか見つかります。

npm install firebase-functions@latest firebase-admin@latest --save
npm install -g firebase-tools

アプデをしましょう、ということなのでしょうが、私の場合はどうもアプデ関係なく一回目は常に失敗しているように思えました。エラーが出てそのままアプデせずに再デプロイを試みると成功したり。
この辺の怪しい挙動が上記の別のトラブル(一時ファイルが削除されない問題)と関係しているのかもしれません。運用には問題ないので、原因がわからないのを仕方なく放置で済ませてしまっています。

おわりに

サーバーサイドの実装をしたことがほとんどなく、JavaScriptすらも使ったことがない状態から手探りで始めましたが、こんな私でもなんとか実装することが出来ました。
順を追って解説している気の利いた記事ではありませんが、部分的にでも何らかの助けになれることを願っています。

実装したアプリ

ツール系のアプリに、リワード広告とサブスクの組み合わせをうまく導入できたのではないか、と思っています。
是非、参考にいかがでしょうか?

https://play.google.com/store/apps/details?id=jp.gr.java_conf.tunecode.counter
カウンターアプリは全て試せないほど溢れています。「もうこのアプリだけあればいい」を目指しました。

脚注
  1. 一時停止をしたとしても次回の自動購読がされないだけであって、サブスクの有効期間の消費はされるとの解釈を前提としての判断です。 ↩︎

Discussion

ログインするとコメントできます