mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-39
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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" }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user