mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 00:02:28 +00:00
ui: buy a badge in the browser, with a deep link back on mobile (#7592)
This commit is contained in:
1 parent
0dc85d3311
commit
36da024394
21 files changed
+623
-97
No files matched your search
@@ -70,6 +70,15 @@
|
||||
|
||||
<data android:scheme="simplex" />
|
||||
</intent-filter>
|
||||
<!-- open simplexchat:/ app URI -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="simplexchat" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+233
-32
@@ -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()
|
||||
)
|
||||
}
|
||||
+16
-12
@@ -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) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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>
|
||||
|
||||
@@ -107,6 +107,12 @@ When onboarding is complete:
|
||||
1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call).
|
||||
2. Routes to `AndroidScreen` or `DesktopScreen` based on platform.
|
||||
|
||||
### URL Deep Link
|
||||
|
||||
On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L775), which re-queues it while no profile is active. Desktop registers no URL scheme.
|
||||
|
||||
App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L796) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L800) dispatches on its path. A badge link (`simplexchat:/badge/code/<code>`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L268), which shows `BadgesRedeemLinkView` as a `ModalManager.end` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the screen composes), which core would refuse, the screen does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or back closes the screen without cancelling anything; if the redemption then succeeds with the screen closed or covered, an alert says the badge was added. Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L265)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string.
|
||||
|
||||
### Overlay Layers (bottom of MainScreen)
|
||||
|
||||
| Layer | Condition | Content |
|
||||
|
||||
Reference in new issue
Block a user