ui: buy a badge in the browser, with a deep link back on mobile (#7592)

This commit is contained in:
spaced4ndy
2026-09-29 10:30:57 +00:00
committed by GitHub
parent 0dc85d3311
commit 36da024394
21 changed files with 623 additions and 97 deletions
@@ -34,6 +34,11 @@ val badgeStoreProductIds: List<BadgeStoreProductId> = BadgeLevel.entries.flatMap
// learns which invoice a store transaction settles.
fun newBadgeInvoiceId(): String = UUID.randomUUID().toString()
// the page's app flag rides in the fragment, which never reaches the service
val badgePageUrl: String =
if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true"
else "https://badges.simplex.chat/#/tier?app=desktop"
// what the platform store knows about one product; ProductDetails cannot cross into commonMain
data class BadgeProduct(
val id: BadgeStoreProductId,
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -57,10 +58,44 @@ private fun formatBadgeCodeInput(s: String): String {
return groups.joinToString("-")
}
sealed class BadgeRedeemOutcome {
object Redeemed: BadgeRedeemOutcome()
class Refused(val message: String): BadgeRedeemOutcome()
object Cancelled: BadgeRedeemOutcome()
}
// sets the badge before returning, so a caller that dismisses on Redeemed lands on Your Badge
// instead of showing the switch from Support. Cancelled when the user cancels the retry alert.
suspend fun redeemBadgeCode(rhId: Long?, user: User, code: String): BadgeRedeemOutcome =
when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, code)) {
null -> BadgeRedeemOutcome.Cancelled
is BadgeRedeemResult.Redeemed -> withContext(Dispatchers.Main) {
val badgeState = result.badgeState
BadgeModel.set(rhId, user.userId, badgeState)
chatModel.updateUser(result.user)
if (badgeState != null && !badgeState.shown) {
// a replay adds no purchase; a fresh code's badge can be retired on arrival
BadgeRedeemOutcome.Refused(
generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used)
)
} else {
appPrefs.supporterBannerShown.set(true)
BadgeRedeemOutcome.Redeemed
}
}
is BadgeRedeemResult.Failed -> {
Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}")
BadgeRedeemOutcome.Refused(chatModel.controller.redeemErrorText(result.err))
}
}
fun showCannotRedeemAlert(message: String) {
AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_error_title), text = message)
}
@Composable
fun BadgesRedeemCodeView(modalManager: ModalManager) {
val rhId = remember { chatModel.remoteHostId() }
val supporterBannerShown = remember { appPrefs.supporterBannerShown }
val code = remember { mutableStateOf(TextFieldValue("")) }
val canonicalCode = remember { mutableStateOf<String?>(null) }
val submitting = remember { mutableStateOf(false) }
@@ -78,37 +113,13 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) {
val user = chatModel.currentUser.value ?: return
submitting.value = true
withBGApi {
when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, sending)) {
null -> withContext(Dispatchers.Main) { submitting.value = false }
is BadgeRedeemResult.Redeemed -> {
val badgeState = result.badgeState
withContext(Dispatchers.Main) {
submitting.value = false
// set before dismissing: BadgesView then switches Support to Your Badge while this screen
// still covers it, so the pop lands on Your Badge instead of showing the switch
BadgeModel.set(rhId, user.userId, badgeState)
chatModel.updateUser(result.user)
if (badgeState != null && !badgeState.shown) {
// a replay adds no purchase; a fresh code's badge can be retired on arrival
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_error_title),
text = generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used)
)
} else {
supporterBannerShown.set(true)
modalManager.closeModal()
}
}
}
is BadgeRedeemResult.Failed -> {
Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}")
withContext(Dispatchers.Main) {
submitting.value = false
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_error_title),
text = chatModel.controller.redeemErrorText(result.err)
)
}
val outcome = redeemBadgeCode(rhId, user, sending)
withContext(Dispatchers.Main) {
submitting.value = false
when (outcome) {
is BadgeRedeemOutcome.Redeemed -> modalManager.closeModal()
is BadgeRedeemOutcome.Refused -> showCannotRedeemAlert(outcome.message)
is BadgeRedeemOutcome.Cancelled -> {}
}
}
}
@@ -244,3 +255,193 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) {
onclick = onClick
)
}
// the open screen's own state, not a copy of its step, so the two cannot disagree
private var badgeLinkStep: MutableState<BadgeLinkStep>? = null
private fun isBadgeLinkOpen(): Boolean =
ModalManager.end.hasModalOpen(ModalViewId.BADGE_LINK)
fun isBadgeLinkIssuing(): Boolean =
isBadgeLinkOpen() && badgeLinkStep?.value == BadgeLinkStep.Issuing
fun openBadgeLink(rhId: Long?, codeText: String) {
val code = parseBadgeCode(codeText)
?: return showCannotRedeemAlert(generalGetString(MR.strings.badges_error_invalid_code))
// opens over the chat list, as iOS dismisses every sheet before a link
ModalManager.closeAllModalsEverywhere()
// held by the modal, not remembered: a modal is composed only while on top, and rotation recreates the activity,
// either of which would reset remembered state to Confirming with a request in flight
val step = mutableStateOf(BadgeLinkStep.Confirming)
ModalManager.end.showCustomModal(id = ModalViewId.BADGE_LINK) { close ->
BadgesRedeemLinkView(rhId, code, step, close)
}
badgeLinkStep = step
}
enum class BadgeLinkStep {
Confirming,
Issuing,
Redeemed,
ViewingBadge
}
// Any web page can send a badge link, and a profile holds one badge at a time,
// so this screen asks before redeeming, names the profile, and offers nothing but the redemption.
// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an
// interrupted request is not resumed, and the code stays on the page that issued the link.
@Composable
fun BadgesRedeemLinkView(rhId: Long?, code: String, step: MutableState<BadgeLinkStep>, close: () -> Unit) {
fun isOpen(): Boolean =
badgeLinkStep === step && isBadgeLinkOpen()
fun isShowing(): Boolean =
isOpen() && ModalManager.end.isLastModalOpen(ModalViewId.BADGE_LINK)
// An outcome can arrive after this screen was closed or covered: it must not close another screen,
// and a covered one goes back to asking, so it is never left locked on the spinner.
fun closeIfShowing() {
if (isShowing()) {
close()
} else if (isOpen()) {
step.value = BadgeLinkStep.Confirming
}
}
fun redeemFromLink() {
// a second tap before the screen changes must not send the code again
if (step.value != BadgeLinkStep.Confirming) return
val user = chatModel.currentUser.value ?: return closeIfShowing()
step.value = BadgeLinkStep.Issuing
withBGApi {
val outcome = redeemBadgeCode(rhId, user, code)
withContext(Dispatchers.Main) {
when (outcome) {
is BadgeRedeemOutcome.Redeemed -> {
if (!isShowing()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_link_added_title),
text = String.format(generalGetString(MR.strings.badges_link_added_profile), user.displayName)
)
}
// a covered screen is not left on the spinner; a closed one is not written to
if (isOpen()) step.value = BadgeLinkStep.Redeemed
}
is BadgeRedeemOutcome.Refused -> {
closeIfShowing()
showCannotRedeemAlert(outcome.message)
}
is BadgeRedeemOutcome.Cancelled -> closeIfShowing()
}
}
}
}
// read for the profile the screen names, when it renders, so the two cannot disagree;
// core refuses a code while the profile shows a badge
val profileHasBadge = BadgeModel.isCurrent(rhId, chatModel.currentUser.value?.userId) && BadgeModel.badgeState.value?.shown == true
when (step.value) {
BadgeLinkStep.Confirming -> ModalView(::closeIfShowing) {
if (profileHasBadge) {
BadgeHeld(onViewBadge = { step.value = BadgeLinkStep.ViewingBadge }, onCancel = ::closeIfShowing)
} else {
Confirming(onConfirm = ::redeemFromLink, onCancel = ::closeIfShowing)
}
}
BadgeLinkStep.Issuing -> ModalView(::closeIfShowing) { BeingIssued(onDismiss = ::closeIfShowing) }
BadgeLinkStep.Redeemed, BadgeLinkStep.ViewingBadge -> BadgesView(ModalManager.end, close)
}
}
// the screen cannot tell a repeated link for the badge it shows from a code for another badge,
// so it asserts neither
@Composable
private fun BadgeHeld(onViewBadge: () -> Unit, onCancel: () -> Unit) {
LinkStep(
MR.strings.badges_link_held_title,
primary = MR.strings.badges_link_view_badge to onViewBadge,
textButton = MR.strings.cancel_verb to onCancel
) {
LinkText(stringResource(MR.strings.badges_link_held_other_profile))
LinkText(stringResource(MR.strings.badges_link_held_page))
}
}
@Composable
private fun Confirming(onConfirm: () -> Unit, onCancel: () -> Unit) {
LinkStep(
MR.strings.badges_link_confirm_title,
primary = MR.strings.badges_link_add_badge to onConfirm,
textButton = MR.strings.cancel_verb to onCancel
) {
LinkText(String.format(stringResource(MR.strings.badges_link_confirm_profile), chatModel.currentUser.value?.displayName ?: ""))
}
}
// leaving does not cancel: the badge is still added after the screen closes
@Composable
private fun BeingIssued(onDismiss: () -> Unit) {
LinkStep(MR.strings.badges_being_issued, textButton = MR.strings.badges_dismiss to onDismiss) {
Spacer(Modifier.weight(1f))
CircularProgressIndicator(
Modifier.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
}
// with no primary, the text button takes its place, so a button does not move between steps
@Composable
private fun LinkStep(
title: StringResource,
primary: Pair<StringResource, () -> Unit>? = null,
textButton: Pair<StringResource, () -> Unit>,
content: @Composable ColumnScope.() -> Unit
) {
ColumnWithScrollBar(
Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
maxIntrinsicSize = true,
) {
Text(
stringResource(title),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
content()
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (primary != null) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = primary.first,
onboarding = null,
onclick = primary.second
)
TextButtonBelowOnboardingButton(stringResource(textButton.first), textButton.second)
} else {
TextButtonBelowOnboardingButton(stringResource(textButton.first), textButton.second)
TextButtonBelowOnboardingButton("", null)
}
}
}
}
@Composable
private fun LinkText(text: String) {
Text(
text,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
@@ -66,8 +66,8 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) {
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BuyInBrowserButton(modalManager)
RedeemCodeButton(modalManager)
GetCodeButton()
}
}
}
@@ -112,22 +112,26 @@ private fun HowItWorksButton(modalManager: ModalManager) {
@Composable
private fun RedeemCodeButton(modalManager: ModalManager) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.badges_redeem_code_button,
onboarding = null,
onclick = {
modalManager.showModal { BadgesRedeemCodeView(modalManager) }
}
TextButtonBelowOnboardingButton(
text = stringResource(MR.strings.badges_redeem_code_button),
onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager) } }
)
}
@Composable
private fun GetCodeButton() {
private fun BuyInBrowserButton(modalManager: ModalManager) {
val uriHandler = LocalUriHandler.current
TextButtonBelowOnboardingButton(
text = stringResource(MR.strings.badges_get_your_code),
onClick = { uriHandler.openExternalLink("https://simplex.chat/badges/") }
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.badges_buy_in_browser,
onboarding = null,
onclick = {
uriHandler.openUriCatching(badgePageUrl)
// desktop has no scheme to bring the code back, so the code is pasted into this screen, opened beside the browser
if (appPlatform.isDesktop && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) {
modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager) }
}
}
)
}
@@ -776,6 +776,10 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = rhId to uri
} else if (isAppLink(uri)) {
// branched on the scheme before the connection dispatch, which an app link must never reach
// an app link must not tear down a redemption in flight
if (!isBadgeLinkIssuing()) openAppLink(rhId, uri)
} else {
withBGApi {
chatModel.appOpenUrlConnecting.value = true
@@ -784,6 +788,27 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
}
}
// the app's own scheme, for every link that is not a connection link, which stays on simplex:
private const val appLinkScheme = "simplexchat"
private const val badgeLinkPath = "/badge/code/"
// the prefix of the raw text, not a parsed URI: a link that does not parse is still an app link
fun isAppLink(uri: String): Boolean =
uri.startsWith("$appLinkScheme:", ignoreCase = true)
// a link type added in a later version reaches this build too, so an unknown path asks for an update
private fun openAppLink(rhId: Long?, uri: String) {
// a link that does not parse is dropped, as iOS never receives one; one that parses without
// a path, such as simplexchat:badge/code/X, is an unknown link on both platforms
val parsed = uriCreateOrNull(uri) ?: return
val path = parsed.path ?: ""
if (path.startsWith(badgeLinkPath)) {
openBadgeLink(rhId, path.removePrefix(badgeLinkPath))
} else {
AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.app_link_not_supported))
}
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
Box {
@@ -91,7 +91,9 @@ class ModalData(val keyboardCoversBar: Boolean = true) {
enum class ModalViewId {
SECONDARY_CHAT,
CONTEXT_USER_PICKER_INCOGNITO
CONTEXT_USER_PICKER_INCOGNITO,
BADGE_LINK,
BADGE_REDEEM_CODE
}
class ModalManager(private val placement: ModalPlacement? = null) {
@@ -25,6 +25,7 @@
<string name="connect_plan_you_are_owner">You are an owner</string>
<string name="error_parsing_uri_title">Invalid link</string>
<string name="error_parsing_uri_desc">Please check that SimpleX link is correct.</string>
<string name="app_link_not_supported">This link is not supported by this app version. Please check for app updates.</string>
<!-- MainActivity.kt -->
<string name="opening_database">Opening database…</string>
@@ -3182,8 +3183,18 @@
<string name="badges_purchase_pending">Purchase pending</string>
<string name="badges_purchase_pending_desc">The purchase is awaiting approval. This build does not deliver purchases approved later.</string>
<string name="badges_purchase_error">Purchase error</string>
<string name="badges_get_your_code">Get your code</string>
<string name="badges_buy_in_browser">Buy in browser</string>
<string name="badges_redeem_code_title">Redeem code</string>
<string name="badges_link_confirm_title">Add badge to your profile?</string>
<string name="badges_link_confirm_profile">The badge will be added to the profile %1$s.</string>
<string name="badges_link_add_badge">Add badge</string>
<string name="badges_link_held_title">Profile already has a badge</string>
<string name="badges_link_held_other_profile">A badge can be added to another profile, or here once this badge ends.</string>
<string name="badges_link_held_page">Its code is on the page you bought it on, under Show code.</string>
<string name="badges_link_view_badge">View your badge</string>
<string name="badges_being_issued">Badge is being issued</string>
<string name="badges_link_added_title">Badge added</string>
<string name="badges_link_added_profile">The badge was added to the profile %1$s.</string>
<string name="badges_redeem_code_body">Paste the code you received.</string>
<string name="badges_redeem_code_placeholder" translatable="false">SB-XXXXX-XXXXX-XXXXX-XXXXX</string>
<string name="badges_redeem">Redeem</string>