From e35bce4d19262023c021bba9a275d40e0f5339e4 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 4 Mar 2026 23:31:53 +0100 Subject: [PATCH] feat(upgrade): Add yearly subscription with trial to Google Play billing Rewrite SKU type system from data class to interface hierarchy supporting both IAP and subscriptions. Add subscription query, offer matching, and billing flow launch for yearly plans with optional free trial. FOSS: Add sponsor-gate with 10-second timer and snackbar nudge. --- .../darken/capod/upgrade/ui/UpgradeScreen.kt | 76 +++++-- .../capod/upgrade/ui/UpgradeViewModel.kt | 29 ++- app/src/foss/res/values/strings.xml | 2 + .../capod/common/upgrade/core/CapodSku.kt | 33 ++- .../common/upgrade/core/UpgradeRepoGplay.kt | 67 ++++-- .../core/client/BillingClientConnection.kt | 124 ++++++---- .../client/BillingClientConnectionProvider.kt | 7 +- .../common/upgrade/core/data/AvailableSku.kt | 5 - .../common/upgrade/core/data/BillingData.kt | 14 +- .../upgrade/core/data/BillingDataRepo.kt | 34 ++- .../common/upgrade/core/data/PurchasedSku.kt | 4 +- .../capod/common/upgrade/core/data/Sku.kt | 28 ++- .../common/upgrade/core/data/SkuDetails.kt | 8 + .../darken/capod/upgrade/ui/UpgradeScreen.kt | 211 ++++++++++++++---- .../capod/upgrade/ui/UpgradeViewModel.kt | 133 ++++++++++- app/src/main/res/values/strings.xml | 13 ++ 16 files changed, 625 insertions(+), 163 deletions(-) delete mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/AvailableSku.kt create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/SkuDetails.kt diff --git a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index 21809f91..31f70d39 100644 --- a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -3,16 +3,16 @@ package eu.darken.capod.upgrade.ui import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -31,22 +31,30 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper @@ -58,7 +66,32 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) { ErrorEventHandler(vm) NavigationEventHandler(vm) + val snackbarHostState = remember { SnackbarHostState() } + val returnedEarlyMessage = stringResource(R.string.upgrade_foss_sponsor_returned_early) + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + vm.onResume() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + LaunchedEffect(Unit) { + vm.sponsorEvents.collect { event -> + when (event) { + UpgradeViewModel.SponsorEvent.ReturnedTooEarly -> { + snackbarHostState.showSnackbar(returnedEarlyMessage) + } + } + } + } + UpgradeScreen( + snackbarHostState = snackbarHostState, onNavigateUp = { vm.navUp() }, onSponsor = { vm.sponsor() }, ) @@ -68,6 +101,7 @@ private data class Benefit(val icon: ImageVector, val textRes: Int) @Composable fun UpgradeScreen( + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onNavigateUp: () -> Unit, onSponsor: () -> Unit, ) { @@ -80,7 +114,14 @@ fun UpgradeScreen( Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support), ) - Box(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars)) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = MaterialTheme.colorScheme.surface, + ) { paddingValues -> + Box(modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars) + .padding(paddingValues) + ) { Column( modifier = Modifier .verticalScroll(rememberScrollState()) @@ -112,15 +153,7 @@ fun UpgradeScreen( } }, style = MaterialTheme.typography.headlineLarge, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.upgrade_capod_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(24.dp)) @@ -138,7 +171,7 @@ fun UpgradeScreen( ) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) Card( modifier = Modifier.fillMaxWidth(), @@ -160,7 +193,7 @@ fun UpgradeScreen( Icon( imageVector = benefit.icon, contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, + tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(16.dp), ) } @@ -175,7 +208,7 @@ fun UpgradeScreen( } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(16.dp)) Button( onClick = onSponsor, @@ -196,6 +229,13 @@ fun UpgradeScreen( ) } + Text( + text = stringResource(R.string.upgrade_foss_sponsor_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) } @@ -206,9 +246,11 @@ fun UpgradeScreen( Icon( imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, ) } } + } } @Preview2 diff --git a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt index 91d66de3..e20c11bb 100644 --- a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt @@ -1,8 +1,11 @@ package eu.darken.capod.upgrade.ui +import android.os.SystemClock +import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import eu.darken.capod.common.WebpageTool import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.flow.SingleEventFlow import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.upgrade.core.FossUpgrade import eu.darken.capod.common.upgrade.core.UpgradeControlFoss @@ -10,14 +13,36 @@ import javax.inject.Inject @HiltViewModel class UpgradeViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, dispatcherProvider: DispatcherProvider, private val upgradeControlFoss: UpgradeControlFoss, private val webpageTool: WebpageTool, ) : ViewModel4(dispatcherProvider) { + sealed interface SponsorEvent { + data object ReturnedTooEarly : SponsorEvent + } + + val sponsorEvents = SingleEventFlow() + fun sponsor() { - upgradeControlFoss.upgrade(FossUpgrade.Reason.DONATED) + savedStateHandle[KEY_SPONSOR_OPENED_AT] = SystemClock.elapsedRealtime() webpageTool.open("https://github.com/sponsors/d4rken") - navUp() + } + + fun onResume() { + val openedAt = savedStateHandle.get(KEY_SPONSOR_OPENED_AT) ?: return + savedStateHandle.remove(KEY_SPONSOR_OPENED_AT) + + if (SystemClock.elapsedRealtime() - openedAt >= 10_000L) { + upgradeControlFoss.upgrade(FossUpgrade.Reason.DONATED) + navUp() + } else { + sponsorEvents.tryEmit(SponsorEvent.ReturnedTooEarly) + } + } + + companion object { + private const val KEY_SPONSOR_OPENED_AT = "sponsor_opened_at" } } diff --git a/app/src/foss/res/values/strings.xml b/app/src/foss/res/values/strings.xml index 155a70c2..1f37523e 100644 --- a/app/src/foss/res/values/strings.xml +++ b/app/src/foss/res/values/strings.xml @@ -5,4 +5,6 @@ I spend all my money on AirPods CAPod FOSS is free and open source. If you find it useful, consider sponsoring development to help keep the project going. Sponsor development + No ads. No tracking. No Google Play lock-in. + Back already? Your support keeps CAPod alive. \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/CapodSku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/CapodSku.kt index 8520928f..d7327b1a 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/CapodSku.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/CapodSku.kt @@ -1,9 +1,34 @@ package eu.darken.capod.common.upgrade.core import eu.darken.capod.common.BuildConfigWrap -import eu.darken.capod.common.upgrade.core.data.AvailableSku import eu.darken.capod.common.upgrade.core.data.Sku -enum class CapodSku constructor(override val sku: Sku) : AvailableSku { - PRO_UPGRADE(Sku("${BuildConfigWrap.APPLICATION_ID}.iap.upgrade.pro")) -} \ No newline at end of file +interface CapodSku { + + interface Iap : CapodSku { + object PRO_UPGRADE : Sku.Iap, Iap { + override val id = "${BuildConfigWrap.APPLICATION_ID}.iap.upgrade.pro" + } + } + + interface Sub : CapodSku { + object PRO_UPGRADE : Sku.Subscription, Sub { + override val id = "upgrade.pro" + override val offers = setOf(BASE_OFFER, TRIAL_OFFER) + + object BASE_OFFER : Sku.Subscription.Offer { + override val basePlanId = "upgrade-pro-baseplan" + override val offerId: String? = null + } + + object TRIAL_OFFER : Sku.Subscription.Offer { + override val basePlanId = "upgrade-pro-baseplan" + override val offerId = "upgrade-pro-baseplan-trial" + } + } + } + + companion object { + val PRO_SKUS: Set = setOf(Sub.PRO_UPGRADE, Iap.PRO_UPGRADE) + } +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt index b2f38463..f55b1432 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -1,23 +1,28 @@ package eu.darken.capod.common.upgrade.core +import android.app.Activity import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag -import eu.darken.capod.common.flow.replayingShare import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.core.data.BillingData import eu.darken.capod.common.upgrade.core.data.BillingDataRepo import eu.darken.capod.common.upgrade.core.data.PurchasedSku +import eu.darken.capod.common.upgrade.core.data.Sku +import eu.darken.capod.common.upgrade.core.data.SkuDetails import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn import java.time.Instant import javax.inject.Inject import javax.inject.Singleton -import kotlin.math.pow import eu.darken.capod.common.datastore.valueBlocking @Singleton @@ -32,44 +37,49 @@ class UpgradeRepoGplay @Inject constructor( set(value) { billingCache.lastProStateAt.valueBlocking = value } override val upgradeInfo: Flow = billingDataRepo.billingData - .map { data -> // Only relinquish pro state if we haven't had it for a while + .map { data -> val now = System.currentTimeMillis() val proSku = data.getProSku() log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, data=${data}" } when { proSku != null -> { - // If we are pro refresh timestamp lastProStateAt = now - Info(billingData = data) + Info(billingData = data, upgrades = data.getProSkus()) } - (now - lastProStateAt) < 6 * 60 * 60 * 1000L -> { // 6 hours + (now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L -> { // 7 days log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" } Info(gracePeriod = true, billingData = null) } else -> { - Info(billingData = data) + Info(billingData = data, upgrades = data.getProSkus()) } } } - .retryWhen { error, attempt -> + .onStart { val now = System.currentTimeMillis() - log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, attempt=$attempt, error=$error" } - if ((now - lastProStateAt) < 24 * 60 * 60 * 1000L) { // 24 hours - log(TAG, VERBOSE) { "We are not pro, but were recently, and just and an error, what is GPlay doing???" } + if ((now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L) { emit(Info(gracePeriod = true, billingData = null)) } else { - emit(Info(billingData = null, error = if (attempt == 0L) error else null)) + emit(Info(billingData = null)) } - delay(30_000L * 2.0.pow(attempt.toDouble()).toLong()) - true } - .replayingShare(scope) + .catch { error -> + log(TAG, WARN) { "upgradeInfo error: ${error.asLog()}" } + val now = System.currentTimeMillis() + if ((now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L) { + emit(Info(gracePeriod = true, billingData = null)) + } else { + emit(Info(billingData = null, error = error)) + } + } + .shareIn(scope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1) data class Info( private val gracePeriod: Boolean = false, private val billingData: BillingData?, + val upgrades: Collection = emptyList(), override val error: Throwable? = null, ) : UpgradeRepo.Info { @@ -79,6 +89,12 @@ class UpgradeRepoGplay @Inject constructor( override val isPro: Boolean get() = billingData?.getProSku() != null || gracePeriod + val hasIap: Boolean + get() = upgrades.any { it.sku is Sku.Iap } + + val hasSub: Boolean + get() = upgrades.any { it.sku is Sku.Subscription } + override val upgradedAt: Instant? get() = billingData ?.getProSku() @@ -86,10 +102,23 @@ class UpgradeRepoGplay @Inject constructor( ?.let { Instant.ofEpochMilli(it) } } + suspend fun querySkus(vararg skus: Sku): Collection = billingDataRepo.querySkus(*skus) + + suspend fun launchBillingFlow( + activity: Activity, + sku: Sku, + offer: Sku.Subscription.Offer? = null, + ) = billingDataRepo.startBillingFlow(activity, sku, offer) + + suspend fun refresh(): BillingData = billingDataRepo.refresh() + companion object { private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus - .firstOrNull { it.sku == CapodSku.PRO_UPGRADE.sku } + .firstOrNull { it.sku in CapodSku.PRO_SKUS } + + private fun BillingData.getProSkus(): Collection = purchasedSkus + .filter { it.sku in CapodSku.PRO_SKUS } val TAG: String = logTag("Upgrade", "Gplay", "Control") } -} \ No newline at end of file +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt index 8321ea57..95bfd26a 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt @@ -15,6 +15,9 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.upgrade.core.data.Sku +import eu.darken.capod.common.upgrade.core.data.SkuDetails +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -25,41 +28,60 @@ data class BillingClientConnection( private val client: BillingClient, private val purchasesGlobal: Flow>, ) { - private val purchasesLocal = MutableStateFlow>(emptySet()) - val purchases: Flow> = combine(purchasesGlobal, purchasesLocal) { global, local -> + private val queryCacheIaps = MutableStateFlow?>(null) + private val queryCacheSubs = MutableStateFlow?>(null) + + val purchases: Flow> = combine( + purchasesGlobal, + queryCacheIaps, + queryCacheSubs, + ) { global, iaps, subs -> val combined = mutableSetOf() - combined.addAll(local) + iaps?.let { combined.addAll(it) } + subs?.let { combined.addAll(it) } global - .let { purchases -> purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } } + .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } .let { combined.addAll(it) } combined.sortedByDescending { it.purchaseTime } } .setupCommonEventHandlers(TAG) { "purchases" } - suspend fun queryPurchases(): Collection { + suspend fun refreshPurchases(): Collection = coroutineScope { + val iapsDeferred = async { queryPurchasesByType(BillingClient.ProductType.INAPP) } + val subsDeferred = async { queryPurchasesByType(BillingClient.ProductType.SUBS) } + + val iaps = iapsDeferred.await() + val subs = subsDeferred.await() + + queryCacheIaps.value = iaps + queryCacheSubs.value = subs + + (iaps + subs).sortedByDescending { it.purchaseTime } + } + + private suspend fun queryPurchasesByType(productType: String): Collection { val params = QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.INAPP) + .setProductType(productType) .build() - + val (result: BillingResult, purchases) = suspendCoroutine?>> { continuation -> client.queryPurchasesAsync(params) { result, purchases -> continuation.resume(result to purchases) } } - log(TAG) { "queryPurchases(): code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases" } + log(TAG) { "queryPurchases($productType): code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases" } if (!result.isSuccess) { - log(TAG, WARN) { "queryPurchases() failed" } - throw BillingResultException(result) + log(TAG, WARN) { "queryPurchases($productType) failed" } + throw BillingResultException(result) } else { requireNotNull(purchases) } - purchasesLocal.value = purchases return purchases } @@ -77,43 +99,67 @@ data class BillingClientConnection( if (!result.isSuccess) throw BillingResultException(result) } - suspend fun querySku(sku: Sku): Sku.Details { - val productDetails = QueryProductDetailsParams.Product.newBuilder().apply { - setProductType(BillingClient.ProductType.INAPP) - setProductId(sku.id) - }.build() + suspend fun querySkus(vararg skus: Sku): Collection { + val byType = skus.groupBy { it.type } + val results = mutableListOf() - val params = QueryProductDetailsParams.newBuilder().setProductList(listOf(productDetails)).build() + for ((type, typeSkus) in byType) { + val productType = when (type) { + Sku.Type.IAP -> BillingClient.ProductType.INAPP + Sku.Type.SUBSCRIPTION -> BillingClient.ProductType.SUBS + } - val (result, details) = suspendCoroutine>> { continuation -> - client.queryProductDetailsAsync(params) { billingResult, queryResult -> - val productDetailsList = queryResult.productDetailsList ?: emptyList() - continuation.resume(billingResult to productDetailsList) + val products = typeSkus.map { sku -> + QueryProductDetailsParams.Product.newBuilder().apply { + setProductType(productType) + setProductId(sku.id) + }.build() + } + + val params = QueryProductDetailsParams.newBuilder().setProductList(products).build() + + val (result, details) = suspendCoroutine>> { continuation -> + client.queryProductDetailsAsync(params) { billingResult, queryResult -> + continuation.resume(billingResult to queryResult.productDetailsList.orEmpty()) + } + } + + log(TAG) { + "querySkus(type=$type, skus=${typeSkus.map { it.id }}): code=${result.responseCode}, debug=${result.debugMessage}, details=$details" + } + + if (!result.isSuccess) throw BillingResultException(result) + + for (detail in details) { + val matchingSku = typeSkus.firstOrNull { it.id == detail.productId } + if (matchingSku != null) { + results.add(SkuDetails(matchingSku, detail)) + } } } - log(TAG) { - "querySku(sku=$sku): code=${result.responseCode}, debug=${result.debugMessage}), skuDetails=$details" - } - - if (!result.isSuccess) throw BillingResultException(result) - - if (details.isEmpty()) throw IllegalStateException("Unknown SKU, no details available.") - - return Sku.Details(sku, details) + return results } - suspend fun launchBillingFlow(activity: Activity, sku: Sku): BillingResult { - log(TAG) { "launchBillingFlow(activity=$activity, sku=$sku)" } - val skuDetails = querySku(sku) - return launchBillingFlow(activity, skuDetails) - } + suspend fun launchBillingFlow( + activity: Activity, + sku: Sku, + offer: Sku.Subscription.Offer? = null, + ): BillingResult { + log(TAG) { "launchBillingFlow(activity=$activity, sku=$sku, offer=$offer)" } - suspend fun launchBillingFlow(activity: Activity, skuDetails: Sku.Details): BillingResult { - log(TAG) { "launchBillingFlow(activity=$activity, skuDetails=$skuDetails)" } + val skuDetails = querySkus(sku).firstOrNull() + ?: throw IllegalStateException("Unknown SKU, no details available for ${sku.id}") val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().apply { - setProductDetails(skuDetails.details.first()) + setProductDetails(skuDetails.details) + if (sku is Sku.Subscription && offer != null) { + val offerDetails = skuDetails.details.subscriptionOfferDetails + ?.firstOrNull { offer.matches(it) } + if (offerDetails != null) { + setOfferToken(offerDetails.offerToken) + } + } }.build() val billingFlowParams = BillingFlowParams.newBuilder().apply { @@ -126,4 +172,4 @@ data class BillingClientConnection( companion object { val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientConnection") } -} \ No newline at end of file +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt index 499cdc22..61f1e19c 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt @@ -37,6 +37,7 @@ class BillingClientConnectionProvider @Inject constructor( enablePendingPurchases( PendingPurchasesParams.newBuilder() .enableOneTimeProducts() + .enablePrepaidPlans() .build() ) setListener { result, purchases -> @@ -69,10 +70,10 @@ class BillingClientConnectionProvider @Inject constructor( launch { try { - purchasePublisher.value = connection.queryPurchases() - log(TAG) { "Initial IAP query successful." } + connection.refreshPurchases() + log(TAG) { "Initial purchase query successful." } } catch (e: Exception) { - log(TAG, ERROR) { "Initial IAP query failed:\n${e.asLog()}" } + log(TAG, ERROR) { "Initial purchase query failed:\n${e.asLog()}" } } } } diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/AvailableSku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/AvailableSku.kt deleted file mode 100644 index b4b5d25d..00000000 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/AvailableSku.kt +++ /dev/null @@ -1,5 +0,0 @@ -package eu.darken.capod.common.upgrade.core.data - -interface AvailableSku { - val sku: Sku -} \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt index e5e3e34e..56e825b8 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt @@ -1,14 +1,16 @@ package eu.darken.capod.common.upgrade.core.data import com.android.billingclient.api.Purchase +import eu.darken.capod.common.upgrade.core.CapodSku data class BillingData( val purchases: Collection ) { val purchasedSkus: Collection - get() = purchases.map { it.toPurchasedSku() }.flatten() - - private fun Purchase.toPurchasedSku(): Collection = skus.map { - PurchasedSku(Sku(it), this) - } -} \ No newline at end of file + get() = purchases.flatMap { purchase -> + purchase.products.mapNotNull { productId -> + val sku = CapodSku.PRO_SKUS.singleOrNull { it.id == productId } + sku?.let { PurchasedSku(it, purchase) } + } + } +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt index 43d6308b..f7d381cc 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt @@ -24,13 +24,16 @@ class BillingDataRepo @Inject constructor( ) { private val connectionProvider = billingClientConnectionProvider.connection - .catch { log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" } } + .catch { + log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" } + throw it + } .replayingShare(scope) val billingData: Flow = connectionProvider .flatMapLatest { it.purchases } .map { BillingData(purchases = it) } - .setupCommonEventHandlers(TAG) { "iapData" } + .setupCommonEventHandlers(TAG) { "billingData" } .replayingShare(scope) init { @@ -84,26 +87,35 @@ class BillingDataRepo @Inject constructor( .launchIn(scope) } - suspend fun getIapData(): BillingData = try { + suspend fun refresh(): BillingData = try { val clientConnection = connectionProvider.first() - val iaps = clientConnection.queryPurchases() + val purchases = clientConnection.refreshPurchases() - BillingData( - purchases = iaps - ) + BillingData(purchases = purchases) } catch (e: Exception) { throw e.tryMapUserFriendly() } - suspend fun startIapFlow(activity: Activity, sku: Sku) { + suspend fun querySkus(vararg skus: Sku): Collection = try { + val clientConnection = connectionProvider.first() + clientConnection.querySkus(*skus) + } catch (e: Exception) { + throw e.tryMapUserFriendly() + } + + suspend fun startBillingFlow( + activity: Activity, + sku: Sku, + offer: Sku.Subscription.Offer? = null, + ) { try { val clientConnection = connectionProvider.first() - clientConnection.launchBillingFlow(activity, sku) + clientConnection.launchBillingFlow(activity, sku, offer) } catch (e: Exception) { - log(TAG, WARN) { "Failed to start IAP flow:\n${e.asLog()}" } + log(TAG, WARN) { "Failed to start billing flow:\n${e.asLog()}" } val ignoredCodes = listOf(3, 6) if (e !is BillingResultException || !e.result.responseCode.let { ignoredCodes.contains(it) }) { - Bugs.report(TAG, "IAP flow failed for $sku", e) + Bugs.report(TAG, "Billing flow failed for $sku", e) } throw e.tryMapUserFriendly() diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/PurchasedSku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/PurchasedSku.kt index 9b8f4f14..89b50c06 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/PurchasedSku.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/PurchasedSku.kt @@ -3,5 +3,5 @@ package eu.darken.capod.common.upgrade.core.data import com.android.billingclient.api.Purchase data class PurchasedSku(val sku: Sku, val purchase: Purchase) { - override fun toString(): String = "IAP(sku=$sku, purchase=${purchase.skus})" -} \ No newline at end of file + override fun toString(): String = "PurchasedSku(sku=$sku, purchase=${purchase.products})" +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt index 74ae50d1..06586bf2 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt @@ -2,11 +2,25 @@ package eu.darken.capod.common.upgrade.core.data import com.android.billingclient.api.ProductDetails -data class Sku( +interface Sku { val id: String -) { - data class Details( - val sku: Sku, - val details: Collection, - ) -} \ No newline at end of file + val type: Type + + interface Iap : Sku { + override val type: Type get() = Type.IAP + } + + interface Subscription : Sku { + override val type: Type get() = Type.SUBSCRIPTION + val offers: Collection + + interface Offer { + val basePlanId: String + val offerId: String? + fun matches(target: ProductDetails.SubscriptionOfferDetails): Boolean = + basePlanId == target.basePlanId && offerId == target.offerId + } + } + + enum class Type { IAP, SUBSCRIPTION } +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/SkuDetails.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/SkuDetails.kt new file mode 100644 index 00000000..7a176ea6 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/SkuDetails.kt @@ -0,0 +1,8 @@ +package eu.darken.capod.common.upgrade.core.data + +import com.android.billingclient.api.ProductDetails + +data class SkuDetails( + val sku: Sku, + val details: ProductDetails, +) diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index 815ff49f..1d2689d2 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -5,16 +5,16 @@ import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -31,14 +31,18 @@ import androidx.compose.material.icons.twotone.Widgets import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -59,6 +63,7 @@ import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler + @Composable fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) { ErrorEventHandler(vm) @@ -66,6 +71,7 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) { val context = LocalContext.current val activity = context as? Activity + val state by vm.state.collectAsState() LaunchedEffect(Unit) { vm.events.collect { event -> @@ -73,7 +79,7 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) { UpgradeViewModel.UpgradeEvent.RestoreFailed -> { Toast.makeText( context, - R.string.upgrades_no_purchases_found_check_account, + R.string.upgrade_screen_restore_purchase_message, Toast.LENGTH_LONG ).show() } @@ -81,9 +87,23 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) { } } + LaunchedEffect(Unit) { + vm.billingEvents.collect { event -> + activity ?: return@collect + when (event) { + UpgradeViewModel.BillingEvent.LaunchIap -> vm.launchBillingIap(activity) + UpgradeViewModel.BillingEvent.LaunchSubscription -> vm.launchBillingSubscription(activity) + UpgradeViewModel.BillingEvent.LaunchSubscriptionTrial -> vm.launchBillingSubscriptionTrial(activity) + } + } + } + UpgradeScreen( + state = state, onNavigateUp = { vm.navUp() }, - onUpgrade = { activity?.let { vm.startPurchase(it) } }, + onSubscription = { vm.onGoSubscription() }, + onSubscriptionTrial = { vm.onGoSubscriptionTrial() }, + onIap = { vm.onGoIap() }, onRestore = { vm.restorePurchase() }, ) } @@ -92,8 +112,11 @@ private data class Benefit(val icon: ImageVector, val textRes: Int) @Composable fun UpgradeScreen( + state: UpgradeViewModel.Pricing?, onNavigateUp: () -> Unit, - onUpgrade: () -> Unit, + onSubscription: () -> Unit, + onSubscriptionTrial: () -> Unit, + onIap: () -> Unit, onRestore: () -> Unit, ) { val benefits = listOf( @@ -137,15 +160,7 @@ fun UpgradeScreen( } }, style = MaterialTheme.typography.headlineLarge, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.upgrade_capod_description), - style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(24.dp)) @@ -185,7 +200,7 @@ fun UpgradeScreen( Icon( imageVector = benefit.icon, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(16.dp), ) } @@ -200,32 +215,18 @@ fun UpgradeScreen( } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(24.dp)) - Button( - onClick = onUpgrade, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(12.dp), - ) { - Icon( - imageVector = Icons.TwoTone.Stars, - contentDescription = null, - modifier = Modifier.size(20.dp), + if (state == null) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp)) + } else { + PricingContent( + state = state, + onSubscription = onSubscription, + onSubscriptionTrial = onSubscriptionTrial, + onIap = onIap, + onRestore = onRestore, ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.general_upgrade_action), - style = MaterialTheme.typography.titleMedium, - ) - } - - TextButton( - onClick = onRestore, - modifier = Modifier.padding(top = 4.dp), - ) { - Text(text = stringResource(R.string.upgrade_restore_action)) } Spacer(modifier = Modifier.height(24.dp)) @@ -238,17 +239,145 @@ fun UpgradeScreen( Icon( imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, ) } } } +@Composable +private fun PricingContent( + state: UpgradeViewModel.Pricing, + onSubscription: () -> Unit, + onSubscriptionTrial: () -> Unit, + onIap: () -> Unit, + onRestore: () -> Unit, +) { + // Subscription button (primary) + if (state.subAvailable) { + val subscriptionAction = if (state.hasTrialOffer) onSubscriptionTrial else onSubscription + + val subscriptionLabel = if (state.hasTrialOffer) { + stringResource(R.string.upgrade_screen_subscription_trial_action) + } else { + stringResource(R.string.upgrade_screen_subscription_action) + } + + Button( + onClick = subscriptionAction, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon( + imageVector = Icons.TwoTone.Stars, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = subscriptionLabel, + style = MaterialTheme.typography.titleMedium, + ) + } + + if (state.subPrice != null) { + Text( + text = stringResource(R.string.upgrade_screen_subscription_action_hint, state.subPrice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + } + + // IAP button (secondary) + if (state.iapAvailable) { + FilledTonalButton( + onClick = onIap, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = stringResource(R.string.upgrade_screen_iap_action), + style = MaterialTheme.typography.titleMedium, + ) + } + + if (state.iapPrice != null) { + Text( + text = stringResource(R.string.upgrade_screen_iap_action_hint, state.iapPrice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + + // If no details loaded at all, show a simple fallback upgrade button + if (!state.subAvailable && !state.iapAvailable) { + Button( + onClick = onIap, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon( + imageVector = Icons.TwoTone.Stars, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.general_upgrade_action), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.upgrade_screen_options_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = onRestore, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action)) + } +} + @Preview2 @Composable private fun UpgradeScreenPreview() = PreviewWrapper { UpgradeScreen( + state = UpgradeViewModel.Pricing( + subPrice = "€3.49", + iapPrice = "€6.49", + hasTrialOffer = true, + ), onNavigateUp = {}, - onUpgrade = {}, + onSubscription = {}, + onSubscriptionTrial = {}, + onIap = {}, onRestore = {}, ) } diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt index 56762e5c..67ed51c7 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt @@ -3,30 +3,106 @@ package eu.darken.capod.upgrade.ui import android.app.Activity import dagger.hilt.android.lifecycle.HiltViewModel import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.SingleEventFlow import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.upgrade.core.CapodSku import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay -import eu.darken.capod.common.upgrade.core.data.BillingDataRepo +import eu.darken.capod.common.upgrade.core.data.SkuDetails +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject @HiltViewModel class UpgradeViewModel @Inject constructor( private val dispatcherProvider: DispatcherProvider, private val upgradeRepo: UpgradeRepoGplay, - private val billingDataRepo: BillingDataRepo, ) : ViewModel4(dispatcherProvider) { sealed interface UpgradeEvent { data object RestoreFailed : UpgradeEvent } + sealed interface BillingEvent { + data object LaunchIap : BillingEvent + data object LaunchSubscription : BillingEvent + data object LaunchSubscriptionTrial : BillingEvent + } + + data class Pricing( + val iap: SkuDetails? = null, + val sub: SkuDetails? = null, + val hasIap: Boolean = false, + val hasSub: Boolean = false, + val subPrice: String? = null, + val iapPrice: String? = null, + val hasTrialOffer: Boolean = false, + ) { + val subAvailable: Boolean get() = sub != null || subPrice != null + val iapAvailable: Boolean get() = iap != null || iapPrice != null + } + val events = SingleEventFlow() + val billingEvents = SingleEventFlow() + + val state: StateFlow = flow { + val iapDetails = try { + withTimeoutOrNull(5_000L) { + upgradeRepo.querySkus(CapodSku.Iap.PRO_UPGRADE).firstOrNull() + } + } catch (e: Exception) { + log(TAG, WARN) { "Failed to query IAP SKU: ${e.asLog()}" } + null + } + + val subDetails = try { + withTimeoutOrNull(5_000L) { + upgradeRepo.querySkus(CapodSku.Sub.PRO_UPGRADE).firstOrNull() + } + } catch (e: Exception) { + log(TAG, WARN) { "Failed to query Sub SKU: ${e.asLog()}" } + null + } + + val info = try { + withTimeoutOrNull(5_000L) { + upgradeRepo.upgradeInfo.first() as? UpgradeRepoGplay.Info + } + } catch (e: Exception) { + log(TAG, WARN) { "Failed to get upgrade info: ${e.asLog()}" } + null + } + + val subProductDetails = subDetails?.details + val baseOffer = subProductDetails?.subscriptionOfferDetails?.firstOrNull { offerDetails -> + CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) + } + val hasTrialOffer = subProductDetails?.subscriptionOfferDetails?.any { offerDetails -> + CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) + } == true + + emit( + Pricing( + iap = iapDetails, + sub = subDetails, + hasIap = info?.hasIap == true, + hasSub = info?.hasSub == true, + subPrice = baseOffer?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice, + iapPrice = iapDetails?.details?.oneTimePurchaseOfferDetails?.formattedPrice, + hasTrialOffer = hasTrialOffer, + ) + ) + }.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), null) init { upgradeRepo.upgradeInfo @@ -39,22 +115,65 @@ class UpgradeViewModel @Inject constructor( .launchIn(vmScope) } - fun startPurchase(activity: Activity) = launch { + fun onGoIap() { + billingEvents.tryEmit(BillingEvent.LaunchIap) + } + + fun onGoSubscription() { + billingEvents.tryEmit(BillingEvent.LaunchSubscription) + } + + fun onGoSubscriptionTrial() { + billingEvents.tryEmit(BillingEvent.LaunchSubscriptionTrial) + } + + fun launchBillingIap(activity: Activity) = launch { try { withContext(dispatcherProvider.Main) { - billingDataRepo.startIapFlow(activity, CapodSku.PRO_UPGRADE.sku) + upgradeRepo.launchBillingFlow(activity, CapodSku.Iap.PRO_UPGRADE) } } catch (e: Exception) { - log(TAG) { "startIapFlow failed: $e" } + log(TAG) { "launchBillingIap failed: $e" } + errorEvents.emitBlocking(e) + } + } + + fun launchBillingSubscription(activity: Activity) = launch { + try { + withContext(dispatcherProvider.Main) { + upgradeRepo.launchBillingFlow( + activity, + CapodSku.Sub.PRO_UPGRADE, + CapodSku.Sub.PRO_UPGRADE.BASE_OFFER, + ) + } + } catch (e: Exception) { + log(TAG) { "launchBillingSubscription failed: $e" } + errorEvents.emitBlocking(e) + } + } + + fun launchBillingSubscriptionTrial(activity: Activity) = launch { + try { + withContext(dispatcherProvider.Main) { + upgradeRepo.launchBillingFlow( + activity, + CapodSku.Sub.PRO_UPGRADE, + CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER, + ) + } + } catch (e: Exception) { + log(TAG) { "launchBillingSubscriptionTrial failed: $e" } errorEvents.emitBlocking(e) } } fun restorePurchase() = launch { try { - val data = billingDataRepo.getIapData() + val data = upgradeRepo.refresh() log(TAG) { "Restore check: $data" } - if (data.purchasedSkus.any { it.sku == CapodSku.PRO_UPGRADE.sku }) { + val info = upgradeRepo.upgradeInfo.first() + if (info.isPro) { log(TAG) { "Pro purchase found" } } else { log(TAG) { "No pro purchase found" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ffc4588..7f8df2a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,19 @@ Home screen widgets Support the developer + Same features, different pricing. The subscription includes a free trial and can be cancelled at any time. + + Start free trial + Subscribe yearly + %s/year + Buy once + One-time purchase: %s + Restore purchase + No purchases found. Are you using the right account? + If you\'ve recently purchased, it may take a moment for Google Play to sync. + Try again in a few minutes if your purchase doesn\'t appear. + Make sure you\'re signed in with the same Google account used for the purchase. + Monitor mode Under which circumstances this app monitors Bluetooth data. Extra notification