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