diff --git a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt index 855528a4e..029655832 100644 --- a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt +++ b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import org.tasks.BuildConfig import org.tasks.analytics.Firebase import org.tasks.jobs.WorkManager @@ -46,6 +47,29 @@ class BillingClientImpl( private var connected = false private var onPurchasesUpdated: OnPurchasesUpdated? = null + override suspend fun getSkus(skus: List): List = + executeServiceRequest { + val skuDetailsResult = withContext(Dispatchers.IO) { + billingClient.querySkuDetails( + SkuDetailsParams + .newBuilder() + .setType(SkuType.SUBS) + .setSkusList(skus) + .build() + ) + } + skuDetailsResult.billingResult.let { + if (!it.success) { + throw IllegalStateException(it.responseCodeString) + } + } + val json = Json { ignoreUnknownKeys = true } + skuDetailsResult + .skuDetailsList + ?.map { json.decodeFromString(it.originalJson) } + ?: emptyList() + } + override suspend fun queryPurchases(throwError: Boolean) = try { executeServiceRequest { withContext(Dispatchers.IO + NonCancellable) { @@ -174,11 +198,11 @@ class BillingClientImpl( ) } - private suspend fun executeServiceRequest(runnable: suspend () -> Unit) { + private suspend fun executeServiceRequest(runnable: suspend () -> T): T { if (!connected) { connect() } - runnable() + return runnable() } override suspend fun consume(sku: String) { diff --git a/app/src/main/java/org/tasks/billing/BillingClient.kt b/app/src/main/java/org/tasks/billing/BillingClient.kt index c8501b96d..f4d14bc4c 100644 --- a/app/src/main/java/org/tasks/billing/BillingClient.kt +++ b/app/src/main/java/org/tasks/billing/BillingClient.kt @@ -12,4 +12,5 @@ interface BillingClient { oldPurchase: Purchase? = null ) suspend fun acknowledge(purchase: Purchase) -} \ No newline at end of file + suspend fun getSkus(skus: List): List +} diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt index a47a85fc4..ddb8e1ab3 100644 --- a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt +++ b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt @@ -47,6 +47,7 @@ class PurchaseActivity : AppCompatActivity(), OnPurchasesUpdated { setPrice = { viewModel.setPrice(it) }, setNameYourPrice = { viewModel.setNameYourPrice(it) }, onBack = { finish() }, + skus = state.skus, snackbarHostState = snackbarHostState, ) LaunchedEffect(state.error) { diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivityViewModel.kt b/app/src/main/java/org/tasks/billing/PurchaseActivityViewModel.kt index 3d32c30c6..e9b56beae 100644 --- a/app/src/main/java/org/tasks/billing/PurchaseActivityViewModel.kt +++ b/app/src/main/java/org/tasks/billing/PurchaseActivityViewModel.kt @@ -33,6 +33,7 @@ class PurchaseActivityViewModel @Inject constructor( val price: Float = -1f, val subscription: Purchase? = null, val error: String? = null, + val skus: List = emptyList(), ) private val purchaseReceiver = object : BroadcastReceiver() { @@ -72,6 +73,19 @@ class PurchaseActivityViewModel @Inject constructor( it.copy(error = e.message) } } + try { + val skus = + (1..25).map { it.toSku(false) } + .plus((1..3).map { it.toSku(true) }) + .plus(30.toSku(false)) + _viewState.update { + it.copy(skus = billingClient.getSkus(skus)) + } + } catch (e: Exception) { + _viewState.update { + it.copy(error = e.message) + } + } } firebase.logEvent(R.string.event_showed_purchase_dialog) @@ -83,7 +97,7 @@ class PurchaseActivityViewModel @Inject constructor( } fun purchase(activity: Activity, price: Int, monthly: Boolean) = viewModelScope.launch { - val newSku = String.format(Locale.US, "%s_%02d", if (monthly) "monthly" else "annual", price) + val newSku = price.toSku(monthly) try { billingClient.initiatePurchaseFlow( activity, @@ -109,5 +123,8 @@ class PurchaseActivityViewModel @Inject constructor( companion object { const val EXTRA_GITHUB = "extra_github" const val EXTRA_NAME_YOUR_PRICE = "extra_name_your_price" + + fun Int.toSku(monthly: Boolean) = + String.format(Locale.US, "%s_%02d", if (monthly) "monthly" else "annual", this) } } diff --git a/app/src/main/java/org/tasks/billing/Sku.kt b/app/src/main/java/org/tasks/billing/Sku.kt new file mode 100644 index 000000000..381dffb9c --- /dev/null +++ b/app/src/main/java/org/tasks/billing/Sku.kt @@ -0,0 +1,9 @@ +package org.tasks.billing + +import kotlinx.serialization.Serializable + +@Serializable +data class Sku( + val productId: String, + val price: String, +) \ No newline at end of file diff --git a/app/src/main/java/org/tasks/compose/Subscription.kt b/app/src/main/java/org/tasks/compose/Subscription.kt index e69e1a39d..72176329f 100644 --- a/app/src/main/java/org/tasks/compose/Subscription.kt +++ b/app/src/main/java/org/tasks/compose/Subscription.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import org.tasks.R +import org.tasks.billing.Sku import org.tasks.compose.Constants.HALF_KEYLINE import org.tasks.compose.Constants.KEYLINE_FIRST import org.tasks.compose.PurchaseText.SubscriptionScreen @@ -145,6 +146,7 @@ object PurchaseText { setPrice: (Float) -> Unit, setNameYourPrice: (Boolean) -> Unit, subscribe: (Int, Boolean) -> Unit, + skus: List, onBack: () -> Unit, ) { Scaffold( @@ -221,6 +223,7 @@ object PurchaseText { setNameYourPrice = setNameYourPrice, setPrice = setPrice, subscribe = subscribe, + skus = skus, ) } } @@ -271,6 +274,7 @@ object PurchaseText { setNameYourPrice: (Boolean) -> Unit, setPrice: (Float) -> Unit, subscribe: (Int, Boolean) -> Unit, + skus: List, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -282,9 +286,13 @@ object PurchaseText { sliderPosition = sliderPosition, setPrice = setPrice, subscribe = subscribe, + skus = skus, ) } else { - TasksAccount(subscribe) + TasksAccount( + skus = skus, + subscribe = subscribe + ) } Spacer(Modifier.height(KEYLINE_FIRST)) val scope = rememberCoroutineScope() @@ -373,7 +381,10 @@ object PurchaseText { } @Composable - fun TasksAccount(subscribe: (Int, Boolean) -> Unit) { + fun TasksAccount( + skus: List, + subscribe: (Int, Boolean) -> Unit, + ) { Column( modifier = Modifier .fillMaxWidth() @@ -385,15 +396,19 @@ object PurchaseText { horizontalArrangement = Arrangement.Center ) { PurchaseButton( - price = 30, + price = remember(skus) { + skus.find { it.productId == "annual_30" }?.price ?: "$30" + }, popperText = "${stringResource(R.string.save_percent, 16)} $POPPER", - onClick = subscribe + onClick = { subscribe(30, false) }, ) Spacer(Modifier.width(KEYLINE_FIRST)) PurchaseButton( - price = 3, + price = remember (skus) { + skus.find { it.productId == "monthly_03" }?.price ?: "$3" + }, monthly = true, - onClick = subscribe + onClick = { subscribe(3, true) }, ) } } @@ -401,21 +416,21 @@ object PurchaseText { @Composable fun PurchaseButton( - price: Int, + price: String, monthly: Boolean = false, popperText: String = "", - onClick: (Int, Boolean) -> Unit, + onClick: () -> Unit, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Button( - onClick = { onClick(price, monthly) }, + onClick = { onClick() }, colors = ButtonDefaults.textButtonColors( containerColor = MaterialTheme.colorScheme.secondary ) ) { Text( text = stringResource( - if (monthly) R.string.price_per_month else R.string.price_per_year, + if (monthly) R.string.price_per_month_with_currency else R.string.price_per_year_with_currency, price ), color = MaterialTheme.colorScheme.onSecondary, @@ -435,6 +450,7 @@ object PurchaseText { sliderPosition: Float, setPrice: (Float) -> Unit, subscribe: (Int, Boolean) -> Unit, + skus: List, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -460,21 +476,32 @@ object PurchaseText { modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { + val price = sliderPosition.toInt() PurchaseButton( - price = sliderPosition.toInt(), + price = remember (skus, price) { + skus + .find { it.productId == "annual_${price.toString().padStart(2, '0')}" } + ?.price + ?: "$$price" + }, popperText = if (sliderPosition.toInt() >= 7) "${stringResource(R.string.above_average, 16)} $POPPER" else "", - onClick = subscribe + onClick = { subscribe(sliderPosition.toInt(), false) }, ) if (sliderPosition.toInt() < 3) { Spacer(Modifier.width(KEYLINE_FIRST)) PurchaseButton( - price = sliderPosition.toInt(), + price = remember (skus, price) { + skus + .find { it.productId == "monthly_${price.toString().padStart(2, '0')}" } + ?.price + ?: "$$price" + }, monthly = true, popperText = "${stringResource(R.string.above_average)} $POPPER", - onClick = subscribe + onClick = { subscribe(price, true) }, ) } } @@ -494,6 +521,7 @@ private fun PurchaseDialogPreview() { sliderPosition = 1f, setPrice = {}, setNameYourPrice = {}, + skus = emptyList(), ) } } @@ -510,6 +538,7 @@ private fun NameYourPricePreview() { sliderPosition = 4f, setPrice = {}, setNameYourPrice = {}, + skus = emptyList(), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef9f93be1..e7aea9c8a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -634,6 +634,8 @@ File %1$s contained %2$s.\n\n No eligible Google Play subscription found $%s/year $%s/month + %s/month + %s/year Current subscription: %s Join r/tasks Follow @tasks_org