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