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.
This commit is contained in:
darken
2026-03-04 22:38:23 +00:00
committed by Matthias Urhahn
parent 1a196689db
commit e35bce4d19
16 changed files with 625 additions and 163 deletions
@@ -3,16 +3,16 @@ package eu.darken.capod.upgrade.ui
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -31,22 +31,30 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
@@ -58,7 +66,32 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
ErrorEventHandler(vm) ErrorEventHandler(vm)
NavigationEventHandler(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( UpgradeScreen(
snackbarHostState = snackbarHostState,
onNavigateUp = { vm.navUp() }, onNavigateUp = { vm.navUp() },
onSponsor = { vm.sponsor() }, onSponsor = { vm.sponsor() },
) )
@@ -68,6 +101,7 @@ private data class Benefit(val icon: ImageVector, val textRes: Int)
@Composable @Composable
fun UpgradeScreen( fun UpgradeScreen(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onSponsor: () -> Unit, onSponsor: () -> Unit,
) { ) {
@@ -80,7 +114,14 @@ fun UpgradeScreen(
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support), 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( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
@@ -112,15 +153,7 @@ fun UpgradeScreen(
} }
}, },
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
) color = MaterialTheme.colorScheme.onSurface,
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)) 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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -160,7 +193,7 @@ fun UpgradeScreen(
Icon( Icon(
imageVector = benefit.icon, imageVector = benefit.icon,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
} }
@@ -175,7 +208,7 @@ fun UpgradeScreen(
} }
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
onClick = onSponsor, 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)) Spacer(modifier = Modifier.height(24.dp))
} }
@@ -206,9 +246,11 @@ fun UpgradeScreen(
Icon( Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
) )
} }
} }
}
} }
@Preview2 @Preview2
@@ -1,8 +1,11 @@
package eu.darken.capod.upgrade.ui package eu.darken.capod.upgrade.ui
import android.os.SystemClock
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider 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.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.FossUpgrade import eu.darken.capod.common.upgrade.core.FossUpgrade
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
@@ -10,14 +13,36 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class UpgradeViewModel @Inject constructor( class UpgradeViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
dispatcherProvider: DispatcherProvider, dispatcherProvider: DispatcherProvider,
private val upgradeControlFoss: UpgradeControlFoss, private val upgradeControlFoss: UpgradeControlFoss,
private val webpageTool: WebpageTool, private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) { ) : ViewModel4(dispatcherProvider) {
sealed interface SponsorEvent {
data object ReturnedTooEarly : SponsorEvent
}
val sponsorEvents = SingleEventFlow<SponsorEvent>()
fun sponsor() { fun sponsor() {
upgradeControlFoss.upgrade(FossUpgrade.Reason.DONATED) savedStateHandle[KEY_SPONSOR_OPENED_AT] = SystemClock.elapsedRealtime()
webpageTool.open("https://github.com/sponsors/d4rken") webpageTool.open("https://github.com/sponsors/d4rken")
navUp() }
fun onResume() {
val openedAt = savedStateHandle.get<Long>(KEY_SPONSOR_OPENED_AT) ?: return
savedStateHandle.remove<Long>(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"
} }
} }
+2
View File
@@ -5,4 +5,6 @@
<string name="foss_upgrade_no_money_label">I spend all my money on AirPods</string> <string name="foss_upgrade_no_money_label">I spend all my money on AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS is free and open source. If you find it useful, consider sponsoring development to help keep the project going.</string> <string name="upgrade_foss_preamble">CAPod FOSS is free and open source. If you find it useful, consider sponsoring development to help keep the project going.</string>
<string name="upgrade_foss_sponsor_action">Sponsor development</string> <string name="upgrade_foss_sponsor_action">Sponsor development</string>
<string name="upgrade_foss_sponsor_subtitle">No ads. No tracking. No Google Play lock-in.</string>
<string name="upgrade_foss_sponsor_returned_early">Back already? Your support keeps CAPod alive.</string>
</resources> </resources>
@@ -1,9 +1,34 @@
package eu.darken.capod.common.upgrade.core package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.upgrade.core.data.AvailableSku
import eu.darken.capod.common.upgrade.core.data.Sku import eu.darken.capod.common.upgrade.core.data.Sku
enum class CapodSku constructor(override val sku: Sku) : AvailableSku { interface CapodSku {
PRO_UPGRADE(Sku("${BuildConfigWrap.APPLICATION_ID}.iap.upgrade.pro"))
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<Sku> = setOf(Sub.PRO_UPGRADE, Iap.PRO_UPGRADE)
}
} }
@@ -1,23 +1,28 @@
package eu.darken.capod.common.upgrade.core package eu.darken.capod.common.upgrade.core
import android.app.Activity
import eu.darken.capod.common.coroutine.AppScope 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.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.log
import eu.darken.capod.common.debug.logging.logTag 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.UpgradeRepo
import eu.darken.capod.common.upgrade.core.data.BillingData 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.BillingDataRepo
import eu.darken.capod.common.upgrade.core.data.PurchasedSku 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.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map 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 java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.math.pow
import eu.darken.capod.common.datastore.valueBlocking import eu.darken.capod.common.datastore.valueBlocking
@Singleton @Singleton
@@ -32,44 +37,49 @@ class UpgradeRepoGplay @Inject constructor(
set(value) { billingCache.lastProStateAt.valueBlocking = value } set(value) { billingCache.lastProStateAt.valueBlocking = value }
override val upgradeInfo: Flow<UpgradeRepo.Info> = billingDataRepo.billingData override val upgradeInfo: Flow<UpgradeRepo.Info> = billingDataRepo.billingData
.map { data -> // Only relinquish pro state if we haven't had it for a while .map { data ->
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val proSku = data.getProSku() val proSku = data.getProSku()
log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, data=${data}" } log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, data=${data}" }
when { when {
proSku != null -> { proSku != null -> {
// If we are pro refresh timestamp
lastProStateAt = now 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?" } log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null) Info(gracePeriod = true, billingData = null)
} }
else -> { else -> {
Info(billingData = data) Info(billingData = data, upgrades = data.getProSkus())
} }
} }
} }
.retryWhen { error, attempt -> .onStart {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, attempt=$attempt, error=$error" } if ((now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L) {
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???" }
emit(Info(gracePeriod = true, billingData = null)) emit(Info(gracePeriod = true, billingData = null))
} else { } 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( data class Info(
private val gracePeriod: Boolean = false, private val gracePeriod: Boolean = false,
private val billingData: BillingData?, private val billingData: BillingData?,
val upgrades: Collection<PurchasedSku> = emptyList(),
override val error: Throwable? = null, override val error: Throwable? = null,
) : UpgradeRepo.Info { ) : UpgradeRepo.Info {
@@ -79,6 +89,12 @@ class UpgradeRepoGplay @Inject constructor(
override val isPro: Boolean override val isPro: Boolean
get() = billingData?.getProSku() != null || gracePeriod 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? override val upgradedAt: Instant?
get() = billingData get() = billingData
?.getProSku() ?.getProSku()
@@ -86,9 +102,22 @@ class UpgradeRepoGplay @Inject constructor(
?.let { Instant.ofEpochMilli(it) } ?.let { Instant.ofEpochMilli(it) }
} }
suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> = 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 { companion object {
private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus 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<PurchasedSku> = purchasedSkus
.filter { it.sku in CapodSku.PRO_SKUS }
val TAG: String = logTag("Upgrade", "Gplay", "Control") val TAG: String = logTag("Upgrade", "Gplay", "Control")
} }
@@ -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.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.core.data.Sku 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -25,23 +28,43 @@ data class BillingClientConnection(
private val client: BillingClient, private val client: BillingClient,
private val purchasesGlobal: Flow<Collection<Purchase>>, private val purchasesGlobal: Flow<Collection<Purchase>>,
) { ) {
private val purchasesLocal = MutableStateFlow<Collection<Purchase>>(emptySet()) private val queryCacheIaps = MutableStateFlow<Collection<Purchase>?>(null)
val purchases: Flow<Collection<Purchase>> = combine(purchasesGlobal, purchasesLocal) { global, local -> private val queryCacheSubs = MutableStateFlow<Collection<Purchase>?>(null)
val purchases: Flow<Collection<Purchase>> = combine(
purchasesGlobal,
queryCacheIaps,
queryCacheSubs,
) { global, iaps, subs ->
val combined = mutableSetOf<Purchase>() val combined = mutableSetOf<Purchase>()
combined.addAll(local) iaps?.let { combined.addAll(it) }
subs?.let { combined.addAll(it) }
global global
.let { purchases -> purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } } .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
.let { combined.addAll(it) } .let { combined.addAll(it) }
combined.sortedByDescending { it.purchaseTime } combined.sortedByDescending { it.purchaseTime }
} }
.setupCommonEventHandlers(TAG) { "purchases" } .setupCommonEventHandlers(TAG) { "purchases" }
suspend fun queryPurchases(): Collection<Purchase> { suspend fun refreshPurchases(): Collection<Purchase> = 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<Purchase> {
val params = QueryPurchasesParams.newBuilder() val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP) .setProductType(productType)
.build() .build()
val (result: BillingResult, purchases) = suspendCoroutine<Pair<BillingResult, Collection<Purchase>?>> { continuation -> val (result: BillingResult, purchases) = suspendCoroutine<Pair<BillingResult, Collection<Purchase>?>> { continuation ->
@@ -50,16 +73,15 @@ data class BillingClientConnection(
} }
} }
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) { if (!result.isSuccess) {
log(TAG, WARN) { "queryPurchases() failed" } log(TAG, WARN) { "queryPurchases($productType) failed" }
throw BillingResultException(result) throw BillingResultException(result)
} else { } else {
requireNotNull(purchases) requireNotNull(purchases)
} }
purchasesLocal.value = purchases
return purchases return purchases
} }
@@ -77,43 +99,67 @@ data class BillingClientConnection(
if (!result.isSuccess) throw BillingResultException(result) if (!result.isSuccess) throw BillingResultException(result)
} }
suspend fun querySku(sku: Sku): Sku.Details { suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> {
val productDetails = QueryProductDetailsParams.Product.newBuilder().apply { val byType = skus.groupBy { it.type }
setProductType(BillingClient.ProductType.INAPP) val results = mutableListOf<SkuDetails>()
setProductId(sku.id)
}.build()
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<Pair<BillingResult, List<ProductDetails>>> { continuation -> val products = typeSkus.map { sku ->
client.queryProductDetailsAsync(params) { billingResult, queryResult -> QueryProductDetailsParams.Product.newBuilder().apply {
val productDetailsList = queryResult.productDetailsList ?: emptyList() setProductType(productType)
continuation.resume(billingResult to productDetailsList) setProductId(sku.id)
}.build()
}
val params = QueryProductDetailsParams.newBuilder().setProductList(products).build()
val (result, details) = suspendCoroutine<Pair<BillingResult, List<ProductDetails>>> { 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) { return results
"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)
} }
suspend fun launchBillingFlow(activity: Activity, sku: Sku): BillingResult { suspend fun launchBillingFlow(
log(TAG) { "launchBillingFlow(activity=$activity, sku=$sku)" } activity: Activity,
val skuDetails = querySku(sku) sku: Sku,
return launchBillingFlow(activity, skuDetails) offer: Sku.Subscription.Offer? = null,
} ): BillingResult {
log(TAG) { "launchBillingFlow(activity=$activity, sku=$sku, offer=$offer)" }
suspend fun launchBillingFlow(activity: Activity, skuDetails: Sku.Details): BillingResult { val skuDetails = querySkus(sku).firstOrNull()
log(TAG) { "launchBillingFlow(activity=$activity, skuDetails=$skuDetails)" } ?: throw IllegalStateException("Unknown SKU, no details available for ${sku.id}")
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().apply { 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() }.build()
val billingFlowParams = BillingFlowParams.newBuilder().apply { val billingFlowParams = BillingFlowParams.newBuilder().apply {
@@ -37,6 +37,7 @@ class BillingClientConnectionProvider @Inject constructor(
enablePendingPurchases( enablePendingPurchases(
PendingPurchasesParams.newBuilder() PendingPurchasesParams.newBuilder()
.enableOneTimeProducts() .enableOneTimeProducts()
.enablePrepaidPlans()
.build() .build()
) )
setListener { result, purchases -> setListener { result, purchases ->
@@ -69,10 +70,10 @@ class BillingClientConnectionProvider @Inject constructor(
launch { launch {
try { try {
purchasePublisher.value = connection.queryPurchases() connection.refreshPurchases()
log(TAG) { "Initial IAP query successful." } log(TAG) { "Initial purchase query successful." }
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "Initial IAP query failed:\n${e.asLog()}" } log(TAG, ERROR) { "Initial purchase query failed:\n${e.asLog()}" }
} }
} }
} }
@@ -1,5 +0,0 @@
package eu.darken.capod.common.upgrade.core.data
interface AvailableSku {
val sku: Sku
}
@@ -1,14 +1,16 @@
package eu.darken.capod.common.upgrade.core.data package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.Purchase import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.CapodSku
data class BillingData( data class BillingData(
val purchases: Collection<Purchase> val purchases: Collection<Purchase>
) { ) {
val purchasedSkus: Collection<PurchasedSku> val purchasedSkus: Collection<PurchasedSku>
get() = purchases.map { it.toPurchasedSku() }.flatten() get() = purchases.flatMap { purchase ->
purchase.products.mapNotNull { productId ->
private fun Purchase.toPurchasedSku(): Collection<PurchasedSku> = skus.map { val sku = CapodSku.PRO_SKUS.singleOrNull { it.id == productId }
PurchasedSku(Sku(it), this) sku?.let { PurchasedSku(it, purchase) }
} }
}
} }
@@ -24,13 +24,16 @@ class BillingDataRepo @Inject constructor(
) { ) {
private val connectionProvider = billingClientConnectionProvider.connection 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) .replayingShare(scope)
val billingData: Flow<BillingData> = connectionProvider val billingData: Flow<BillingData> = connectionProvider
.flatMapLatest { it.purchases } .flatMapLatest { it.purchases }
.map { BillingData(purchases = it) } .map { BillingData(purchases = it) }
.setupCommonEventHandlers(TAG) { "iapData" } .setupCommonEventHandlers(TAG) { "billingData" }
.replayingShare(scope) .replayingShare(scope)
init { init {
@@ -84,26 +87,35 @@ class BillingDataRepo @Inject constructor(
.launchIn(scope) .launchIn(scope)
} }
suspend fun getIapData(): BillingData = try { suspend fun refresh(): BillingData = try {
val clientConnection = connectionProvider.first() val clientConnection = connectionProvider.first()
val iaps = clientConnection.queryPurchases() val purchases = clientConnection.refreshPurchases()
BillingData( BillingData(purchases = purchases)
purchases = iaps
)
} catch (e: Exception) { } catch (e: Exception) {
throw e.tryMapUserFriendly() throw e.tryMapUserFriendly()
} }
suspend fun startIapFlow(activity: Activity, sku: Sku) { suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> = 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 { try {
val clientConnection = connectionProvider.first() val clientConnection = connectionProvider.first()
clientConnection.launchBillingFlow(activity, sku) clientConnection.launchBillingFlow(activity, sku, offer)
} catch (e: Exception) { } 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) val ignoredCodes = listOf(3, 6)
if (e !is BillingResultException || !e.result.responseCode.let { ignoredCodes.contains(it) }) { 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() throw e.tryMapUserFriendly()
@@ -3,5 +3,5 @@ package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.Purchase import com.android.billingclient.api.Purchase
data class PurchasedSku(val sku: Sku, val purchase: Purchase) { data class PurchasedSku(val sku: Sku, val purchase: Purchase) {
override fun toString(): String = "IAP(sku=$sku, purchase=${purchase.skus})" override fun toString(): String = "PurchasedSku(sku=$sku, purchase=${purchase.products})"
} }
@@ -2,11 +2,25 @@ package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails
data class Sku( interface Sku {
val id: String val id: String
) { val type: Type
data class Details(
val sku: Sku, interface Iap : Sku {
val details: Collection<ProductDetails>, override val type: Type get() = Type.IAP
) }
interface Subscription : Sku {
override val type: Type get() = Type.SUBSCRIPTION
val offers: Collection<Offer>
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 }
} }
@@ -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,
)
@@ -5,16 +5,16 @@ import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler
@Composable @Composable
fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) { fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
ErrorEventHandler(vm) ErrorEventHandler(vm)
@@ -66,6 +71,7 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? Activity val activity = context as? Activity
val state by vm.state.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
vm.events.collect { event -> vm.events.collect { event ->
@@ -73,7 +79,7 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
UpgradeViewModel.UpgradeEvent.RestoreFailed -> { UpgradeViewModel.UpgradeEvent.RestoreFailed -> {
Toast.makeText( Toast.makeText(
context, context,
R.string.upgrades_no_purchases_found_check_account, R.string.upgrade_screen_restore_purchase_message,
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() ).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( UpgradeScreen(
state = state,
onNavigateUp = { vm.navUp() }, onNavigateUp = { vm.navUp() },
onUpgrade = { activity?.let { vm.startPurchase(it) } }, onSubscription = { vm.onGoSubscription() },
onSubscriptionTrial = { vm.onGoSubscriptionTrial() },
onIap = { vm.onGoIap() },
onRestore = { vm.restorePurchase() }, onRestore = { vm.restorePurchase() },
) )
} }
@@ -92,8 +112,11 @@ private data class Benefit(val icon: ImageVector, val textRes: Int)
@Composable @Composable
fun UpgradeScreen( fun UpgradeScreen(
state: UpgradeViewModel.Pricing?,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onUpgrade: () -> Unit, onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRestore: () -> Unit, onRestore: () -> Unit,
) { ) {
val benefits = listOf( val benefits = listOf(
@@ -137,15 +160,7 @@ fun UpgradeScreen(
} }
}, },
style = MaterialTheme.typography.headlineLarge, 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, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -185,7 +200,7 @@ fun UpgradeScreen(
Icon( Icon(
imageVector = benefit.icon, imageVector = benefit.icon,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
} }
@@ -200,32 +215,18 @@ fun UpgradeScreen(
} }
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( if (state == null) {
onClick = onUpgrade, CircularProgressIndicator(modifier = Modifier.padding(16.dp))
modifier = Modifier } else {
.fillMaxWidth() PricingContent(
.height(52.dp), state = state,
shape = RoundedCornerShape(12.dp), onSubscription = onSubscription,
) { onSubscriptionTrial = onSubscriptionTrial,
Icon( onIap = onIap,
imageVector = Icons.TwoTone.Stars, onRestore = onRestore,
contentDescription = null,
modifier = Modifier.size(20.dp),
) )
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)) Spacer(modifier = Modifier.height(24.dp))
@@ -238,17 +239,145 @@ fun UpgradeScreen(
Icon( Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack, imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null, 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 @Preview2
@Composable @Composable
private fun UpgradeScreenPreview() = PreviewWrapper { private fun UpgradeScreenPreview() = PreviewWrapper {
UpgradeScreen( UpgradeScreen(
state = UpgradeViewModel.Pricing(
subPrice = "€3.49",
iapPrice = "€6.49",
hasTrialOffer = true,
),
onNavigateUp = {}, onNavigateUp = {},
onUpgrade = {}, onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {}, onRestore = {},
) )
} }
@@ -3,30 +3,106 @@ package eu.darken.capod.upgrade.ui
import android.app.Activity import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider 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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.CapodSku import eu.darken.capod.common.upgrade.core.CapodSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class UpgradeViewModel @Inject constructor( class UpgradeViewModel @Inject constructor(
private val dispatcherProvider: DispatcherProvider, private val dispatcherProvider: DispatcherProvider,
private val upgradeRepo: UpgradeRepoGplay, private val upgradeRepo: UpgradeRepoGplay,
private val billingDataRepo: BillingDataRepo,
) : ViewModel4(dispatcherProvider) { ) : ViewModel4(dispatcherProvider) {
sealed interface UpgradeEvent { sealed interface UpgradeEvent {
data object RestoreFailed : 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<UpgradeEvent>() val events = SingleEventFlow<UpgradeEvent>()
val billingEvents = SingleEventFlow<BillingEvent>()
val state: StateFlow<Pricing?> = 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 { init {
upgradeRepo.upgradeInfo upgradeRepo.upgradeInfo
@@ -39,22 +115,65 @@ class UpgradeViewModel @Inject constructor(
.launchIn(vmScope) .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 { try {
withContext(dispatcherProvider.Main) { withContext(dispatcherProvider.Main) {
billingDataRepo.startIapFlow(activity, CapodSku.PRO_UPGRADE.sku) upgradeRepo.launchBillingFlow(activity, CapodSku.Iap.PRO_UPGRADE)
} }
} catch (e: Exception) { } 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) errorEvents.emitBlocking(e)
} }
} }
fun restorePurchase() = launch { fun restorePurchase() = launch {
try { try {
val data = billingDataRepo.getIapData() val data = upgradeRepo.refresh()
log(TAG) { "Restore check: $data" } 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" } log(TAG) { "Pro purchase found" }
} else { } else {
log(TAG) { "No pro purchase found" } log(TAG) { "No pro purchase found" }
+13
View File
@@ -27,6 +27,19 @@
<string name="upgrade_benefit_widgets">Home screen widgets</string> <string name="upgrade_benefit_widgets">Home screen widgets</string>
<string name="upgrade_benefit_support">Support the developer</string> <string name="upgrade_benefit_support">Support the developer</string>
<string name="upgrade_screen_options_description">Same features, different pricing. The subscription includes a free trial and can be cancelled at any time.</string>
<string name="upgrade_screen_subscription_trial_action">Start free trial</string>
<string name="upgrade_screen_subscription_action">Subscribe yearly</string>
<string name="upgrade_screen_subscription_action_hint">%s/year</string>
<string name="upgrade_screen_iap_action">Buy once</string>
<string name="upgrade_screen_iap_action_hint">One-time purchase: %s</string>
<string name="upgrade_screen_restore_purchase_action">Restore purchase</string>
<string name="upgrade_screen_restore_purchase_message">No purchases found. Are you using the right account?</string>
<string name="upgrade_screen_restore_troubleshooting_msg">If you\'ve recently purchased, it may take a moment for Google Play to sync.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Try again in a few minutes if your purchase doesn\'t appear.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Make sure you\'re signed in with the same Google account used for the purchase.</string>
<string name="settings_monitor_mode_label">Monitor mode</string> <string name="settings_monitor_mode_label">Monitor mode</string>
<string name="settings_monitor_mode_description">Under which circumstances this app monitors Bluetooth data.</string> <string name="settings_monitor_mode_description">Under which circumstances this app monitors Bluetooth data.</string>
<string name="settings_monitor_connected_notification_label">Extra notification</string> <string name="settings_monitor_connected_notification_label">Extra notification</string>