Compose Multiplatform で サブスクリプションを実装する
概要
- Android/iOSの両方でサブスクリプションの課金処理を実装する方法をまとめました。
- この記事ではCompose Multiplatform を使ったコードを記載していますが、課金処理の部分はCompose Multiplatformを使っていない場合でも参考になると思います。
- StoreKit 2からバックエンドでのレシート検証が不要になったので、バックエンドサーバはありません。
スクリーンショット
OS | サブスクリプション画面 | 購入ボタン押下後 |
---|---|---|
Android | ![]() | ![]() |
iOS | ![]() | ![]() |
利用するライブラリ
- Common(Android/iOSの共通部分)
- Compose Multiplatform (opens in a new tab): UI共通化
- InsertKoinIO/koin (opens in a new tab): dependency injection framework
- russhwolf/multiplatform-settings (opens in a new tab): キーバリュー永続化
- adrielcafe/voyager (opens in a new tab): ナビゲーション
- Android
- iOS
Common(Android/iOSの共通部分)
参考
実装
リソース
composeApp/src/commonMain/composeResources/values/strings.xml
<string name="subscription">Subscription</string>
<string name="current_plan">Current plan</string>
<string name="register">Register</string>
<string name="register_description">By registering, you agree to the Terms of Use and Privacy Policy. Plans renew automatically until you cancel your membership. You may cancel at least 24 hours before your next renewal date to avoid subsequent billing.</string>
<string name="subscription_management">Subscription Management</string>
<string name="month">month</string>
<string name="terms_of_use">Terms of Use</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="restore_purchases">Restore Purchases</string>
composeApp/src/commonMain/composeResources/values-ja/strings.xml
<string name="subscription">サブスクリプション</string>
<string name="current_plan">現在のプラン</string>
<string name="register">登録する</string>
<string name="register_description">登録すると、利用規約とプライバシー規約に同意されたものとみなします。プランは退会するまで自動更新します。次の更新日の少なくとも24時間前に解約することで、その後の請求を避けることができます。</string>
<string name="subscription_management">定期購入の管理</string>
<string name="month">月</string>
<string name="terms_of_use">利用規約</string>
<string name="privacy_policy">プライバシーポリシー</string>
<string name="restore_purchases">購入の復元</string>
課金関連
composeApp/src/commonMain/kotlin/app/shinagawa/voicevoxtts/billing/BillingManager.kt
package app.shinagawa.voicevoxtts.billing
interface BillingManager {
/** 全てのサブスクリプション商品アイテムを取得する */
suspend fun fetchProductItems(): List<ProductItem>
/** 現在のサブスクリプションプランを返す */
suspend fun fetchCurrentSubscriptionPlan(): SubscriptionPlan?
/** 購入フローを開始する */
suspend fun launchBillingFlow(productItem: ProductItem)
/** 購入の復元 */
suspend fun restorePurchases()
/** サブスクリプション管理画面を開く */
fun openSubscriptionManagementScreen()
}
composeApp/src/commonMain/kotlin/app/shinagawa/voicevoxtts/billing/BillingSdk.kt
package app.shinagawa.voicevoxtts.billing
class BillingSdk(
private val billingManager: BillingManager
) : CoroutineScope by CoroutineScope(Dispatchers.Default), KoinComponent {
private val preference: Preference by inject()
private val store: AppStore by inject()
/** 全てのサブスクリプション商品アイテムを取得する */
suspend fun fetchProductItems(): List<ProductItem> {
return billingManager.fetchProductItems()
}
/** 加入中のサブスクリプションプランを更新する */
suspend fun updateCurrentSubscriptionPlan() {
val currentPlan = billingManager.fetchCurrentSubscriptionPlan() ?: return
Logger.d("fetchCurrentSubscriptionPlan: $currentPlan")
if (preference.currentSubscriptionPlan != currentPlan) {
store.dispatch(MainAction.OnChangeSubscriptionPlan(currentPlan))
}
}
/** 購入フローを開始する */
suspend fun launchBillingFlow(productItem: ProductItem) {
billingManager.launchBillingFlow(productItem)
}
/** 購入の復元 */
suspend fun restorePurchases() {
billingManager.restorePurchases()
}
/** サブスクリプション管理画面を開く */
fun openSubscriptionManagementScreen() {
billingManager.openSubscriptionManagementScreen()
}
}
composeApp/src/commonMain/kotlin/app/shinagawa/voicevoxtts/billing/ProductItem.kt
package app.shinagawa.voicevoxtts.billing
interface ProductItem {
val displayName: String
val description: String
val formattedPrice: String
val formattedBillingPeriod: String
}
composeApp/src/commonMain/kotlin/app/shinagawa/voicevoxtts/billing/SubscriptionPlan.kt
package app.shinagawa.voicevoxtts.billing
enum class SubscriptionPlan(val value: Int) {
Free(0),
Premium(1);
/** 広告を非表示にするならtrueを返す */
val shouldHideAd: Boolean
get() {
return when (this) {
Free -> false
Premium -> true
}
}
companion object {
fun fromValueOrDefault(value: Int): SubscriptionPlan {
return entries.firstOrNull { value == it.value } ?: Free
}
}
}
Core
composeApp/src/commonMain/kotlin/app/shinagawa/voicevoxtts/core/MainCore.kt
package app.shinagawa.voicevoxtts.core
data class MainState(
val isEmpty: Boolean = false
) : ReduxState
sealed class MainAction : Action {
/** アプリがフォアグラウンドになった */
data object OnAppStarted : MainAction()
/** サブスクリプションプランが変更された */
data class OnChangeSubscriptionPlan(val subscriptionPlan: SubscriptionPlan) : MainAction()
}
sealed class MainSideEffect : Effect() {
/** サブスクリプションプランが変更された時の通知 */
data class OnChangeSubscriptionPlan(val subscriptionPlan: SubscriptionPlan) : MainSideEffect()
}
class MainReducer(val store: AppStore) :
CoroutineScope by CoroutineScope(Dispatchers.Main), KoinComponent {
private val billingSdk: BillingSdk by inject()
fun reduce(
action: MainAction,
state: AppState?
): MainState {
val oldState = state?.mainState ?: MainState()
return when (action) {
is MainAction.OnAppStarted -> {
launch {
billingSdk.updateCurrentSubscriptionPlan()
}
oldState
}
is MainAction.OnChangeSubscriptionPlan -> {
launch {
preference.currentSubscriptionPlanValue = action.subscriptionPlan.value
store.emitSideEffect(MainSideEffect.OnChangeSubscriptionPlan(action.subscriptionPlan))
}
oldState
}
}
}
}
キーバリュー永続化
composeApp/src/commonMain/kotlin/app/shinagawa/voicevoxtts/model/Preference.kt
package app.shinagawa.voicevoxtts.model
interface Preference {
/** 現在のサブスクリプションのプラン */
var currentSubscriptionPlanValue: Int
val currentSubscriptionPlan: SubscriptionPlan get() = SubscriptionPlan.fromValueOrDefault(currentSubscriptionPlanValue)
}
class PreferenceImpl(settings: Settings) : Preference {
override var currentSubscriptionPlanValue: Int by settings.int(defaultValue = SubscriptionPlan.Free.value)
}
サブスクリプション画面
composeApp/src/commonMain/kotlin/app/shinagawa/voicevoxtts/ui/screens/subscription/SubscriptionScreen.kt
package app.shinagawa.voicevoxtts.ui.screens.subscription
class SubscriptionScreen : Screen, KoinComponent {
@Composable
override fun Content() {
val adFactory: AdFactory by inject()
val platformContext: PlatformContext by inject()
val store: AppStore by inject()
val navigator = LocalNavigator.currentOrThrow
val screenModel = navigator.rememberNavigatorScreenModel { SubscriptionScreenModel() }
val viewModelState by screenModel.state.collectAsState()
val state = viewModelState as SubscriptionScreenModel.State.Result
DisposableEffect(key) {
screenModel.onStart()
onDispose {
screenModel.onDispose()
}
}
val mainSideEffect = store.observeSideEffect()
.filterIsInstance<MainSideEffect>()
LaunchedEffect(mainSideEffect) {
mainSideEffect.collect {
when (val effect = it) {
is MainSideEffect.OnChangeSubscriptionPlan -> {
screenModel.onChangeSubscriptionPlan(effect.subscriptionPlan)
}
else -> {}
}
}
}
SubscriptionContent(screenModel, state, adFactory, platformContext)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SubscriptionContent(
screenModel: SubscriptionScreenModel,
state: SubscriptionScreenModel.State.Result,
adFactory: AdFactory,
platformContext: PlatformContext,
) {
val navigator = LocalNavigator.currentOrThrow
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
title = { Text(stringResource(Res.string.subscription)) },
navigationIcon = {
AppBarBackButton() {
navigator.pop()
}
},
)
LazyColumn(modifier = Modifier.weight(1f, true)) {
// 現在のプラン
item {
Card(
modifier = Modifier
.padding(all = 16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.current_plan),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
text = state.currentSubscriptionPlan.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
TextButton(modifier = Modifier.padding(top = 8.dp), onClick = {
screenModel.onTapSubscriptionManagementButton()
}) {
Text(stringResource(Res.string.subscription_management), textDecoration = TextDecoration.Underline)
}
if (getPlatform().os == OS.IOS) {
TextButton(onClick = {
screenModel.onTapRestorePurchasesButton()
}) {
Text(stringResource(Res.string.restore_purchases), textDecoration = TextDecoration.Underline)
}
}
}
}
}
// Premium
item {
state.productItems.forEach { item ->
Card(
modifier = Modifier
.padding(all = 16.dp)
.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.Start) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = item.displayName,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "${item.formattedPrice} / ${item.formattedBillingPeriod}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
Row(
modifier = Modifier.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "check",
tint = MaterialTheme.colorScheme.primary
)
Text(
text = item.description,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(Res.string.register_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Row(
modifier = Modifier
.padding(top = 8.dp)
.clickable {
platformContext.openInBrowser("https://shinagawa.app/voicevox-tts/terms-and-conditions/")
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
painterResource(Res.drawable.contract),
contentDescription = ""
)
Text(stringResource(Res.string.terms_of_use), textDecoration = TextDecoration.Underline)
}
Row(
modifier = Modifier
.padding(top = 8.dp, bottom = 8.dp)
.clickable {
platformContext.openInBrowser("https://shinagawa.app/voicevox-tts/privacy-policy/")
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
painterResource(Res.drawable.contract),
contentDescription = ""
)
Text(stringResource(Res.string.privacy_policy), textDecoration = TextDecoration.Underline)
}
Button(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp),
onClick = {
screenModel.onTapRegisterButton(item)
},
enabled = state.currentSubscriptionPlan == SubscriptionPlan.Free
) {
Text(stringResource(Res.string.register))
}
}
}
}
}
}
adFactory.AdBanner()
}
}
/Users/kazuyamada/git/voicevox-tts/composeApp/src/commonMain/kotlin/app/shinagawa/voicevoxtts/ui/screens/AppStateScreenModel.kt
package app.shinagawa.voicevoxtts.ui.screens
abstract class AppStateScreenModel<S>(initialState: S) : ScreenModel {
//region StateScreenModel
val mutableState: MutableStateFlow<S> = MutableStateFlow(initialState)
val state: StateFlow<S> = mutableState.asStateFlow()
//endregion
private val sideEffect = MutableSharedFlow<Effect>()
fun observeSideEffect(): Flow<Effect> = sideEffect
suspend fun emitSideEffect(effect: Effect) {
sideEffect.emit(effect)
}
}
composeApp/src/commonMain/../voicevoxtts/ui/screens/subscription/SubscriptionScreenModel.kt
package app.shinagawa.voicevoxtts.ui.screens.subscription
class SubscriptionScreenModel : ScreenModel, KoinComponent,
AppStateScreenModel<SubscriptionScreenModel.State>(State.Result()) {
sealed class State {
data class Result(
val productItems: List<ProductItem> = emptyList(),
val currentSubscriptionPlan: SubscriptionPlan = SubscriptionPlan.Free,
) : State()
}
private val preference: Preference by inject()
private val resultState: State.Result get() = mutableState.value as? State.Result ?: State.Result()
private val billingSdk: BillingSdk by inject()
private fun setResult(result: State.Result) {
mutableState.value = result
}
fun onStart() {
screenModelScope.launch {
val newState = resultState.copy(
productItems = billingSdk.fetchProductItems(),
currentSubscriptionPlan = preference.currentSubscriptionPlan
)
setResult(newState)
}
}
fun onTapRegisterButton(productItem: ProductItem) {
screenModelScope.launch {
billingSdk.launchBillingFlow(productItem)
}
}
fun onChangeSubscriptionPlan(subscriptionPlan: SubscriptionPlan) {
setResult(resultState.copy(currentSubscriptionPlan = subscriptionPlan))
}
fun onTapSubscriptionManagementButton() {
billingSdk.openSubscriptionManagementScreen()
}
fun onTapRestorePurchasesButton() {
screenModelScope.launch {
billingSdk.restorePurchases()
}
}
}
ヘルパー
composeApp/src/iosMain/kotlin/app/shinagawa/voicevoxtts/KoinHelper.kt
package app.shinagawa.voicevoxtts
class KoinHelper : KoinComponent
fun initKoin(
appStore: AppStore,
platformContext: PlatformContext,
voicevoxTalker: VoicevoxTalker,
billingSdk: BillingSdk,
preference: Preference,
adBannerViewFactory: AdBannerViewFactory,
) {
startKoin {
modules(module {
single<AppStore> { appStore }
single<PlatformContext> { platformContext }
single<VoicevoxApi> { VoicevoxApi() }
single<VoicevoxSdk> { VoicevoxSdk(voicevoxTalker = voicevoxTalker, api = get()) }
single<BillingSdk> { billingSdk }
single<Preference> { preference }
single<AdFactory> { AdFactoryImpl(adBannerViewFactory) }
single<DataRepository> { DataRepository(getRoomDatabase(getDatabaseBuilder())) }
})
}
}
Android
参考
- 定期購入について理解する (opens in a new tab)
- Google Play Billing Library をアプリに統合する (opens in a new tab)
- Google Play Billing Library 統合をテストする (opens in a new tab)
準備
- 支払い方法を登録する
- Google Play Console > 設定 > お支払いプロファイル > お受け取り方法
- 15%のサービス手数料に登録する (opens in a new tab)
- ライブラリへの依存関係を追加する (opens in a new tab)
composeApp/build.gradle.kts
kotlin {
sourceSets {
androidMain.dependencies {
implementation("com.android.billingclient:billing-ktx:7.1.1")
}
}
}
- アプリをアップロードする (opens in a new tab)
- 内部テストトラックに、アプリをアップロードします。アップロードしないと、次のステップで定期購入アイテムを作成できません。
- アイテムを作成して構成する (opens in a new tab)
- Google Play Console > Google Playで収益化する > 商品 > 定期購入 > 定期購入を作成
- 定期購入を作成する
- アイテムID: voicevoxtts.subscription.premium
- 最大40文字。アイテムIDは変更不可で、一度作った定期購入は削除できないので、よく考えてください。
- 名前: Premium
- アイテムID: voicevoxtts.subscription.premium
- 定期購入の詳細を編集する
- 特典: アプリ広告を非表示にします。
- 説明: アプリ広告を非表示にします。
- 基本プランを追加する
- 基本プランID: monthly
- タイプ: 自動更新
- 請求対象期間: 1 ヶ月ごと
- 猶予期間: 7 日別
- アカウントの一時停止期間: 30 日別
- ユーザーの基本プランと特典の変更: 次回の請求日に請求
- 再度定期購入: 許可
- 価格と在庫状況
- Set pricesボタンを押下
- 左上のチェックボックスをクリックし、全ての国をチェック
- 価格を設定ボタンを押下
- 2.99 USD を設定し、更新ボタンを押下
- 日本の価格を 300.00 JPY に変更
実装
composeApp/src/androidMain/kotlin/app/shinagawa/voicevoxtts/MainApplication.kt
package app.shinagawa.voicevoxtts
class MainApplication : Application(), Application.ActivityLifecycleCallbacks,
DefaultLifecycleObserver {
override fun onCreate() {
super<Application>.onCreate()
registerActivityLifecycleCallbacks(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
startKoin {
androidContext(this@MainApplication)
modules(makeAppModule())
}
}
private fun makeAppModule(): Module {
val application = this
return module {
single<AppStore> { AppStore() }
single<PlatformContext> { PlatformContextImpl() }
single<VoicevoxApi> { VoicevoxApi() }
single<VoicevoxSdk> {
VoicevoxSdk(voicevoxTalker = VoicevoxTalkerImpl(), api = get())
}
single<BillingSdk> {
BillingSdk(billingManager = BillingManagerImpl(application))
}
single<Preference> { PreferenceImpl.create(application) }
single<AdFactory> { AdFactoryImpl() }
single<DataRepository> { DataRepository(getRoomDatabase(getDatabaseBuilder(application))) }
}
}
}
composeApp/src/androidMain/kotlin/app/shinagawa/voicevoxtts/billing/BillingManagerImpl.kt
package app.shinagawa.voicevoxtts.billing
class BillingManagerImpl(private val context: Context) : KoinComponent, BillingManager, CoroutineScope by CoroutineScope(Dispatchers.Main) {
private val store: AppStore by inject()
private val platformContext: PlatformContext by inject()
private val purchasesUpdatedListener =
PurchasesUpdatedListener { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
for (purchase in purchases) {
handlePurchase(purchase)
}
} else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
Logger.i("Purchase canceled")
} else {
Logger.e("Error: ${billingResult.debugMessage}")
}
}
private val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
.enableOneTimeProducts() // 一度購入型商品の保留中購入をサポート
.build()
private var billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases(pendingPurchasesParams)
.build()
/**
* Returns immediately if this BillingClient is already connected, otherwise
* initiates the connection and suspends until this client is connected.
*/
private suspend fun ensureReady() {
if (billingClient.isReady) {
return // fast path if already connected
}
return suspendCoroutine { cont ->
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
cont.resume(Unit)
} else {
// you could also use a custom, more precise exception
cont.resumeWithException(RuntimeException("Billing setup failed: ${billingResult.debugMessage} (code ${billingResult.responseCode})"))
}
}
override fun onBillingServiceDisconnected() {
// no need to setup reconnection logic here, call ensureReady()
// before each purchase to reconnect as necessary
}
})
}
}
override suspend fun fetchProductItems(): List<ProductItem> {
try {
ensureReady()
} catch (e: RuntimeException) {
Logger.e(e.message ?: "", e)
}
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
ImmutableList.of(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(PRODUCT_ID_PREMIUM)
.setProductType(BillingClient.ProductType.SUBS)
.build(),
),
)
val productDetailsResult = withContext(Dispatchers.IO) {
billingClient.queryProductDetails(params.build())
}
val productItems = mutableListOf<ProductItem>()
productDetailsResult.productDetailsList?.forEach { details ->
details.subscriptionOfferDetails?.forEach { offerDetails ->
offerDetails.pricingPhases.pricingPhaseList.forEach { pricingPhrase ->
productItems.add(
ProductItemImpl(
productDetails = details,
subscriptionOfferDetails = offerDetails,
pricingPhase = pricingPhrase,
formattedBillingPeriod = formatBillingPeriod(pricingPhrase.billingPeriod)
)
)
}
}
}
return productItems.toList()
}
private suspend fun formatBillingPeriod(billingPeriod: String): String {
return when (billingPeriod) {
"P1M" -> getString(Res.string.month)
else -> billingPeriod
}
}
override suspend fun fetchCurrentSubscriptionPlan(): SubscriptionPlan? {
try {
ensureReady()
} catch (e: RuntimeException) {
Logger.e(e.message ?: "", e)
return null
}
return suspendCancellableCoroutine { continuation ->
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
) { billingResult, purchasesList ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
for (purchase in purchasesList) {
handlePurchase(purchase)
}
// 承認された購入のリスト
val ackPurchases = purchasesList.filter { it.isAcknowledged }
val isPremium = ackPurchases.any { purchase ->
purchase.products.contains(PRODUCT_ID_PREMIUM)
}
val plan = if (isPremium) SubscriptionPlan.Premium else SubscriptionPlan.Free
continuation.resume(plan)
} else {
continuation.resume(SubscriptionPlan.Free)
}
}
}
}
private fun handlePurchase(purchase: Purchase) {
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
// 購入が成功した場合、消費または確認
if (!purchase.isAcknowledged) {
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
val productId = purchase.products.first()
Logger.i("Subscription purchased: productId=${productId}")
when (productId) {
PRODUCT_ID_PREMIUM -> {
launch {
store.dispatch(MainAction.OnChangeSubscriptionPlan(SubscriptionPlan.Premium))
}
}
else -> {}
}
}
}
}
}
}
override suspend fun launchBillingFlow(productItem: ProductItem) {
val itemImpl = productItem as ProductItemImpl
val productDetailsParamsList = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(itemImpl.productDetails)
.setOfferToken(itemImpl.subscriptionOfferDetails.offerToken)
.build()
)
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.setIsOfferPersonalized(true)
.build()
// Launch the billing flow
val billingResult = billingClient.launchBillingFlow(MainActivity.INSTANCE, billingFlowParams)
Logger.d("launchBillingFlow responseCode=${billingResult.responseCode}")
}
override suspend fun restorePurchases() {
// Androidは実装の必要なし
}
override fun openSubscriptionManagementScreen() {
val url = "https://play.google.com/store/account/subscriptions?sku=${PRODUCT_ID_PREMIUM}"
platformContext.openInBrowser(url)
}
companion object {
const val PRODUCT_ID_PREMIUM = "voicevoxtts.subscription.premium"
}
}
composeApp/src/androidMain/kotlin/app/shinagawa/voicevoxtts/billing/ProductItemImpl.kt
package app.shinagawa.voicevoxtts.billing
data class ProductItemImpl(
val productDetails: ProductDetails,
val subscriptionOfferDetails: SubscriptionOfferDetails,
val pricingPhase: PricingPhase,
override val formattedBillingPeriod: String
) : ProductItem {
override val displayName: String get() = productDetails.name
override val description: String get() = productDetails.description
override val formattedPrice: String get() = pricingPhase.formattedPrice
}
購入のテスト
- 以下の記事の通りにアプリライセンスを設定する
- アプリ ライセンスを使用したアプリ内課金のテスト (opens in a new tab)
- 注意: [設定] > [ライセンス テスト] の画面では、ライセンステスターを「チェックした状態」で、[変更を保存]してください。
- AndroidStudioでデバッグ実行する
- build.gradle.kts の デバッグ時の
applicationId
を内部テストトラックに公開したアプリと同じにする必要があります。
- build.gradle.kts の デバッグ時の
build.gradle.kts
android {
buildTypes {
getByName("debug") {
versionNameSuffix = "-debug"
// FIXME: サブスクリプションのテストのため、一時的にコメントアウト
// applicationIdSuffix = ".debug"
}
- アプリからサブスクリプションを購入する
- 定期購入を解約する。
- アプリのサブスクリプション画面 > 定期購入の管理 > 定期購入を解約
iOS
参考
準備
App Store Connectでの設定
- 銀行口座・納税フォームを登録する (opens in a new tab)
- App Store Small Business Program (opens in a new tab)に登録する
- 手数料率が30%から15%に引き下がります。
- サブスクリプショングループを作成
- App Store Connect > 対象のアプリを選択 > 収益化 > サブスクリプション > サブスクリプショングループの作成ボタン押下
- 参照名: voicevoxtts.subscription.premium
- App Store Connect > 対象のアプリを選択 > 収益化 > サブスクリプション > サブスクリプショングループの作成ボタン押下
- サブスクリプションを作成
- 参照名: monthly
- 製品ID: voicevoxtts.subscription.premium.monthly
- サブスクリプションの期間: 1か月
- 配信可否: すべての国を選択
- サブスクリプション価格
- アメリカ合衆国: $2.99 にして次へ
- 日本: ¥300にして次へ
- 確認ボタン押下
- ローカリゼーション
- 表示名: Premium (monthly)
- 説明: アプリ広告を非表示にします。
- 画像(任意): 未指定
- 税金カテゴリ: アプリに最適なカテゴリをここ (opens in a new tab)から選択する。
- 審査に関する情報
- スクリーンショット: 最初はダミーでいいので、iPhone等で適当なスクリーンショットを貼って貼り付けてください。課金処理が実装できたら、サブスクリプション購入画面のスクリーンショットに差し替えてください。
- 審査メモ: スクリーンショットの画像を表示する手順を記述します。
1. 右下の設定タブを押下して設定画面を開く 2. サブスクリプション > 現在のプランをタップ 3. Premiumプランの[登録する]をタップする
- サブスクリプションのローカリゼーションを作成(※サブスクリプションのステータスは、ローカリゼーション作成後に「送信準備完了」になります)
- 言語: 日本語
- サブスクリプショングループ表示名: Premium
- App Store Connectでアプリの新規バージョンを作成
- App Store Connect > 対象のアプリを選択 > 左メニューのiOSアプリの右にある[+]ボタンをタップ > バージョン入力
- [App内課金とサブスクリプション]のセクションで、作成したサブスクリプションを追加する。 (opens in a new tab)
- [概要]の末尾に利用規約とプライバシーポリシーのリンクを追加する。 (opens in a new tab)
# 規約とポリシー - 利用規約: https://shinagawa.app/voicevox-tts/terms-and-conditions/ - プライバシーポリシー: https://shinagawa.app/voicevox-tts/privacy-policy/
Xcodeでの設定
- TARGETS > Signing & Capabilities > 左上の[+ Capability]を押下 > [In-App Purchase]を追加
実装
iosApp/iosApp/iOSApp.swift
import SwiftUI
import FirebaseCore
import FirebaseAnalytics
import GoogleMobileAds
import shared
final public class AppEnv {
private init() {
preference = PreferenceImpl.Companion().create()
}
public var preference: Preference
public static let shared = AppEnv()
}
@main
struct iOSApp: SwiftUI.App {
let store: ObservableAppStore
@Environment(\.scenePhase) private var scenePhase
init() {
let appStore = AppStore()
store = ObservableAppStore(store: appStore)
let billingManager = BillingManagerImpl(store: store)
billingManager.observeTransactionUpdates()
KoinHelperKt.doInitKoin(
appStore: appStore,
platformContext: PlatformContextImpl(store: store),
voicevoxTalker: VoicevoxTalkerImpl(),
billingSdk: BillingSdk(billingManager: billingManager),
preference: AppEnv.shared.preference,
adBannerViewFactory: AdBannerViewFactoryImpl()
)
}
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(self.store)
// ...
}
}
}
iosApp/iosApp/billing/BillingManagerImpl.swift
import shared
import StoreKit
class BillingManagerImpl: BillingManager {
let store: ObservableAppStore
static let productIdPremium = "voicevoxtts.subscription.premium.monthly"
enum SubscribeError: LocalizedError {
case userCancelled // ユーザーによって購入がキャンセルされた
case pending // クレジットカードが未設定などの理由で購入が保留された
case productUnavailable // 指定した商品が無効
case purchaseNotAllowed // OSの支払い機能が無効化されている
case failedVerification // トランザクションデータの署名が不正
case otherError // その他のエラー
}
init(store: ObservableAppStore) {
self.store = store
}
func observeTransactionUpdates() {
Task(priority: .background) {
for await verificationResult in Transaction.updates {
guard case .verified(let transaction) = verificationResult else {
continue
}
if transaction.revocationDate != nil {
// 払い戻しされてるので特典削除
store.dispatch(MainAction.OnChangeSubscriptionPlan(subscriptionPlan: .free))
} else if let expirationDate = transaction.expirationDate,
Date() < expirationDate // 有効期限内
&& !transaction.isUpgraded // アップグレードされていない
{
// 有効なサブスクリプションなのでproductIdに対応した特典を有効にする
guard let plan = getSubscriptionPlanBy(productID: transaction.productID) else { return }
store.dispatch(MainAction.OnChangeSubscriptionPlan(subscriptionPlan: plan))
}
await transaction.finish()
}
}
}
private func getSubscriptionPlanBy(productID: String) -> SubscriptionPlan? {
switch productID {
case BillingManagerImpl.productIdPremium:
return .premium
default:
return nil
}
}
func fetchProductItems() async throws -> [any ProductItem] {
let products = try await Product.products(for: [BillingManagerImpl.productIdPremium])
return products.map { ProductItemImpl(product: $0) }
}
func fetchCurrentSubscriptionPlan() async throws -> SubscriptionPlan? {
var validSubscription: Transaction?
// Transaction.currentEntitlements は、オフライン時にはキャッシュされている情報を返します。
// したがって、プレミアムプラン加入中にオフライン状態になった場合、fetchCurrentSubscriptionPlanメソッドは .premiumを返します。
// https://developer.apple.com/forums/thread/706450
for await verificationResult in Transaction.currentEntitlements {
if case .verified(let transaction) = verificationResult,
transaction.productType == .autoRenewable && !transaction.isUpgraded {
print("productID=\(transaction.productID), productType = \(transaction.productType), isUpgraded=\(transaction.isUpgraded)")
validSubscription = transaction
}
}
let isPremium = validSubscription?.productID == BillingManagerImpl.productIdPremium
let plan = isPremium ? SubscriptionPlan.premium : SubscriptionPlan.free
return plan
}
func launchBillingFlow(productItem: any ProductItem) async throws {
guard let productItemImpl = productItem as? ProductItemImpl else { return }
do {
let transaction = try await purchase(product: productItemImpl.product)
// productIdに対応した特典を有効にする
if let plan = getSubscriptionPlanBy(productID: transaction.productID) {
store.dispatch(MainAction.OnChangeSubscriptionPlan(subscriptionPlan: plan))
}
await transaction.finish()
// 完了メッセージを表示
print("購入が完了しました。")
} catch {
// エラーメッセージを表示
let errorMessage = getErrorMessage(error: error)
print(errorMessage)
}
}
private func purchase(product: Product) async throws -> Transaction {
// Product.PurchaseResultの取得
let purchaseResult: Product.PurchaseResult
do {
purchaseResult = try await product.purchase()
} catch Product.PurchaseError.productUnavailable {
throw SubscribeError.productUnavailable
} catch Product.PurchaseError.purchaseNotAllowed {
throw SubscribeError.purchaseNotAllowed
} catch {
throw SubscribeError.otherError
}
// VerificationResultの取得
let verificationResult: VerificationResult<Transaction>
switch purchaseResult {
case .success(let result):
verificationResult = result
case .userCancelled:
throw SubscribeError.userCancelled
case .pending:
throw SubscribeError.pending
@unknown default:
throw SubscribeError.otherError
}
// Transactionの取得
switch verificationResult {
case .verified(let transaction):
return transaction
case .unverified:
throw SubscribeError.failedVerification
}
}
private func getErrorMessage(error: Error) -> String {
switch error {
case SubscribeError.userCancelled:
return "ユーザーによって購入がキャンセルされました"
case SubscribeError.pending:
return "購入が保留されています"
case SubscribeError.productUnavailable:
return "指定した商品が無効です"
case SubscribeError.purchaseNotAllowed:
return "OSの支払い機能が無効化されています"
case SubscribeError.failedVerification:
return "トランザクションデータの署名が不正です"
default:
return "不明なエラーが発生しました"
}
}
func restorePurchases() async {
do {
try await AppStore.sync()
print("Purchase sync completed")
} catch {
print("sync failed: \(error)")
}
}
func openSubscriptionManagementScreen() {
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
UIApplication.shared.open(url)
}
}
}
iosApp/iosApp/billing/ProductItemImpl.swift
import shared
import StoreKit
class ProductItemImpl: ProductItem {
var product: Product
var description_: String
var displayName: String
var formattedBillingPeriod: String
var formattedPrice: String
init(product: Product) {
self.product = product
self.description_ = product.description
self.displayName = product.displayName
self.formattedPrice = product.displayPrice
self.formattedBillingPeriod = ""
if let period = product.subscription?.subscriptionPeriod {
self.formattedBillingPeriod = "\(period.value) \(period.unit)"
}
}
}
購入のテスト
- Sandboxアカウントを作成 (opens in a new tab)
- App Store Connect > ユーザとアクセス > Sandbox > +ボタン > サンドボックスアカウントを作成
- サンドボックスアカウントのメールアドレスは、ダミーでもかまいません。
- 設定アプリでSandboxアカウントでサインイン (opens in a new tab)
- 設定アプリ > デベロッパ > SANDBOX APPLE ACCOUNT から、1で作成したアカウントでサインインする。
- サインイン後に[Apple Account セキュリティ画面]が表示されたら、[その他のオプション]>[アップグレードしない]を選択
- アプリを実行する
- XCode > TARGET > Signing & Capabilities の
Bundle Identifier
は本番と同じにする必要があります。
- XCode > TARGET > Signing & Capabilities の
- アプリからサブスクリプションを購入する
- サブスクリプションをキャンセルする
- 設定アプリ > デベロッパ > SANDBOX APPLE ACCOUNT > 管理 > サブスクリプション > サブスクリプションをキャンセル