diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2a9c82a966..cfd857c035 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -3095,7 +3095,7 @@ func processReceivedMsg(_ res: ChatEvent) async { } } case let .storePurchaseCredited(user, invoiceId): - Task { await BadgeStore.shared.storePurchaseResolved(user, invoiceId) } + Task { await BadgeStore.shared.storePurchaseCredited(user, invoiceId) } case let .storePurchaseRefused(user, invoiceId, refusal): Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) } default: diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index f5da8d62e1..8194abcc46 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -259,6 +259,14 @@ final class BadgeStore: ObservableObject { } } + func storePurchaseCredited(_ user: UserRef, _ invoiceId: String) async { + await MainActor.run { + // an open badges screen is the notice only for the active profile, whose badge it shows + if !active(user) || !badgesViewShown { showBadgeAddedAlert(user) } + } + await storePurchaseResolved(user, invoiceId) + } + func storePurchaseRefused(_ user: UserRef, _ invoiceId: String, _ refusal: BadgeIssueFailure) async { await MainActor.run { if active(user) && openStorePurchases(user.userId).contains(where: { $0.invoiceId == invoiceId }) { @@ -268,7 +276,7 @@ final class BadgeStore: ObservableObject { await storePurchaseResolved(user, invoiceId) } - func storePurchaseResolved(_ user: UserRef, _ invoiceId: String) async { + private func storePurchaseResolved(_ user: UserRef, _ invoiceId: String) async { // the row goes before the store is read, or the screen shows it open while the store answers; // the transaction is finished whoever owns it, as only the app can, while the cached row is the active profile's await MainActor.run { diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index 322dbf1864..0cf0ec0673 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -67,6 +67,15 @@ func showCannotRedeemAlert(_ message: String) { showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) } +// a hidden profile is named only while it is the active one, which is unlocked and on screen +func showBadgeAddedAlert(_ user: any UserLike) { + guard let owner = ChatModel.shared.getUser(user.userId), active(owner) || !owner.hidden else { return } + showAlert( + NSLocalizedString("Badge added", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("The badge was added to the profile %@.", comment: "alert message"), owner.displayName) + ) +} + struct BadgesRedeemCodeView: View { @EnvironmentObject var theme: AppTheme @EnvironmentObject var chatModel: ChatModel @@ -396,12 +405,7 @@ struct BadgesRedeemLinkView: View { await MainActor.run { switch outcome { case .redeemed: - if !isShowing { - showAlert( - NSLocalizedString("Badge added", comment: "alert title"), - message: String.localizedStringWithFormat(NSLocalizedString("The badge was added to the profile %@.", comment: "alert message"), user.displayName) - ) - } + if !isShowing { showBadgeAddedAlert(user) } // a covered screen is not left on the spinner; a closed one is not written to if isOpen { state.step = .redeemed } case let .refused(message): closeIfShowing { showCannotRedeemAlert(message) } diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index 2d37860c45..c6a9749dd4 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -41,9 +41,13 @@ struct BadgesView: View { .animation(.default, value: shownBadge != nil) .animation(.default, value: store.purchaseState(chatModel.currentUser?.userId)) .animation(.default, value: store.checkingPurchases) + .onAppear { badgesViewShown = true } + .onDisappear { badgesViewShown = false } } } +private(set) var badgesViewShown = false + var supportSimpleXAlertAction: UIAlertAction { UIAlertAction(title: NSLocalizedString("Support SimpleX", comment: "alert button"), style: .default) { _ in openBadgesView() diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md index 329f764efa..81965dcd8d 100644 --- a/apps/ios/spec/client/navigation.md +++ b/apps/ios/spec/client/navigation.md @@ -200,7 +200,7 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181). -App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectViaUrl_()` branches on that scheme before the path switch, so an app link never reaches `planAndConnect()`, and `openAppLink()` dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to `openBadgeLink()` in [`BadgesRedeemCodeView.swift`](../../Shared/Views/Badges/BadgesRedeemCodeView.swift), which presents `BadgesRedeemLinkView` as an app sheet. The sheet 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 sheet renders), which core would refuse, the sheet 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 a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added. While the sheet shows a redemption in flight (`isBadgeLinkIssuing()`), `connectViaUrl_()` ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so `onOpenURL` logs it as "app link" rather than the URL. +App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectViaUrl_()` branches on that scheme before the path switch, so an app link never reaches `planAndConnect()`, and `openAppLink()` dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to `openBadgeLink()` in [`BadgesRedeemCodeView.swift`](../../Shared/Views/Badges/BadgesRedeemCodeView.swift), which presents `BadgesRedeemLinkView` as an app sheet. The sheet 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 sheet renders), which core would refuse, the sheet 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 a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown (`showBadgeAddedAlert()`, which the store lane shares). While the sheet shows a redemption in flight (`isBadgeLinkIssuing()`), `connectViaUrl_()` ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so `onOpenURL` logs it as "app link" rather than the URL. ### Call Deep Link diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 3c0443090b..53355e90f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3613,7 +3613,7 @@ object ChatController { } } is CR.StorePurchaseCredited -> - withLongRunningApi { BadgeStore.storePurchaseResolved(rhId, r.user, r.invoiceId) } + withLongRunningApi { BadgeStore.storePurchaseCredited(rhId, r.user, r.invoiceId) } is CR.StorePurchaseRefused -> withLongRunningApi { BadgeStore.storePurchaseRefused(rhId, r.user, r.invoiceId, r.refusal) } else -> 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 index 5f63f38670..3b82ac1371 100644 --- 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 @@ -7,6 +7,8 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.ModalViewId import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.* import kotlinx.coroutines.CancellationException @@ -306,6 +308,16 @@ object BadgeStore { } } + suspend fun storePurchaseCredited(rhId: Long?, user: UserRef, invoiceId: String) { + withContext(Dispatchers.Main) { + // not isLastModalOpen: the purchase screens pushed over the badges screen close once the badge appears + val badgesOpen = ModalManager.start.hasModalOpen(ModalViewId.BADGES) || ModalManager.end.hasModalOpen(ModalViewId.BADGES) + // an open badges screen is the notice only for the active profile, whose badge it shows + if (!chatModel.controller.activeUser(rhId, user) || !badgesOpen) showBadgeAddedAlert(rhId, user) + } + storePurchaseResolved(rhId, user, invoiceId) + } + suspend fun storePurchaseRefused(rhId: Long?, user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) { withContext(Dispatchers.Main) { if (chatModel.controller.activeUser(rhId, user) && openStorePurchases(user.userId).any { it.invoiceId == invoiceId }) { @@ -315,7 +327,7 @@ object BadgeStore { storePurchaseResolved(rhId, user, invoiceId) } - suspend fun storePurchaseResolved(rhId: Long?, user: UserRef, invoiceId: String) { + private suspend fun storePurchaseResolved(rhId: Long?, user: UserRef, invoiceId: String) { // the row goes before the store is read, or the screen shows it open while the store answers; // the purchase is finished whoever owns it, as only the app can, while the cached row is the active profile's withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index b3e74d3e2c..094f623032 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -93,6 +93,18 @@ fun showCannotRedeemAlert(message: String) { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_error_title), text = message) } +// a hidden profile is named only while it is the active one, which is unlocked and on screen +fun showBadgeAddedAlert(rhId: Long?, user: UserLike) { + val owner = if (chatModel.controller.activeUser(rhId, user)) chatModel.currentUser.value + else chatModel.users.firstOrNull { it.user.remoteHostId == rhId && it.user.userId == user.userId }?.user?.takeIf { !it.hidden } + if (owner != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_link_added_title), + text = String.format(generalGetString(MR.strings.badges_link_added_profile), owner.displayName) + ) + } +} + @Composable fun BadgesRedeemCodeView(modalManager: ModalManager, unwindToDepth: Int) { val rhId = remember { chatModel.remoteHostId() } @@ -320,12 +332,7 @@ fun BadgesRedeemLinkView(rhId: Long?, code: String, step: MutableState { - 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) - ) - } + if (!isShowing()) showBadgeAddedAlert(rhId, user) // a covered screen is not left on the spinner; a closed one is not written to if (isOpen()) step.value = BadgeLinkStep.Redeemed } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index c0050c8406..0e91b9ec7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -9,6 +9,7 @@ import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView +import chat.simplex.common.views.helpers.ModalViewId import chat.simplex.res.* @OptIn(ExperimentalAnimationApi::class) @@ -58,5 +59,5 @@ fun CloseWhenSupportGivesWay(modalManager: ModalManager, unwindToDepth: Int) { // ModalManager.end, not start: every caller is in the chat, which on desktop is the right pane fun openBadgesView() { - ModalManager.end.showCustomModal { close -> BadgesView(ModalManager.end, close) } + ModalManager.end.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.end, close) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 31dfc4f64d..0615f6e5dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1081,7 +1081,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat SupportSimpleXBanner( title = stringResource(MR.strings.badges_support_ended), subtitle = String.format(stringResource(MR.strings.badges_support_ended_on), alert.dateText), - onTap = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }, + onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_support_ended)) } ) } @@ -1094,7 +1094,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat title = stringResource(MR.strings.badges_renewal_failed), subtitle = stringResource(MR.strings.badges_tap_for_details), warning = true, - onTap = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }, + onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_renewal_failed)) } ) } @@ -1107,7 +1107,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat showDismiss = supporterBannerTapped.value, onTap = { appPrefs.supporterBannerTapped.set(true) - ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } + ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = ::showSupportSimpleXDismissAlert ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 0511bb1cba..7422d673d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -93,7 +93,8 @@ enum class ModalViewId { SECONDARY_CHAT, CONTEXT_USER_PICKER_INCOGNITO, BADGE_LINK, - BADGE_REDEEM_CODE + BADGE_REDEEM_CODE, + BADGES } class ModalManager(private val placement: ModalPlacement? = null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index bd929bc2f7..3e36ebda0f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -110,7 +110,7 @@ fun SettingsLayout( SectionView { // Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render // a gray top bar / back button that badges views don't want (they have their own inline titles). - SectionItemView(click = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }) { + SectionItemView(click = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }) { val badgeType = chatModel.currentUser.value?.profile?.localBadge?.badge?.badgeType ?: BadgeType.Supporter Image(painterResource(badgeImage(badgeType)), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) TextIconSpaced() diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index d6d5670c2f..ee0eb60f77 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -113,7 +113,7 @@ On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/Mai On desktop the same state is set from three places, for `simplexchat:` app links and `simplex:` connection links alike. [`isAcceptedLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L12) accepts either scheme within 8192 bytes in UTF-8, which leaves room for a one-time link with its post-quantum key (about 2 KB) and for a second such key. A link that starts the app arrives as the only argument of [`main()`](../../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) on Windows and Linux ([`linkFromArgs()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L15)). A link for an app that is already running starts a second process, which writes it into the single-instance signal file ([`signalRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L138)); the running instance renames the file before reading and deleting it ([`takeSignal()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L159)), retrying once if another program holds the file open, handles a signal already present when its watcher ([`watchShowSignals()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L247)) starts unless it predates the lock attempt by more than 2 s ([`deleteStaleSignalFiles()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L116)), and passes the link to [`openDesktopLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L19), which queues it for the active host and shows the window. If the running instance does not take the signal within 1 s, the second process asks whether to start anyway ([`startDespiteRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L93)); on *Yes* it withdraws the signal first and exits instead if the running instance has recovered and taken it, so the link opens once. On macOS the link is an Apple Event handled by [`installOpenUriHandler()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L25). From there `connectIfOpenedViaUri()` handles the link as on Android: a connection link goes to `planAndConnect()`, which asks before connecting. Both schemes are declared by the macOS Info.plist, the deb, AppImage and Flatpak desktop entries, and at runtime by [`registerLinkSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L67) (HKCU on Windows, a hidden desktop entry for an AppImage). Only `simplexchat:` is checked: [`appLinkSchemeRegistered()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L65) decides the badge page's ending through [`badgePageUrl()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt#L53): `app=true` when a link is expected to reach this installation, otherwise `app=desktop` with the Redeem code screen opened beside the browser. [`desktopInstallation()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L74) tells the installation kind apart. A packaged macOS app and a Flatpak count as registered without a check, since a Flatpak cannot query the host's default handler ([GAP](../../product/gaps.md#gap-08-desktop-app-link-registration)); Windows, where each scheme key is paired with a `SimpleX.` ProgId named in `Capabilities` and listed in `RegisteredApplications`, so the app appears in Windows default-app settings for each scheme that no other program holds, when the shell (`AssocQueryStringW`) reports this exe as the program that opens `simplexchat:` links, which accounts for any app picked in settings ([`registerWindowsSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L168)); an AppImage ([`registerAppImageScheme()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L299)) or deb when its own desktop entry is the default `simplexchat:` handler. Registration takes a scheme only when no handler is set or the current one is no longer installed: another installed program, such as a deb beside an AppImage or on Windows a per-user or machine-wide handler, keeps it, while this app still repairs its own ProgId unless another installed copy holds it, and the page then ends with `app=desktop`. An AppImage is not registered ([`onlyOwnerCanReplace()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L284)) when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it (POSIX ACLs are not inspected), or when its path contains a line break or `%`. An unpackaged run registers nothing. -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#L797) 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#L804) dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L268), which shows `BadgesRedeemLinkView` as a `ModalManager.start` 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. +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#L797) 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#L804) dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L282), which shows `BadgesRedeemLinkView` as a `ModalManager.start` 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 and names the profile, unless the profile is hidden and no longer active, when nothing is shown ([`showBadgeAddedAlert()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L97), which the store lane shares). 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#L279)), 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)