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.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
@@ -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<SponsorEvent>()
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<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="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_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>
@@ -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"))
}
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<Sku> = setOf(Sub.PRO_UPGRADE, Iap.PRO_UPGRADE)
}
}
@@ -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<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 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<PurchasedSku> = 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<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 {
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")
}
}
}
@@ -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<Collection<Purchase>>,
) {
private val purchasesLocal = MutableStateFlow<Collection<Purchase>>(emptySet())
val purchases: Flow<Collection<Purchase>> = combine(purchasesGlobal, purchasesLocal) { global, local ->
private val queryCacheIaps = MutableStateFlow<Collection<Purchase>?>(null)
private val queryCacheSubs = MutableStateFlow<Collection<Purchase>?>(null)
val purchases: Flow<Collection<Purchase>> = combine(
purchasesGlobal,
queryCacheIaps,
queryCacheSubs,
) { global, iaps, subs ->
val combined = mutableSetOf<Purchase>()
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<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()
.setProductType(BillingClient.ProductType.INAPP)
.setProductType(productType)
.build()
val (result: BillingResult, purchases) = suspendCoroutine<Pair<BillingResult, Collection<Purchase>?>> { 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<SkuDetails> {
val byType = skus.groupBy { it.type }
val results = mutableListOf<SkuDetails>()
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 ->
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<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) {
"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")
}
}
}
@@ -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()}" }
}
}
}
@@ -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
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.CapodSku
data class BillingData(
val purchases: Collection<Purchase>
) {
val purchasedSkus: Collection<PurchasedSku>
get() = purchases.map { it.toPurchasedSku() }.flatten()
private fun Purchase.toPurchasedSku(): Collection<PurchasedSku> = skus.map {
PurchasedSku(Sku(it), this)
}
}
get() = purchases.flatMap { purchase ->
purchase.products.mapNotNull { productId ->
val sku = CapodSku.PRO_SKUS.singleOrNull { it.id == productId }
sku?.let { PurchasedSku(it, purchase) }
}
}
}
@@ -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<BillingData> = 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<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 {
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()
@@ -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})"
}
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
data class Sku(
interface Sku {
val id: String
) {
data class Details(
val sku: Sku,
val details: Collection<ProductDetails>,
)
}
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<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.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 = {},
)
}
@@ -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<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 {
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" }
+13
View File
@@ -27,6 +27,19 @@
<string name="upgrade_benefit_widgets">Home screen widgets</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_description">Under which circumstances this app monitors Bluetooth data.</string>
<string name="settings_monitor_connected_notification_label">Extra notification</string>