diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme index 7394bc272a..6a1d4192e6 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -60,9 +60,6 @@ ReferencedContainer = "container:SimpleX.xcodeproj"> - - ): List = emptyList() + +@Suppress("UNUSED_PARAMETER") +suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = + throw BadgeStoreError.StoreUnavailable diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index a0e7734ff0..125f8351f0 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -1,8 +1,14 @@ package chat.simplex.app +import chat.simplex.common.platform.Log import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry +import chat.simplex.common.platform.mainActivity +import chat.simplex.common.views.badges.* import com.android.billingclient.api.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext // Requests the country of the Google Play account into [androidPlayStoreCountry]. // It stays null when Play is unavailable or the user is not signed in. @@ -29,3 +35,147 @@ fun loadPlayStoreCountry() { override fun onBillingServiceDisconnected() = client.endConnection() }) } + +// One long-lived client for badges: ProductDetails obtained from it are passed back to it when the +// purchase is launched, and the purchase result arrives on its listener rather than as a return value. +// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher +@Volatile private var badgeBillingClient: BillingClient? = null +@Volatile private var badgeProductDetails: Map = emptyMap() +@Volatile private var badgePurchase: CompletableDeferred? = null + +suspend fun loadBadgeProducts(productIds: List): List { + val client = connectedBadgeBillingClient() + // every id is queried as both types - Play returns only the ones that match, so the product type + // does not have to be inferred from the id + val details = queryBadgeProducts(client, productIds, BillingClient.ProductType.INAPP) + + queryBadgeProducts(client, productIds, BillingClient.ProductType.SUBS) + badgeProductDetails = details.associateBy { it.productId } + return details.mapNotNull { it.badgeProduct() } +} + +suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome { + val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable + val client = connectedBadgeBillingClient() + val details = badgeProductDetails[productId] ?: throw BadgeStoreError.ProductUnavailable(productId) + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details) + // subscriptions must state which base plan is bought, one-time products must not + details.subscriptionOfferDetails?.firstOrNull()?.let { productParams.setOfferToken(it.offerToken) } + val params = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productParams.build())) + .setObfuscatedAccountId(invoiceId) + .build() + val purchase = CompletableDeferred() + badgePurchase = purchase + try { + val launched = withContext(Dispatchers.Main) { client.launchBillingFlow(activity, params) } + if (launched.responseCode != BillingClient.BillingResponseCode.OK) { + throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) + } + val outcome = purchase.await() + if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt) + return outcome + } finally { + badgePurchase = null + } +} + +private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> + val pending = badgePurchase ?: return@PurchasesUpdatedListener + when { + result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> + pending.complete(badgePurchaseOutcome(purchases)) + result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> + pending.complete(BadgePurchaseOutcome.Cancelled) + else -> + pending.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + } +} + +private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcome { + val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled + if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending + return BadgePurchaseOutcome.Purchased( + BadgeStoreReceipt( + token = purchase.purchaseToken, + productId = purchase.products.firstOrNull() ?: "", + orderId = purchase.orderId, + invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId + ) + ) +} + +private suspend fun connectedBadgeBillingClient(): BillingClient { + badgeBillingClient?.let { if (it.isReady) return it } + val client = BillingClient.newBuilder(androidAppContext) + .setListener(badgePurchasesUpdatedListener) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .build() + val connected = CompletableDeferred() + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + connected.complete(result) + } + + override fun onBillingServiceDisconnected() { + badgeBillingClient = null + connected.complete( + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED).build() + ) + } + }) + val result = connected.await() + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + client.endConnection() + throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) + } + badgeBillingClient = client + return client +} + +private suspend fun queryBadgeProducts(client: BillingClient, productIds: List, productType: String): List { + val params = QueryProductDetailsParams.newBuilder() + .setProductList( + productIds.map { + QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build() + } + ) + .build() + val queried = CompletableDeferred>() + client.queryProductDetailsAsync(params) { result, productDetailsResult -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queried.complete(productDetailsResult.productDetailsList) + } else { + Log.w(TAG, "queryBadgeProducts: $productType query failed ${result.responseCode} ${result.debugMessage}") + queried.complete(emptyList()) + } + } + return queried.await() +} + +private fun ProductDetails.badgeProduct(): BadgeProduct? { + oneTimePurchaseOfferDetails?.let { + return BadgeProduct(productId, it.formattedPrice, it.priceAmountMicros, it.priceCurrencyCode) + } + // the last pricing phase is the recurring base price, earlier phases are trials and intro offers + val phase = subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.lastOrNull() ?: return null + return BadgeProduct(productId, phase.formattedPrice, phase.priceAmountMicros, phase.priceCurrencyCode) +} + +// nothing is delivered in this build, so the purchase is finished right away; once the service issues +// credentials it must only be finished after the credential is stored. One-time products are consumed +// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged +// purchase after 3 days. +private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) { + val done = CompletableDeferred() + if (details.productType == BillingClient.ProductType.SUBS) { + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() + client.acknowledgePurchase(params) { done.complete(it) } + } else { + val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() + client.consumeAsync(params) { result, _ -> done.complete(result) } + } + val result = done.await() + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}") + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index ce47d2c5de..4e59babe27 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -26,6 +26,8 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.call.* import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* @@ -343,6 +345,10 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() + override suspend fun androidLoadBadgeProducts(productIds: List): List = loadBadgeProducts(productIds) + + override suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(productId, invoiceId) + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index b46123c9cf..0ae9710232 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -9,6 +9,9 @@ import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatId import chat.simplex.common.model.NotificationsMode import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreError import kotlinx.coroutines.Job import java.io.Closeable @@ -31,6 +34,9 @@ interface PlatformInterface { fun androidIsXiaomiDevice(): Boolean = false // Requests the Google Play account country into [androidPlayStoreCountry] fun androidLoadPlayStoreCountry() {} + // Play Billing, only implemented in the google flavor - elsewhere no product is offered + suspend fun androidLoadBadgeProducts(productIds: List): List = emptyList() + suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable val androidApiLevel: Int? get() = null // The build distributed via Google Play, which has to follow its policies val androidIsPlayStoreBuild: Boolean get() = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt new file mode 100644 index 0000000000..f2d9018991 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -0,0 +1,150 @@ +package chat.simplex.common.views.badges + +import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.platform.* +import java.text.NumberFormat +import java.util.Currency +import java.util.UUID + +// TODO [badges] product ids will come from app config and prices from the badge service catalog; +// hardcoded here so the Play Store integration can be tested before the purchase API lands. +fun badgeProductId(level: BadgeLevel, period: BadgePeriod): String = when (level) { + BadgeLevel.Supporter -> when (period) { + BadgePeriod.OneMonth -> "BADGE_SUPPORTER_01" + BadgePeriod.Monthly -> "SUBSCR_BADGE_SUPPORTER_MONTH_01" + BadgePeriod.Annual -> "SUBSCR_BADGE_SUPPORTER_YEAR_01" + } + BadgeLevel.Legend -> when (period) { + BadgePeriod.OneMonth -> "BADGE_LEGEND_01" + BadgePeriod.Monthly -> "SUBSCR_BADGE_LEGEND_MONTH_01" + BadgePeriod.Annual -> "SUBSCR_BADGE_LEGEND_YEAR_01" + } +} + +val badgeProductIds: List = BadgeLevel.entries.flatMap { level -> + BadgePeriod.entries.map { badgeProductId(level, it) } +} + +// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. +// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service +// learns which invoice a store transaction settles. +fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() + +// what the platform store knows about one product; ProductDetails cannot cross into commonMain +data class BadgeProduct( + val productId: String, + val displayPrice: String, + val priceMicros: Long, + val currencyCode: String +) + +sealed class BadgePrice { + object Loading: BadgePrice() + class Price(val price: String): BadgePrice() + object Unavailable: BadgePrice() + + val canPurchase: Boolean + get() = when (this) { + is Price -> true + is Loading, is Unavailable -> false + } +} + +data class BadgeStoreReceipt( + // the token the badge service verifies with the Publisher API + val token: String, + val productId: String, + val orderId: String?, + val invoiceId: String? +) + +sealed class BadgePurchaseOutcome { + class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome() + object Pending: BadgePurchaseOutcome() + object Cancelled: BadgePurchaseOutcome() +} + +sealed class BadgeStoreError: Exception() { + class ProductUnavailable(val productId: String): BadgeStoreError() + class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() + object StoreUnavailable: BadgeStoreError() + + override val message: String + get() = when (this) { + is ProductUnavailable -> "productUnavailable(productId: $productId)" + is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" + is StoreUnavailable -> "storeUnavailable" + } +} + +object BadgeStore { + private enum class LoadState { NotLoaded, Loading, Loaded, Failed } + + private val state = mutableStateOf(LoadState.NotLoaded) + // snapshot state, unlike the plain dictionary on iOS: Compose tracks reads per value, so a + // composable that only reads the products would not recompose when they arrive + private val products = mutableStateOf>(emptyMap()) + + fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) { + LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading + LoadState.Loaded, LoadState.Failed -> { + val p = products.value[badgeProductId(level, period)] + if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable + } + } + + // percentage the annual subscription saves against 12 monthly payments + fun annualSavings(level: BadgeLevel): Int? { + val monthly = products.value[badgeProductId(level, BadgePeriod.Monthly)] ?: return null + val annual = products.value[badgeProductId(level, BadgePeriod.Annual)] ?: return null + val year = monthly.priceMicros * 12 + if (year <= 0 || annual.priceMicros >= year) return null + val percent = Math.round((year - annual.priceMicros).toDouble() / year * 100).toInt() + return if (percent > 0) percent else null + } + + suspend fun load() { + if (!startLoading()) return + try { + val loaded = platform.androidLoadBadgeProducts(badgeProductIds) + val byId = loaded.associateBy { it.productId } + val missing = badgeProductIds.filter { !byId.containsKey(it) } + if (missing.isNotEmpty()) { + Log.w(TAG, "BadgeStore.load: no product returned for ${missing.joinToString(", ")}") + } + products.value = byId + state.value = LoadState.Loaded + } catch (e: Exception) { + Log.e(TAG, "BadgeStore.load: ${e.stackTraceToString()}") + state.value = LoadState.Failed + } + } + + suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { + val productId = badgeProductId(level, period) + if (!products.value.containsKey(productId)) throw BadgeStoreError.ProductUnavailable(productId) + return platform.androidPurchaseBadge(productId, invoiceId) + } + + private fun startLoading(): Boolean = when (state.value) { + LoadState.NotLoaded, LoadState.Failed -> { + state.value = LoadState.Loading + true + } + LoadState.Loading, LoadState.Loaded -> false + } +} + +// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency; +// BadgeProduct.displayPrice remains the exact form for views that need the cents +private fun compactPrice(product: BadgeProduct): String { + if (product.priceMicros % 1_000_000L != 0L) return product.displayPrice + return try { + val format = NumberFormat.getCurrencyInstance() + format.currency = Currency.getInstance(product.currencyCode) + format.maximumFractionDigits = 0 + format.format(product.priceMicros / 1_000_000L) + } catch (e: Exception) { + product.displayPrice + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt index 742b03e2bd..95931bb588 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt @@ -10,6 +10,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -25,24 +28,54 @@ import chat.simplex.res.MR // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgePeriod { OneMonth, - Subscribe; + Monthly, + Annual; val icon: dev.icerock.moko.resources.ImageResource get() = when (this) { OneMonth -> MR.images.ic_calendar - Subscribe -> MR.images.ic_refresh + Monthly -> MR.images.ic_refresh + Annual -> MR.images.ic_refresh } val label: StringResource get() = when (this) { OneMonth -> MR.strings.badges_period_one_month - Subscribe -> MR.strings.badges_period_subscribe + Monthly -> MR.strings.badges_period_monthly + Annual -> MR.strings.badges_period_annual } + + @Composable + fun priceText(price: BadgePrice): String = when (price) { + is BadgePrice.Loading -> "…" + is BadgePrice.Unavailable -> "—" + is BadgePrice.Price -> when (this) { + OneMonth -> price.price + Monthly -> stringResource(MR.strings.badges_price_monthly).format(price.price) + Annual -> stringResource(MR.strings.badges_price_annual).format(price.price) + } + } + + // OnboardingActionButton takes a resource id, so the price states map to (id, arg) here rather + // than to a formatted string as on iOS + fun payLabel(price: BadgePrice): Pair = when (price) { + is BadgePrice.Loading -> MR.strings.badges_price_loading to null + is BadgePrice.Unavailable -> MR.strings.badges_price_unavailable to null + is BadgePrice.Price -> when (this) { + OneMonth -> MR.strings.badges_pay_once to price.price + Monthly -> MR.strings.badges_pay_monthly to price.price + Annual -> MR.strings.badges_pay_annual to price.price + } + } } @Composable fun BadgesPayView(level: BadgeLevel) { - var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) } + var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } + val purchasing = remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(Unit) { BadgeStore.load() } ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -71,14 +104,15 @@ fun BadgesPayView(level: BadgeLevel) { Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) - // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height - // when 2-line labels at large fonts would otherwise size them differently. + // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts. Row( Modifier.fillMaxWidth().height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - PeriodCard(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } } Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) @@ -86,7 +120,7 @@ fun BadgesPayView(level: BadgeLevel) { // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a // TextButton so the footer has no hover/click affordance. Column(horizontalAlignment = Alignment.CenterHorizontally) { - PayButton(level, selectedPeriod) + PayButton(level, selectedPeriod, purchasing, clipboard) Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { Text( stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), @@ -101,7 +135,7 @@ fun BadgesPayView(level: BadgeLevel) { } @Composable -private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { +private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { val isSelected = period == selectedPeriod val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. @@ -125,24 +159,94 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie modifier = Modifier.size(32.dp) ) Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + val percent = savingsPercent(level, period) + if (percent != null) { + Text( + stringResource(MR.strings.badges_savings).format(percent), + style = MaterialTheme.typography.caption, + color = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } } } +private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = + if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null + @Composable -private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) { +private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { + val price = BadgeStore.price(level, selectedPeriod) + val (labelId, labelArg) = selectedPeriod.payLabel(price) OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = if (selectedPeriod == BadgePeriod.Subscribe) MR.strings.badges_pay_monthly else MR.strings.badges_pay_once, - labelArg = level.priceAmount, + labelId = labelId, + labelArg = labelArg, onboarding = null, - onclick = { - // TODO [badges] wire to purchase API when it lands. + enabled = price.canPurchase && !purchasing.value, + onclick = { purchase(level, selectedPeriod, purchasing, clipboard) } + ) +} + +private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { + val invoiceId = newBadgeInvoiceId() + purchasing.value = true + // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls + withLongRunningApi { + try { + val outcome = BadgeStore.purchase(level, period, invoiceId) + purchasing.value = false + when (outcome) { + is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard) + is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_pending), + text = generalGetString(MR.strings.badges_purchase_pending_desc) + ) + is BadgePurchaseOutcome.Cancelled -> {} + } + } catch (e: Exception) { + Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}") + purchasing.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = e.toString() + ) } + } +} + +// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. +private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) { + val returnedInvoice = when (receipt.invoiceId) { + null -> "none" + invoiceId -> "yes" + else -> "mismatch: ${receipt.invoiceId}" + } + val summary = listOf( + "Product: ${receipt.productId}", + "Invoice: $invoiceId", + "Invoice returned by Google: $returnedInvoice", + "Order: ${receipt.orderId ?: "none"}", + "Token: ${receipt.token.length} bytes" + ).joinToString("\n") + // logged as well as shown: the log always lands even when the alert is missed + Log.d(TAG, "badge purchase succeeded\n$summary") + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.badges_purchase_successful), + text = summary, + confirmText = "Copy token", + onConfirm = { + clipboard.setText(AnnotatedString(receipt.token)) + showToast(generalGetString(MR.strings.copied)) + }, + dismissText = generalGetString(MR.strings.ok), + parseHtml = false ) } private fun billingFooter(period: BadgePeriod): StringResource = when (period) { - BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe + BadgePeriod.Monthly, BadgePeriod.Annual -> MR.strings.badges_billing_footer_subscribe BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index aa8100519b..099954a4f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -33,6 +33,9 @@ import chat.simplex.res.MR @Composable fun BadgesSupportSimplexView() { // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") + // preloaded here so the level screen shows store prices without a placeholder pass + LaunchedEffect(Unit) { BadgeStore.load() } + ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt index 03082526a0..c77e4dd61e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt @@ -40,12 +40,6 @@ enum class BadgeLevel { Legend -> MR.strings.badges_level_legend_files } - val priceAmount: String - get() = when (this) { - Supporter -> "$7" - Legend -> "$70" - } - val tagline: StringResource get() = when (this) { Supporter -> MR.strings.badges_level_supporter_tagline @@ -63,6 +57,8 @@ enum class BadgeLevel { fun BadgesYourLevelView() { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } + LaunchedEffect(Unit) { BadgeStore.load() } + ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -139,7 +135,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo ) Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) - Text(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 8683af1705..edc7bd73df 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3206,12 +3206,18 @@ Send 2GB files Send 5GB files %1$s/month + %1$s/year Pay %1$s/month + Pay %1$s/year Pay %1$s + Loading… + Not available + Save %1$d%% Optional profile badge\nand 2GB files Optional profile badge\nand 5GB files 1 month - Subscribe + Monthly + Annual Renews on %1$s. Cancel anytime. Ends on %1$s. Your level @@ -3230,6 +3236,10 @@ Support SimpleX Get badge + files up to 5GB You can support SimpleX later in Settings. + Purchase successful + Purchase pending + The purchase is awaiting approval. This build does not deliver purchases approved later. + Purchase error Supporter perks Supporter badge ❤️ Help keep the network running — send files up to 2 GB.