Compose Multiplatform で サブスクリプションを実装する

概要

スクリーンショット

OSサブスクリプション画面購入ボタン押下後
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

参考

準備

  1. 支払い方法を登録する
    • Google Play Console > 設定 > お支払いプロファイル > お受け取り方法
  2. 15%のサービス手数料に登録する (opens in a new tab)
  3. ライブラリへの依存関係を追加する (opens in a new tab)
composeApp/build.gradle.kts
kotlin {
    sourceSets {
        androidMain.dependencies {
            implementation("com.android.billingclient:billing-ktx:7.1.1")
        }
    }
}
  1. アプリをアップロードする (opens in a new tab)
    • 内部テストトラックに、アプリをアップロードします。アップロードしないと、次のステップで定期購入アイテムを作成できません。
  2. アイテムを作成して構成する (opens in a new tab)
    • Google Play Console > Google Playで収益化する > 商品 > 定期購入 > 定期購入を作成
  3. 定期購入を作成する
    • アイテムID: voicevoxtts.subscription.premium
      • 最大40文字。アイテムIDは変更不可で、一度作った定期購入は削除できないので、よく考えてください。
    • 名前: Premium
  4. 定期購入の詳細を編集する
    • 特典: アプリ広告を非表示にします。
    • 説明: アプリ広告を非表示にします。
  5. 基本プランを追加する
    • 基本プランID: monthly
    • タイプ: 自動更新
    • 請求対象期間: 1 ヶ月ごと
    • 猶予期間: 7 日別
    • アカウントの一時停止期間: 30 日別
    • ユーザーの基本プランと特典の変更: 次回の請求日に請求
    • 再度定期購入: 許可
    • 価格と在庫状況
      1. Set pricesボタンを押下
      2. 左上のチェックボックスをクリックし、全ての国をチェック
      3. 価格を設定ボタンを押下
      4. 2.99 USD を設定し、更新ボタンを押下
      5. 日本の価格を 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
}

購入のテスト

  1. 以下の記事の通りにアプリライセンスを設定する
  2. AndroidStudioでデバッグ実行する
    • build.gradle.kts の デバッグ時のapplicationId を内部テストトラックに公開したアプリと同じにする必要があります。
build.gradle.kts
android {
    buildTypes {
        getByName("debug") {
            versionNameSuffix = "-debug"
            // FIXME: サブスクリプションのテストのため、一時的にコメントアウト
            // applicationIdSuffix = ".debug"
        }
  1. アプリからサブスクリプションを購入する
  2. 定期購入を解約する。
    • アプリのサブスクリプション画面 > 定期購入の管理 > 定期購入を解約

iOS

参考

準備

App Store Connectでの設定

  1. 銀行口座・納税フォームを登録する (opens in a new tab)
  2. App Store Small Business Program (opens in a new tab)に登録する
    • 手数料率が30%から15%に引き下がります。
  3. サブスクリプショングループを作成
    1. App Store Connect > 対象のアプリを選択 > 収益化 > サブスクリプション > サブスクリプショングループの作成ボタン押下
      • 参照名: voicevoxtts.subscription.premium
  4. サブスクリプションを作成
    • 参照名: monthly
    • 製品ID: voicevoxtts.subscription.premium.monthly
    • サブスクリプションの期間: 1か月
    • 配信可否: すべての国を選択
    • サブスクリプション価格
      1. アメリカ合衆国: $2.99 にして次へ
      2. 日本: ¥300にして次へ
      3. 確認ボタン押下
    • ローカリゼーション
      • 表示名: Premium (monthly)
      • 説明: アプリ広告を非表示にします。
    • 画像(任意): 未指定
    • 税金カテゴリ: アプリに最適なカテゴリをここ (opens in a new tab)から選択する。
    • 審査に関する情報
      • スクリーンショット: 最初はダミーでいいので、iPhone等で適当なスクリーンショットを貼って貼り付けてください。課金処理が実装できたら、サブスクリプション購入画面のスクリーンショットに差し替えてください。
      • 審査メモ: スクリーンショットの画像を表示する手順を記述します。
      1. 右下の設定タブを押下して設定画面を開く
      2. サブスクリプション > 現在のプランをタップ
      3. Premiumプランの[登録する]をタップする
  5. サブスクリプションのローカリゼーションを作成(※サブスクリプションのステータスは、ローカリゼーション作成後に「送信準備完了」になります)
    • 言語: 日本語
    • サブスクリプショングループ表示名: Premium
  6. App Store Connectでアプリの新規バージョンを作成
    1. App Store Connect > 対象のアプリを選択 > 左メニューのiOSアプリの右にある[+]ボタンをタップ > バージョン入力
    2. [App内課金とサブスクリプション]のセクションで、作成したサブスクリプションを追加する。 (opens in a new tab)
    3. [概要]の末尾に利用規約とプライバシーポリシーのリンクを追加する。 (opens in a new tab)
    # 規約とポリシー
    - 利用規約: https://shinagawa.app/voicevox-tts/terms-and-conditions/
    - プライバシーポリシー: https://shinagawa.app/voicevox-tts/privacy-policy/

Xcodeでの設定

実装

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)"
        }
    }
}

購入のテスト

  1. Sandboxアカウントを作成 (opens in a new tab)
    • App Store Connect > ユーザとアクセス > Sandbox > +ボタン > サンドボックスアカウントを作成
    • サンドボックスアカウントのメールアドレスは、ダミーでもかまいません。
  2. 設定アプリでSandboxアカウントでサインイン (opens in a new tab)
    • 設定アプリ > デベロッパ > SANDBOX APPLE ACCOUNT から、1で作成したアカウントでサインインする。
    • サインイン後に[Apple Account セキュリティ画面]が表示されたら、[その他のオプション]>[アップグレードしない]を選択
  3. アプリを実行する
    • XCode > TARGET > Signing & Capabilities の Bundle Identifier は本番と同じにする必要があります。
  4. アプリからサブスクリプションを購入する
  5. サブスクリプションをキャンセルする
    • 設定アプリ > デベロッパ > SANDBOX APPLE ACCOUNT > 管理 > サブスクリプション > サブスクリプションをキャンセル
© 品川アプリ.RSS