diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 22d0da3829..315041b776 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -463,7 +463,10 @@ struct ContentView: View { func connectViaUrl_(_ url: URL) { dismissAllSheets() { var path = url.path - if path == "/r" { + if isAppLink(url) { + // branched on the scheme before the connection dispatch, which an app link must never reach + openAppLink(url) + } else if path == "/r" { showAlert( NSLocalizedString("Relay address", comment: "alert title"), message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") @@ -498,6 +501,24 @@ struct ContentView: View { } } +// the app's own scheme, for every link that is not a connection link, which stays on simplex: +private let appLinkScheme = "simplexchat" +private let badgeLinkPath = "/badge/code/" + +func isAppLink(_ url: URL) -> Bool { + url.scheme?.lowercased() == appLinkScheme +} + +// a link type added in a later version reaches this build too, so an unknown path asks for an update +private func openAppLink(_ url: URL) { + let path = url.path + if path.hasPrefix(badgeLinkPath) { + openBadgeLink(String(path.dropFirst(badgeLinkPath.count))) + } else { + AlertManager.shared.showAlert(Alert(title: Text("This link is not supported by this app version. Please check for app updates."))) + } +} + final class AlertManager: ObservableObject { static let shared = AlertManager() @Published var presentAlert = false diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 1e9a97c31b..28aa7e4002 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -43,7 +43,8 @@ struct SimpleXApp: App { .environmentObject(chatModel) .environmentObject(AppTheme.shared) .onOpenURL { url in - logger.debug("ContentView.onOpenURL: \(url)") + // an app link can carry a secret, such as a badge code that anyone who reads it can redeem + logger.debug("ContentView.onOpenURL: \(isAppLink(url) ? "app link" : url.absoluteString)") if AppChatState.shared.value == .active { chatModel.appOpenUrl = url } else { diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 8d092bbc19..8ffc960b00 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -32,6 +32,9 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in // which is how the service learns which invoice a store transaction settles. func newBadgeInvoiceId() -> UUID { UUID() } +// the page's app flag rides in the fragment, which never reaches the service; change it to point Buy in browser at another deployment +let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true" + enum BadgePrice { case loading case price(String) diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index 6ea0c58edb..a46ce43afd 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -33,10 +33,43 @@ private func formatBadgeCodeInput(_ s: String) -> String { return groups.joined(separator: "-") } +enum BadgeRedeemOutcome { + case redeemed + case refused(message: String) + case cancelled +} + +// 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. +func redeemBadgeCode(_ user: User, _ code: String) async -> BadgeRedeemOutcome { + do { + guard let redeemed = try await apiRedeemBadgeCode(user.userId, code) else { return .cancelled } + return await MainActor.run { () -> BadgeRedeemOutcome in + BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) + ChatModel.shared.updateUser(redeemed.user) + if let badgeState = redeemed.badgeState, !badgeState.shown { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + return .refused(message: redeemed.newBadge + ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") + : NSLocalizedString("This code has already been used.", comment: "alert message") + ) + } + UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) + return .redeemed + } + } catch let error { + logger.error("apiRedeemBadgeCode: \(responseError(error))") + return .refused(message: redeemErrorText(error)) + } +} + +func showCannotRedeemAlert(_ message: String) { + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) +} + struct BadgesRedeemCodeView: View { @EnvironmentObject var theme: AppTheme @EnvironmentObject var chatModel: ChatModel - @AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false @Environment(\.dismiss) var dismiss: DismissAction @State private var code = "" @State private var canonicalCode: String? = nil @@ -184,33 +217,128 @@ struct BadgesRedeemCodeView: View { guard let sending = canonicalCode, let user = chatModel.currentUser else { return } submitting = true Task { - do { - guard let redeemed = try await apiRedeemBadgeCode(user.userId, sending) else { - await MainActor.run { submitting = false } - return + let outcome = await redeemBadgeCode(user, sending) + await MainActor.run { + submitting = false + switch outcome { + case .redeemed: dismiss() + case let .refused(message): showCannotRedeemAlert(message) + case .cancelled: break } - await MainActor.run { - submitting = 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.shared.set(userId: user.userId, badgeState: redeemed.badgeState) - chatModel.updateUser(redeemed.user) - if let badgeState = redeemed.badgeState, !badgeState.shown { - // a replay adds no purchase; a fresh code's badge can be retired on arrival - let message = redeemed.newBadge - ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") - : NSLocalizedString("This code has already been used.", comment: "alert message") - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) - } else { - supporterBannerShown = true - dismiss() - } + } + } + } +} + +func openBadgeLink(_ codeText: String) { + guard let code = parseBadgeCode(codeText) else { + return showCannotRedeemAlert(NSLocalizedString("This code is not valid.", comment: "alert message")) + } + showAppSheet { + NavigationView { + BadgesRedeemLinkView(code: code) + .modifier(ThemedBackground()) + } + } +} + +enum BadgeLinkStep { + case confirming + case issuing + case redeemed +} + +// Any web page or chat message 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. +struct BadgesRedeemLinkView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + let code: String + @State private var step = BadgeLinkStep.confirming + + var body: some View { + switch step { + case .confirming: confirming() + case .issuing: beingIssued() + case .redeemed: BadgesView(showsAsSheet: true) + } + } + + private func confirming() -> some View { + VStack(alignment: .center, spacing: 16) { + Text("Add badge to your profile?") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text(String.localizedStringWithFormat(NSLocalizedString("The badge will be added to the profile %@.", comment: "badge link confirmation"), chatModel.currentUser?.displayName ?? "")) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + + VStack(spacing: 10) { + Button { + redeemFromLink() + } label: { + Text("Add badge") } - } catch let error { - logger.error("apiRedeemBadgeCode: \(responseError(error))") - await MainActor.run { - submitting = false - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error)) + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .padding(.vertical, 10) + + Button { + dismissAllSheets() + } label: { + Text("Cancel") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + .frame(height: 22) + } + } + .padding(.horizontal, 25) + .padding(.top, 48) + .padding(.bottom, 20) + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func beingIssued() -> some View { + VStack(alignment: .center, spacing: 16) { + Text("Badge is being issued") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + + ProgressView().scaleEffect(2) + + Spacer() + } + .padding(.horizontal, 25) + .padding(.top, 48) + .padding(.bottom, 20) + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func redeemFromLink() { + guard let user = chatModel.currentUser else { return dismissAllSheets() } + step = .issuing + Task { + let outcome = await redeemBadgeCode(user, code) + await MainActor.run { + switch outcome { + case .redeemed: step = .redeemed + case let .refused(message): dismissAllSheets { showCannotRedeemAlert(message) } + case .cancelled: dismissAllSheets() } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 44b1e48be1..22cec4a4f4 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -47,9 +47,9 @@ struct BadgesSupportSimplexView: View { Spacer(minLength: 0) VStack(spacing: 10) { - redeemCodeButton() + buyInBrowserButton() .padding(.vertical, 10) - getCodeButton() + redeemCodeButton() .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) @@ -131,8 +131,10 @@ struct BadgesSupportSimplexView: View { redeemCodeActive = true } label: { Text("Redeem badge code") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $redeemCodeActive) { BadgesRedeemCodeView() @@ -145,15 +147,13 @@ struct BadgesSupportSimplexView: View { } } - private func getCodeButton() -> some View { + private func buyInBrowserButton() -> some View { Button { - openExternalLink(URL(string: "https://simplex.chat/badges/")!) + UIApplication.shared.open(URL(string: badgePageUrl)!) } label: { - Text("Get your code") - .font(.body) - .fontWeight(.medium) - .foregroundColor(theme.colors.primary) + Text("Buy in browser") } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) } } diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 72bd9b0dc3..06edbf9ce9 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -42,6 +42,16 @@ simplex + + CFBundleTypeRole + Editor + CFBundleURLName + chat.simplex.app.deeplink + CFBundleURLSchemes + + simplexchat + + ITSAppUsesNonExemptEncryption diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md index 64d0940e39..de374b2bb9 100644 --- a/apps/ios/spec/client/navigation.md +++ b/apps/ios/spec/client/navigation.md @@ -200,6 +200,8 @@ 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 page or message 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. 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 Call invitations from notifications: diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 9e059afa14..c2c789f998 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -70,6 +70,15 @@ + + + + + + + + + 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 f8a37c39a7..a7db47ba89 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 @@ -34,6 +34,11 @@ val badgeStoreProductIds: List = 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; change it to point Buy in browser at another deployment +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, 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 9ebaf9bcdc..24300d95ac 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 @@ -57,10 +57,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(null) } val submitting = remember { mutableStateOf(false) } @@ -78,37 +112,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 +254,115 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) { onclick = onClick ) } + +fun openBadgeLink(rhId: Long?, codeText: String) { + val code = parseBadgeCode(codeText) + ?: return showCannotRedeemAlert(generalGetString(MR.strings.badges_error_invalid_code)) + ModalManager.end.showCustomModal { close -> + BadgesRedeemLinkView(rhId, code, close) + } +} + +enum class BadgeLinkStep { + Confirming, + Issuing, + Redeemed +} + +// Any web page or chat message 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. +@Composable +fun BadgesRedeemLinkView(rhId: Long?, code: String, close: () -> Unit) { + val step = remember { mutableStateOf(BadgeLinkStep.Confirming) } + + fun redeemFromLink() { + val user = chatModel.currentUser.value ?: return close() + step.value = BadgeLinkStep.Issuing + withBGApi { + val outcome = redeemBadgeCode(rhId, user, code) + withContext(Dispatchers.Main) { + when (outcome) { + is BadgeRedeemOutcome.Redeemed -> step.value = BadgeLinkStep.Redeemed + is BadgeRedeemOutcome.Refused -> { + close() + showCannotRedeemAlert(outcome.message) + } + is BadgeRedeemOutcome.Cancelled -> close() + } + } + } + } + + when (step.value) { + BadgeLinkStep.Confirming -> ModalView(close) { Confirming(onConfirm = ::redeemFromLink, onCancel = close) } + BadgeLinkStep.Issuing -> ModalView(close) { BeingIssued() } + BadgeLinkStep.Redeemed -> BadgesView(ModalManager.end, close) + } +} + +@Composable +private fun Confirming(onConfirm: () -> Unit, onCancel: () -> 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(MR.strings.badges_link_confirm_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + String.format(stringResource(MR.strings.badges_link_confirm_profile), chatModel.currentUser.value?.displayName ?: ""), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_link_add_badge, + onboarding = null, + onclick = onConfirm + ) + TextButtonBelowOnboardingButton(stringResource(MR.strings.cancel_verb), onCancel) + } + } +} + +@Composable +private fun BeingIssued() { + 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(MR.strings.badges_being_issued), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.weight(1f)) + + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + + Spacer(Modifier.weight(1f)) + } +} 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 7fa32edaf3..9cbe2c57cc 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 @@ -66,8 +66,8 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { Spacer(Modifier.weight(1f)) Column(horizontalAlignment = Alignment.CenterHorizontally) { + BuyInBrowserButton(modalManager) RedeemCodeButton(modalManager) - GetCodeButton() } } } @@ -112,22 +112,24 @@ 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.showModal { BadgesRedeemCodeView(modalManager) } + } ) } 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 fae1b7c155..c16b88dcf9 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 @@ -776,6 +776,9 @@ 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 + openAppLink(rhId, uri) } else { withBGApi { chatModel.appOpenUrlConnecting.value = true @@ -784,6 +787,25 @@ 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 + val path = uriCreateOrNull(uri)?.path ?: return + 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, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState) { Box { 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 82dfe5152e..42b1ff7a96 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -25,6 +25,7 @@ You are an owner Invalid link Please check that SimpleX link is correct. + This link is not supported by this app version. Please check for app updates. Opening database… @@ -3182,8 +3183,12 @@ Purchase pending The purchase is awaiting approval. This build does not deliver purchases approved later. Purchase error - Get your code + Buy in browser Redeem code + Add badge to your profile? + The badge will be added to the profile %1$s. + Add badge + Badge is being issued Paste the code you received. SB-XXXXX-XXXXX-XXXXX-XXXXX Redeem