From c171008229f9ebb20d6ad10505c83228c24389e5 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:59:13 +0000 Subject: [PATCH 01/84] docs: wireframes and plan for buying a badge in the app; ios, android: align the badge screens (#7572) --- ...View.swift => BadgesChooseBadgeView.swift} | 24 +- ...sPayView.swift => BadgesHowLongView.swift} | 21 +- .../Badges/BadgesSupportSimplexView.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 +- ...rLevelView.kt => BadgesChooseBadgeView.kt} | 20 +- ...{BadgesPayView.kt => BadgesHowLongView.kt} | 16 +- .../views/badges/BadgesSupportSimplexView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 8 +- plans/2026-09-24-badge-buy-in-browser.md | 114 + ...026-09-23-badge-in-app-checkout.excalidraw | 9352 +++++++++++++++++ 10 files changed, 9528 insertions(+), 47 deletions(-) rename apps/ios/Shared/Views/Badges/{BadgesYourLevelView.swift => BadgesChooseBadgeView.swift} (88%) rename apps/ios/Shared/Views/Badges/{BadgesPayView.swift => BadgesHowLongView.swift} (95%) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/{BadgesYourLevelView.kt => BadgesChooseBadgeView.kt} (90%) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/{BadgesPayView.kt => BadgesHowLongView.kt} (97%) create mode 100644 plans/2026-09-24-badge-buy-in-browser.md create mode 100644 plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift similarity index 88% rename from apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift rename to apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift index d47bcaa0a7..077c9e5810 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift @@ -1,5 +1,5 @@ // -// BadgesYourLevelView.swift +// BadgesChooseBadgeView.swift // SimpleX (iOS) // // Created by spaced4ndy on 28.07.2026. @@ -30,10 +30,10 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } - var tagline: LocalizedStringKey { + var summary: LocalizedStringKey { switch self { - case .supporter: "Optional profile badge\nand 2GB files" - case .legend: "Optional profile badge\nand 5GB files" + case .supporter: "Supporter: 2 GB files available for 7 days." + case .legend: "Legend: 5 GB files available for 21 days." } } @@ -45,7 +45,7 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } -struct BadgesYourLevelView: View { +struct BadgesChooseBadgeView: View { @EnvironmentObject var theme: AppTheme @ObservedObject private var store = BadgeStore.shared @State private var selectedLevel: BadgeLevel = .supporter @@ -56,13 +56,19 @@ struct BadgesYourLevelView: View { GeometryReader { g in ScrollView { VStack(alignment: .center, spacing: 16) { - Text("Your level") + Text("Choose your badge") .font(.largeTitle) .bold() .foregroundColor(theme.colors.primary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) + Text("Larger files that stay available longer.") + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + BadgeUserPreview(level: selectedLevel) { Image(systemName: "chevron.down") .font(.body) @@ -145,7 +151,7 @@ struct BadgesYourLevelView: View { .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $continueActive) { - BadgesPayView(level: selectedLevel) + BadgesHowLongView(level: selectedLevel) .modifier(ThemedBackground()) } label: { EmptyView() @@ -179,10 +185,10 @@ struct BadgesYourLevelView: View { } } -struct BadgesYourLevelView_Previews: PreviewProvider { +struct BadgesChooseBadgeView_Previews: PreviewProvider { static var previews: some View { NavigationView { - BadgesYourLevelView() + BadgesChooseBadgeView() } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesPayView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift similarity index 95% rename from apps/ios/Shared/Views/Badges/BadgesPayView.swift rename to apps/ios/Shared/Views/Badges/BadgesHowLongView.swift index 707bdb3059..d2783383de 100644 --- a/apps/ios/Shared/Views/Badges/BadgesPayView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -1,5 +1,5 @@ // -// BadgesPayView.swift +// BadgesHowLongView.swift // SimpleX (iOS) // // Created by spaced4ndy on 28.07.2026. @@ -60,7 +60,7 @@ enum BadgePeriod: String, CaseIterable, Identifiable { } } -struct BadgesPayView: View { +struct BadgesHowLongView: View { @EnvironmentObject var theme: AppTheme @ObservedObject private var store = BadgeStore.shared let level: BadgeLevel @@ -73,21 +73,20 @@ struct BadgesPayView: View { GeometryReader { g in ScrollView { VStack(alignment: .center, spacing: 16) { - Text(level.title) + Text("How long?") .font(.largeTitle) .bold() .foregroundColor(theme.colors.primary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - BadgeUserPreview(level: level) - .padding(.top, 4) - - Text(level.tagline) + Text(level.summary) .font(.body) - .foregroundColor(theme.colors.onBackground) + .foregroundColor(theme.colors.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: level) .padding(.top, 4) Spacer(minLength: 20) @@ -202,7 +201,7 @@ struct BadgesPayView: View { } } } catch let error { - logger.error("BadgesPayView.purchase: \(String(describing: error))") + logger.error("BadgesHowLongView.purchase: \(String(describing: error))") await MainActor.run { purchasing = false alert = SomeAlert( @@ -260,10 +259,10 @@ struct BadgesPayView: View { } } -struct BadgesPayView_Previews: PreviewProvider { +struct BadgesHowLongView_Previews: PreviewProvider { static var previews: some View { NavigationView { - BadgesPayView(level: .supporter) + BadgesHowLongView(level: .supporter) } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 55d05f2615..44b1e48be1 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -76,7 +76,7 @@ struct BadgesSupportSimplexView: View { .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $chooseLevelActive) { - BadgesYourLevelView() + BadgesChooseBadgeView() .modifier(ThemedBackground()) } label: { EmptyView() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e0a634a713..d4171b89ae 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -166,6 +166,8 @@ 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; }; 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; }; 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; + 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */; }; + 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2B30666C640032110E /* BadgesHowLongView.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; @@ -181,13 +183,11 @@ 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; - 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; }; 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; }; 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; }; 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; }; 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; }; 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; }; - 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; }; 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; @@ -558,6 +558,8 @@ 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; + 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = ""; }; + 647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; @@ -575,11 +577,9 @@ 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; - 64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; 64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = ""; }; - 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; @@ -1217,14 +1217,14 @@ 64C03BF8302F423300072BDE /* Badges */ = { isa = PBXGroup; children = ( + 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */, + 647C9E2B30666C640032110E /* BadgesHowLongView.swift */, 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */, 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, - 64C03BF1302F423300072BDE /* BadgesPayView.swift */, 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */, 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */, 64C03BF4302F423300072BDE /* BadgeStore.swift */, - 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */, 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */, 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */, 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */, @@ -1649,13 +1649,11 @@ 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */, 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, - 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */, 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */, 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */, 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */, 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */, 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */, - 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */, 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */, @@ -1672,6 +1670,8 @@ CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, + 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */, + 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */, 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt similarity index 90% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt index 161702704b..b8324cc9c9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt @@ -40,10 +40,10 @@ enum class BadgeLevel { Legend -> MR.strings.badges_level_legend_files } - val tagline: StringResource + val summary: StringResource get() = when (this) { - Supporter -> MR.strings.badges_level_supporter_tagline - Legend -> MR.strings.badges_level_legend_tagline + Supporter -> MR.strings.badges_level_supporter_summary + Legend -> MR.strings.badges_level_legend_summary } val badgeType: BadgeType @@ -54,7 +54,7 @@ enum class BadgeLevel { } @Composable -fun BadgesYourLevelView(modalManager: ModalManager) { +fun BadgesChooseBadgeView(modalManager: ModalManager) { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } LaunchedEffect(Unit) { BadgeStore.load() } @@ -66,7 +66,7 @@ fun BadgesYourLevelView(modalManager: ModalManager) { maxIntrinsicSize = true, ) { Text( - stringResource(MR.strings.badges_your_level_title), + stringResource(MR.strings.badges_choose_your_badge_title), style = MaterialTheme.typography.h1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary, @@ -74,6 +74,14 @@ fun BadgesYourLevelView(modalManager: ModalManager) { modifier = Modifier.fillMaxWidth() ) + Text( + stringResource(MR.strings.badges_choose_your_badge_lede), + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) { Icon( painterResource(MR.images.ic_keyboard_arrow_down), @@ -146,7 +154,7 @@ private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager labelId = MR.strings.badges_continue, onboarding = null, onclick = { - modalManager.showModal { BadgesPayView(selectedLevel) } + modalManager.showModal { BadgesHowLongView(selectedLevel) } } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt similarity index 97% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index d7a10f38f7..2845e3986a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -68,7 +68,7 @@ enum class BadgePeriod { } @Composable -fun BadgesPayView(level: BadgeLevel) { +fun BadgesHowLongView(level: BadgeLevel) { var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } val purchasing = remember { mutableStateOf(false) } val clipboard = LocalClipboardManager.current @@ -82,7 +82,7 @@ fun BadgesPayView(level: BadgeLevel) { maxIntrinsicSize = true, ) { Text( - stringResource(level.title), + stringResource(MR.strings.badges_how_long_title), style = MaterialTheme.typography.h1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary, @@ -90,16 +90,16 @@ fun BadgesPayView(level: BadgeLevel) { modifier = Modifier.fillMaxWidth() ) - BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) - Text( - stringResource(level.tagline), + stringResource(level.summary), style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground, + color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + modifier = Modifier.fillMaxWidth() ) + BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - @@ -206,7 +206,7 @@ private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: Mutable is BadgePurchaseOutcome.Cancelled -> {} } } catch (e: Exception) { - Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}") + Log.e(TAG, "BadgesHowLongView.purchase: ${e.stackTraceToString()}") purchasing.value = false AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.badges_purchase_error), 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 4f1cc2ed66..7fa32edaf3 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 @@ -80,7 +80,7 @@ private fun ChooseLevelButton(modalManager: ModalManager) { labelId = MR.strings.badges_choose_your_level, onboarding = null, onclick = { - modalManager.showModal { BadgesYourLevelView(modalManager) } + modalManager.showModal { BadgesChooseBadgeView(modalManager) } } ) } 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 42aecc9cfe..82dfe5152e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3151,14 +3151,16 @@ Loading… Not available Save %1$s - Optional profile badge\nand 2GB files - Optional profile badge\nand 5GB files + Supporter: 2 GB files available for 7 days. + Legend: 5 GB files available for 21 days. 1 month Monthly Annual Renews on %1$s. Cancel anytime. Ends on %1$s. - Your level + Choose your badge + Larger files that stay available longer. + How long? Support SimpleX Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile. Why SimpleX is built. diff --git a/plans/2026-09-24-badge-buy-in-browser.md b/plans/2026-09-24-badge-buy-in-browser.md new file mode 100644 index 0000000000..4dea4782f4 --- /dev/null +++ b/plans/2026-09-24-badge-buy-in-browser.md @@ -0,0 +1,114 @@ +# Buying a badge in the browser + +The web page already sells badge codes. This makes the app the way in without making the app a shop: the app opens the page, the page runs the checkout it already has, and at the end hands the code back through a deep link instead of putting it on screen. The buyer never sees a code, and the code purchase itself is untouched. + +Wireframes: `plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw`. Panel ids below (A1, B3, D2…) refer to it, and the screens are drawn there rather than described again here. + +## Scope + +**Part 1** is everything in the wireframes except recurring payments: the browser lane on both builds, and the store lane's one-time product. Monthly and Annual stay on B3 and keep exactly the behaviour they have now — a real store transaction followed by a diagnostic alert, nothing sent anywhere. They must not reach the service, which cannot yet add months to a badge that already exists. + +The two lanes of part 1 are independent and unequal: the browser lane needs nothing from the service and can ship on its own, while the store lane cannot start until `purchaseBadge` accepts store receipts. + +**Part 2** is subscriptions, outlined at the end. + +Everything in the app sections below is both platforms unless it says otherwise. + +## Order of work + +1. **The renames**, on their own. Both screens are unreachable today, so nothing visible changes and the diff is strings and two titles. +2. **The page** — the `app` parameter, the second-screen start, both endings. Nothing sends the flag yet, so this is inert whenever it lands. It sits on the unmerged badge-service branch, which decides where it gets committed. +3. **The app's browser lane** — Buy in browser, the link handler, A6, and the desktop pair. End to end once 2 is deployed; before that it degrades to today's flow, where the buyer carries the code by hand. +4. **`purchaseBadge` with store receipts**, which depends on none of the above and can run alongside it. +5. **The app's store lane**, last, because nothing in it completes without 4. + +(*) **Desktop deep links** sit outside this order entirely — any time or never, blocking nothing. Own section below. + +## The page + +Three changes, all in `apps/simplex-badge-service/web`. + +**Start at the second screen.** The page already routes on the fragment, and `#/tier` is one of its own step hashes, landing on Choose your badge — the screen after its own Support SimpleX, which the app has just shown. But `landingIndex()` compares the whole hash, so `#/tier?app=true` matches no step and falls to the welcome panel. Parsing the flag's tail is what makes this work; it is not free. + +The app opens the page at its own host, `https://badges.simplex.chat/#/tier?app=true`. Not `simplex.chat/badges/`, which the app links to today: that is an Eleventy page whose layout is a bare `` to a fixed URL, so the fragment does not survive it. The redirect stays as it is for people who type it. Both platforms should hold the page URL as one constant, since a dev deployment of the service has its own page and the app has to be able to point at it. + +**Carry an `app` flag through the flow.** It rides in the fragment beside the step — `#/tier?app=true`, or `?app=desktop` — rather than in the query, because a fragment is never sent: the service learns nothing about which visitors came from the app, and it is stripped from the `Referer` when the page hands off to a payment provider. Nothing server-side needs it, and both things it decides are rendering. + +Only the ending needs it, and getting it there takes two carriers, because nothing single-handedly spans the flow. The session record holds it from the first load to checkout. At `created` the session is cleared and the URL is rewritten to a relative `?order=`, which drops the fragment, and a card payment returns to `origin + pathname` with neither — so the flag is then stamped onto the order record, beside the browser-only fields it already keeps, and the ending reads the order alone. An order resumed in another browser has no record, falls back to the plain page, and shows the code; that is correct. Keeping the flag in the session past checkout instead would be smaller and wrong: it would outlive the purchase, and it could not be cleared on a flagless load, because a reload and the card return are both flagless. + +**Replace the final screen for `app=true` (A5).** Try the deep link on load, then offer *Return to SimpleX* with *Show code* under it. `Show code` reveals the existing `codeIssued` content, unchanged — which means the code must still be minted and saved in the browser exactly as now. For `app=desktop` (D2), keep `codeIssued` as it is and change only the "Redeem it in the app" line to say the screen is already open. With no flag, nothing changes. + +## The link + +`simplexchat:/badge/code/` — a scheme of its own, not a path added to `simplex:`. That costs a registration on every platform, including the two where `simplex:` was already free, and buys a badge link that cannot be mistaken for a connection link and an existing dispatch left alone: the URL arrives at the same entry point the app already has, branches on scheme before the path switch that feeds `planAndConnect`, and never reaches it. + +Two things the code must get right: + +The link is a bearer token. Anything that can read it can redeem the badge — no worse than a code on screen, but it must not be logged. iOS logs every incoming URL in `SimpleXApp.onOpenURL` today and Kotlin should be checked for the same; those lines have to stop short of the code. Any web page can send one, so the screen it opens should do nothing but redeem. + +A link may arrive when no profile is active, or for a different profile than the one that bought it. Redeem into the active profile, as the redeem screen does, and let the existing errors speak if the code is spent. + +## The app: non-store (A1, A6) + +On Support SimpleX, *Get your code* becomes the primary button *Buy in browser* and *Redeem badge code* drops to the text button. The tap must open the browser directly, not through `openExternalLink`, which alerts for anything that is not a SimpleX connection link. The info button stays as it is: the TODO in that file proposing to swap it for *Why SimpleX is built* should not be followed. `chooseLevelButton()` is never called in these builds — there is no in-app purchase here at all. + +A6 is the one new screen: a title, a spinner, and a call to the existing redeem API with the code from the link. It is reachable only from the link, never from navigation, and a link can arrive from anywhere in the app — present it the way the badge sheet is already presented from an alert. Its failures are the redeem screen's failures and should use the same alerts. On success the app lands on Your badge by the path it already takes. + +## The app: store builds (B1–B5) + +B1's primary becomes *Choose your badge* — `chooseLevelButton()`, which already exists and is called by nothing. Its text button is *Buy in browser* where the store rules allow it and *Redeem code* where they do not, so the slot is never empty and codes are always reachable. Define that predicate once and use it for both the button and B2's — the rule is expected to be wider than the crowdfunding banner's and is not settled, so it should be one thing to change later. + +B2 (`BadgesChooseBadgeView`) gains *Redeem code* only when B1's slot is taken by *Buy in browser*. The privacy link stays on B1 in both builds and does not move here. + +B3 (`BadgesHowLongView`) loses its Pay button to *Continue*; `purchase()` moves to B4, which is new. Three of B5's outcomes need building: a verified transaction becomes a funded-not-issued state, `.pending` becomes an awaiting-the-store state, and an unverified one is refused with an alert and never sent. Both of those states belong on `BadgeState` and both must hold the Support SimpleX slot, because a consumable can otherwise be bought twice. Cancelled does nothing, and other billing errors keep today's alert. + +Two invariants that are easy to miss. A store transaction must not be finished until the credential is stored — `BadgeStore` says so in a comment already and does the opposite because nothing is delivered yet. And both platforms need reconciliation at launch (`Transaction.updates`, `queryPurchasesAsync`), because an outcome can arrive while the app is closed. + +## Desktop (D1, D2) + +No deep link. *Buy in browser* opens the page with `app=desktop` and the Redeem code screen at the same time, so the code has somewhere to go the moment it appears. This is also the fallback for any platform where the scheme turns out not to work: nothing else in the app depends on the link existing. + +## Desktop deep links (*) + +Optional, off the critical path, and blocking nothing — D1 works without it. Worth investigating on its own; if it lands, desktop joins lane A and D1 becomes the fallback rather than the design. + +Nothing registers a scheme on desktop today. `appOpenUrl` is wired in commonMain with no desktop implementation behind it, and `desktop/build.gradle.kts` declares Deb, Dmg, Msi and Exe with no protocol entry. Registration is per-OS, three separate small jobs: `CFBundleURLTypes` in the bundle plist plus `Desktop.setOpenURIHandler` on macOS, registry keys under `HKCU\Software\Classes` that the installer would have to write on Windows, and a `.desktop` entry with `MimeType=x-scheme-handler/simplexchat` on Linux. None of them covers an unpackaged run, so the fallback stays either way. + +Getting the URL to an app that is already running is the part that half exists. `SingleInstance.kt` takes a file lock and uses a watched file to bring the running instance forward on a second launch; carrying a URL means giving that channel a payload rather than inventing IPC for it. + +## The service + +The browser lane needs nothing. The store lane needs `purchaseBadge` to accept a store receipt and verify it — Apple's JWS offline, Google's token through the Publisher API, per `docs/protocol/badges-rpc.md`. No provider adapter for either exists today. + +Two properties matter more than the plumbing. It must be idempotent per signing key, since the app will retry. And an unknown or invalid token must be a plain refusal, not a retryable error — Android has no local verification, so tampered clients will send junk tokens as a matter of course, and a retryable answer would leave the worker grinding on one that can never become valid. + +`badges-rpc.md` also specifies `getBadgeCatalog` and `getBadgeInvoice`. Neither is needed by anything here and neither should be built: the page owns the catalog and the invoice. + +## Naming + +Two renames: *Your level* becomes *Choose your badge* with the page's lede, and the duration screen's title stops being the level name and becomes *How long?* with the chosen badge on the line below. This is the page's vocabulary; the app adopts it so the two halves of one purchase read the same. + +The views take their screens' names too, in a commit of their own: `BadgesYourLevelView` becomes `BadgesChooseBadgeView` and `BadgesPayView` becomes `BadgesHowLongView`, matching the directory's habit of naming a view after its title. `BadgeLevel` and `BadgePeriod` are domain types rather than views and keep their names — and `BadgeLevel` is marked for replacement by the purchase API's own types, so renaming it would be churn. Kotlin resource ids change along with their values — `badges_your_level_title` becomes `badges_choose_your_badge_title` and so on. Keeping an id while changing its English would leave every translation of it saying the old thing and still being shown, since a changed base value does not invalidate a translation; renaming retires them and gets the string translated afresh, which is what iOS does for free, its literals being their own keys. An id removed from base has to be removed from each translation file too, or the build fails formatting them. None of this costs anything today: no `badges_*` string has been through a translation round, so `base/strings.xml` is the only file that holds any. It stops being free the moment they are translated. + +## Tests + +The page has screen and routing tests; extend those rather than starting a parallel suite, and cover all three cases, including no flag at all. No UI tests on the app side — verify those by review. + +The store purchase needs end-to-end coverage and `BadgeServiceTests` is already the place: it runs a real client against a real service over SMP and covers redeeming, issuing, renewal and the worker. Everything from `APIPurchaseBadge` rightwards belongs there, with the receipt an opaque blob and verification behind a test provider that accepts one known token — the same seam the crypto and card providers already sit behind. Worth covering: a valid receipt issues a credential; the same receipt twice returns that same credential; an unknown token is refused and the refusal is not retryable; a receipt whose purchase key is not the signer is refused, as the code path is already tested for. + +The store SDK call itself cannot be automated. StoreKit needs a configuration file on a device, Play needs a real billing connection, and `.pending` needs a sandbox family account with Ask to Buy. That part is QA, and `useBadgeTestProducts` already exists for walking the Kotlin flow without Play Console products. + +## Comments + +Default to none. Add one only where behaviour or a consequence cannot be carried by names and structure — the transaction-finish ordering, the unlogged code, the retryability rule above are the kind of thing that earns one. Do not comment each function, do not restate the code, and do not reference this plan, a panel id or a ticket in the source. + +## Open questions + +- **Will a browser follow the scheme without a gesture?** Safari and Chrome both block some automatic scheme navigations, and a blocked one can show an error page rather than nothing. *Return to SimpleX* exists for this; the question is only whether the automatic attempt is worth making. +- **Where the browser button is allowed.** Which markets, and whether the same answer governs both builds. + +## Part 2: subscriptions + +Monthly and Annual become real purchases. The service side is the work: a subscription renews without the app present, so the badge has to gain months from a provider notification rather than from a `purchaseBadge` call, which means Apple's server notifications and Google's Pub/Sub, and a way to add months to a badge that already exists. The app side is smaller but not only copy: `BadgeState.renewsAt` and `willRenew` are hardcoded to nothing today and have to become real, and `BadgesYourBadgeView`'s Ends section says prepaid months have no billing date, which stops being true and needs a second wording. + +The browser lane never gains subscriptions: the page sells prepaid months and says so. diff --git a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw new file mode 100644 index 0000000000..78f7d9987a --- /dev/null +++ b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw @@ -0,0 +1,9352 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw-jetbrains-plugin", + "elements": [ + { + "id": "hdr-bg", + "type": "rectangle", + "x": 0, + "y": 0, + "width": 2500, + "height": 96, + "strokeColor": "transparent", + "backgroundColor": "#1c1e26", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": null, + "groupIds": [ + "header" + ], + "seed": 1001, + "index": "a000", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "hdr-title", + "type": "text", + "x": 40, + "y": 24, + "width": 620, + "height": 28, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "header" + ], + "seed": 1002, + "text": "Buying a badge - hidden codes", + "fontSize": 22, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a001", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Buying a badge - hidden codes", + "lineHeight": 1.2727272727272727 + }, + { + "id": "hdr-sub", + "type": "text", + "x": 40, + "y": 58, + "width": 1500, + "height": 15, + "strokeColor": "#a0a6b0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "header" + ], + "seed": 1003, + "text": "the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not", + "fontSize": 12, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a002", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not", + "lineHeight": 1.25 + }, + { + "id": "hdr-r1", + "type": "text", + "x": 2260, + "y": 26, + "width": 200, + "height": 15, + "strokeColor": "#a0a6b0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "header" + ], + "seed": 1004, + "text": "in-app checkout", + "fontSize": 12, + "fontFamily": 2, + "textAlign": "right", + "verticalAlign": "top", + "autoResize": false, + "index": "a003", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "in-app checkout", + "lineHeight": 1.25 + }, + { + "id": "hdr-r2", + "type": "text", + "x": 2260, + "y": 48, + "width": 200, + "height": 14, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "header" + ], + "seed": 1005, + "text": "wireframes", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "right", + "verticalAlign": "top", + "autoResize": false, + "index": "a004", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "wireframes", + "lineHeight": 1.2727272727272727 + }, + { + "id": "l1-band", + "type": "rectangle", + "x": 20, + "y": 150, + "width": 2460, + "height": 850, + "strokeColor": "transparent", + "backgroundColor": "#eef5ff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "lane1" + ], + "seed": 1010, + "index": "a010", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "l1-head", + "type": "text", + "x": 60, + "y": 178, + "width": 900, + "height": 22, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "lane1" + ], + "seed": 1011, + "text": "Non-store builds - the browser does all of it", + "fontSize": 17, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a011", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Non-store builds - the browser does all of it", + "lineHeight": 1.2941176470588236 + }, + { + "id": "l1-sub", + "type": "text", + "x": 60, + "y": 206, + "width": 1300, + "height": 14, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "lane1" + ], + "seed": 1012, + "text": "no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a012", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home", + "lineHeight": 1.2727272727272727 + }, + { + "id": "a1-panel", + "type": "rectangle", + "x": 60, + "y": 260, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1101, + "boundElements": [ + { + "id": "ar-a1a2", + "type": "arrow" + } + ], + "index": "a020", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a1-title", + "type": "text", + "x": 80, + "y": 306, + "width": 260, + "height": 24, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1102, + "text": "Support SimpleX", + "fontSize": 19, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a021", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Support SimpleX", + "lineHeight": 1.263157894736842 + }, + { + "id": "a1-body", + "type": "text", + "x": 76, + "y": 348, + "width": 268, + "height": 39, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1103, + "text": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a022", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", + "lineHeight": 1.3 + }, + { + "id": "a1-info", + "type": "text", + "x": 80, + "y": 404, + "width": 260, + "height": 13, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1104, + "text": "(i) How badges protect your privacy", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a023", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "(i) How badges protect your privacy", + "lineHeight": 1.3 + }, + { + "id": "a1-hero", + "type": "rectangle", + "x": 132, + "y": 448, + "width": 156, + "height": 156, + "strokeColor": "transparent", + "backgroundColor": "#e8eef7", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1105, + "index": "a024", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a1-herot", + "type": "text", + "x": 132, + "y": 519, + "width": 156, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1106, + "text": "phone-supporter", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a025", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "phone-supporter", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a1-btn", + "type": "rectangle", + "x": 90, + "y": 730, + "width": 240, + "height": 38, + "strokeColor": "transparent", + "backgroundColor": "#0053d0", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1107, + "boundElements": [ + { + "id": "a1-btnt", + "type": "text" + } + ], + "index": "a026", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a1-btnt", + "type": "text", + "x": 100, + "y": 742, + "width": 220, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1108, + "text": "Buy in browser", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a1-btn", + "index": "a027", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Buy in browser", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "a1-sec", + "type": "text", + "x": 80, + "y": 786, + "width": 260, + "height": 13, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1109, + "text": "Redeem code", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a028", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Redeem code", + "lineHeight": 1.3 + }, + { + "id": "a1-secn", + "type": "text", + "x": 80, + "y": 806, + "width": 260, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1112, + "text": "the manual path, for a code from elsewhere", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a029", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "the manual path, for a code from elsewhere", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a1-capt", + "type": "text", + "x": 60, + "y": 900, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1110, + "text": "A1. BadgesSupportSimplexView", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a02a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "A1. BadgesSupportSimplexView", + "lineHeight": 1.2727272727272727 + }, + { + "id": "a1-capb", + "type": "text", + "x": 60, + "y": 920, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A1", + "lane1" + ], + "seed": 1111, + "text": "Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a02b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.", + "lineHeight": 1.2222222222222223 + }, + { + "id": "a2-panel", + "type": "rectangle", + "x": 480, + "y": 260, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1201, + "boundElements": [ + { + "id": "ar-a1a2", + "type": "arrow" + }, + { + "id": "ar-a2a3", + "type": "arrow" + } + ], + "index": "a030", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a2-url", + "type": "rectangle", + "x": 480, + "y": 260, + "width": 300, + "height": 26, + "strokeColor": "transparent", + "backgroundColor": "#eceef1", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1202, + "index": "a031", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a2-urlt", + "type": "text", + "x": 496, + "y": 268, + "width": 268, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1203, + "text": "simplex.chat/badges/#app=true", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a032", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "simplex.chat/badges/#app=true", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a2-title", + "type": "text", + "x": 496, + "y": 312, + "width": 268, + "height": 22, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1204, + "text": "Choose your badge", + "fontSize": 17, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a033", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Choose your badge", + "lineHeight": 1.2941176470588236 + }, + { + "id": "a2-lede", + "type": "text", + "x": 496, + "y": 342, + "width": 268, + "height": 13, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1205, + "text": "Larger files that stay available longer.", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a034", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Larger files that stay available longer.", + "lineHeight": 1.368421052631579 + }, + { + "id": "a2-c1", + "type": "rectangle", + "x": 496, + "y": 378, + "width": 130, + "height": 150, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1206, + "index": "a035", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a2-c1i", + "type": "rectangle", + "x": 545, + "y": 398, + "width": 32, + "height": 32, + "strokeColor": "transparent", + "backgroundColor": "#dfe4ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1207, + "index": "a036", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a2-c1t", + "type": "text", + "x": 504, + "y": 442, + "width": 114, + "height": 52, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1208, + "text": "Supporter\n2GB files\n\n$7/month", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a037", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Supporter\n2GB files\n\n$7/month", + "lineHeight": 1.3 + }, + { + "id": "a2-c2", + "type": "rectangle", + "x": 634, + "y": 378, + "width": 130, + "height": 150, + "strokeColor": "#0053d0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1209, + "index": "a038", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a2-c2i", + "type": "rectangle", + "x": 683, + "y": 398, + "width": 32, + "height": 32, + "strokeColor": "transparent", + "backgroundColor": "#dfe4ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1210, + "index": "a039", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a2-c2t", + "type": "text", + "x": 642, + "y": 442, + "width": 114, + "height": 52, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1211, + "text": "Legend\n5GB files\n\n$70/month", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a03a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Legend\n5GB files\n\n$70/month", + "lineHeight": 1.3 + }, + { + "id": "a2-btn", + "type": "rectangle", + "x": 496, + "y": 560, + "width": 268, + "height": 36, + "strokeColor": "transparent", + "backgroundColor": "#0053d0", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1212, + "boundElements": [ + { + "id": "a2-btnt", + "type": "text" + } + ], + "index": "a03b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a2-btnt", + "type": "text", + "x": 506, + "y": 571, + "width": 248, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1213, + "text": "Continue", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a2-btn", + "index": "a03c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Continue", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "a2-capt", + "type": "text", + "x": 480, + "y": 900, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1215, + "text": "A2. Where the link lands", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a03d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "A2. Where the link lands", + "lineHeight": 1.2727272727272727 + }, + { + "id": "a2-capb", + "type": "text", + "x": 480, + "y": 920, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A2", + "lane1" + ], + "seed": 1216, + "text": "The page's own second screen, quoted as it is. #app=true\nskips its Support SimpleX, which the app has just shown.\nNEW on the page: read that flag, start here, and remember it\nthrough the flow. The app builds the URL, so it can also say\nwhich kind of app it is - better than the page guessing from\nthe user agent (see lane C).", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a03e", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "The page's own second screen, quoted as it is. #app=true\nskips its Support SimpleX, which the app has just shown.\nNEW on the page: read that flag, start here, and remember it\nthrough the flow. The app builds the URL, so it can also say\nwhich kind of app it is - better than the page guessing from\nthe user agent (see lane C).", + "lineHeight": 1.2222222222222223 + }, + { + "id": "a3-panel", + "type": "rectangle", + "x": 900, + "y": 260, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1301, + "boundElements": [ + { + "id": "ar-a2a3", + "type": "arrow" + }, + { + "id": "ar-a3a4", + "type": "arrow" + } + ], + "index": "a040", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a3-url", + "type": "rectangle", + "x": 900, + "y": 260, + "width": 300, + "height": 26, + "strokeColor": "transparent", + "backgroundColor": "#eceef1", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1302, + "index": "a041", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a3-urlt", + "type": "text", + "x": 916, + "y": 268, + "width": 268, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1303, + "text": "simplex.chat/badges/#app=true", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a042", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "simplex.chat/badges/#app=true", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a3-title", + "type": "text", + "x": 916, + "y": 312, + "width": 268, + "height": 22, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1304, + "text": "How long?", + "fontSize": 17, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a043", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "How long?", + "lineHeight": 1.2941176470588236 + }, + { + "id": "a3-lede", + "type": "text", + "x": 916, + "y": 342, + "width": 268, + "height": 13, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1305, + "text": "Legend - 5GB files, 21 days", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a044", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Legend - 5GB files, 21 days", + "lineHeight": 1.368421052631579 + }, + { + "id": "a3-c1", + "type": "rectangle", + "x": 916, + "y": 378, + "width": 84, + "height": 124, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1306, + "index": "a045", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a3-c1t", + "type": "text", + "x": 920, + "y": 418, + "width": 76, + "height": 39, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1307, + "text": "1 month\n\n$70", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a046", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "1 month\n\n$70", + "lineHeight": 1.3 + }, + { + "id": "a3-c2", + "type": "rectangle", + "x": 1008, + "y": 378, + "width": 84, + "height": 124, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1308, + "index": "a047", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a3-c2t", + "type": "text", + "x": 1012, + "y": 412, + "width": 76, + "height": 52, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1309, + "text": "3 months\n\n$140\none month free", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a048", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "3 months\n\n$140\none month free", + "lineHeight": 1.3 + }, + { + "id": "a3-c3", + "type": "rectangle", + "x": 1100, + "y": 378, + "width": 84, + "height": 124, + "strokeColor": "#0053d0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1310, + "index": "a049", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a3-c3t", + "type": "text", + "x": 1104, + "y": 412, + "width": 76, + "height": 52, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1311, + "text": "12 months\n\n$420\nsave 50%", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a04a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "12 months\n\n$420\nsave 50%", + "lineHeight": 1.3 + }, + { + "id": "a3-note", + "type": "text", + "x": 916, + "y": 520, + "width": 268, + "height": 24, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1312, + "text": "Paid once, for the months you choose.\nNo subscription.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a04b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Paid once, for the months you choose.\nNo subscription.", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a3-btn", + "type": "rectangle", + "x": 916, + "y": 560, + "width": 268, + "height": 36, + "strokeColor": "transparent", + "backgroundColor": "#0053d0", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1313, + "boundElements": [ + { + "id": "a3-btnt", + "type": "text" + } + ], + "index": "a04c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a3-btnt", + "type": "text", + "x": 926, + "y": 571, + "width": 248, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1314, + "text": "Continue", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a3-btn", + "index": "a04d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Continue", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "a3-capt", + "type": "text", + "x": 900, + "y": 900, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1315, + "text": "A3. Unchanged", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a04e", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "A3. Unchanged", + "lineHeight": 1.2727272727272727 + }, + { + "id": "a3-capb", + "type": "text", + "x": 900, + "y": 920, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A3", + "lane1" + ], + "seed": 1316, + "text": "The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a04f", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.", + "lineHeight": 1.4666666666666666 + }, + { + "id": "a4-panel", + "type": "rectangle", + "x": 1320, + "y": 260, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1401, + "boundElements": [ + { + "id": "ar-a3a4", + "type": "arrow" + }, + { + "id": "ar-a4a5", + "type": "arrow" + } + ], + "index": "a050", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a4-url", + "type": "rectangle", + "x": 1320, + "y": 260, + "width": 300, + "height": 26, + "strokeColor": "transparent", + "backgroundColor": "#eceef1", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1402, + "index": "a051", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a4-urlt", + "type": "text", + "x": 1336, + "y": 268, + "width": 268, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1403, + "text": "simplex.chat/badges/?order=8f3a...#app=true", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a052", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "simplex.chat/badges/?order=8f3a...#app=true", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a4-title", + "type": "text", + "x": 1336, + "y": 312, + "width": 268, + "height": 22, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1404, + "text": "Check your order", + "fontSize": 17, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a053", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Check your order", + "lineHeight": 1.2941176470588236 + }, + { + "id": "a4-rowsL", + "type": "text", + "x": 1336, + "y": 352, + "width": 160, + "height": 48, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1405, + "text": "Your badge\nDuration\nLaunch discount: 50% off\nTotal", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a054", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Your badge\nDuration\nLaunch discount: 50% off\nTotal", + "lineHeight": 1.263157894736842 + }, + { + "id": "a4-rowsR", + "type": "text", + "x": 1508, + "y": 352, + "width": 96, + "height": 48, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1406, + "text": "Legend\n12 months\n-$420.00\n$420.00", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "right", + "verticalAlign": "top", + "autoResize": false, + "index": "a055", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Legend\n12 months\n-$420.00\n$420.00", + "lineHeight": 1.263157894736842 + }, + { + "id": "a4-label", + "type": "text", + "x": 1336, + "y": 420, + "width": 100, + "height": 12, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1407, + "text": "PAY WITH", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a056", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "PAY WITH", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a4-m1", + "type": "rectangle", + "x": 1336, + "y": 438, + "width": 84, + "height": 60, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1408, + "index": "a057", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a4-m1t", + "type": "text", + "x": 1340, + "y": 460, + "width": 76, + "height": 13, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1409, + "text": "Bitcoin", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a058", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Bitcoin", + "lineHeight": 1.3 + }, + { + "id": "a4-m2", + "type": "rectangle", + "x": 1428, + "y": 438, + "width": 84, + "height": 60, + "strokeColor": "#0053d0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1410, + "index": "a059", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a4-m2t", + "type": "text", + "x": 1432, + "y": 460, + "width": 76, + "height": 13, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1411, + "text": "Monero", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a05a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Monero", + "lineHeight": 1.3 + }, + { + "id": "a4-m3", + "type": "rectangle", + "x": 1520, + "y": 438, + "width": 84, + "height": 60, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1412, + "index": "a05b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a4-m3t", + "type": "text", + "x": 1524, + "y": 460, + "width": 76, + "height": 13, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1413, + "text": "Card", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a05c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Card", + "lineHeight": 1.3 + }, + { + "id": "a4-btn", + "type": "rectangle", + "x": 1336, + "y": 560, + "width": 268, + "height": 36, + "strokeColor": "transparent", + "backgroundColor": "#0053d0", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1414, + "boundElements": [ + { + "id": "a4-btnt", + "type": "text" + } + ], + "index": "a05d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a4-btnt", + "type": "text", + "x": 1346, + "y": 571, + "width": 248, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1415, + "text": "Pay $420.00 with Monero", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a4-btn", + "index": "a05e", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Pay $420.00 with Monero", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "a4-capt", + "type": "text", + "x": 1320, + "y": 900, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1416, + "text": "A4. Unchanged, and the pay screens after it", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a05f", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "A4. Unchanged, and the pay screens after it", + "lineHeight": 1.2727272727272727 + }, + { + "id": "a4-capb", + "type": "text", + "x": 1320, + "y": 920, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A4", + "lane1" + ], + "seed": 1417, + "text": "The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a05g", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.", + "lineHeight": 1.4666666666666666 + }, + { + "id": "a5-panel", + "type": "rectangle", + "x": 1740, + "y": 260, + "width": 300, + "height": 620, + "strokeColor": "#0053d0", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1501, + "boundElements": [ + { + "id": "ar-a4a5", + "type": "arrow" + }, + { + "id": "ar-a5a6", + "type": "arrow" + } + ], + "index": "a060", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a5-url", + "type": "rectangle", + "x": 1740, + "y": 260, + "width": 300, + "height": 26, + "strokeColor": "transparent", + "backgroundColor": "#eceef1", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1502, + "index": "a061", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a5-urlt", + "type": "text", + "x": 1756, + "y": 268, + "width": 268, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1503, + "text": "simplex.chat/badges/?order=8f3a...#app=true", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a062", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "simplex.chat/badges/?order=8f3a...#app=true", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a5-title", + "type": "text", + "x": 1756, + "y": 312, + "width": 268, + "height": 22, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1504, + "text": "Paid", + "fontSize": 17, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a063", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Paid", + "lineHeight": 1.2941176470588236 + }, + { + "id": "a5-lede", + "type": "text", + "x": 1756, + "y": 344, + "width": 268, + "height": 26, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1505, + "text": "Opening SimpleX to add your badge.\nIf nothing happens, use the button.", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a064", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Opening SimpleX to add your badge.\nIf nothing happens, use the button.", + "lineHeight": 1.368421052631579 + }, + { + "id": "a5-btn", + "type": "rectangle", + "x": 1756, + "y": 396, + "width": 268, + "height": 38, + "strokeColor": "transparent", + "backgroundColor": "#0053d0", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1506, + "boundElements": [ + { + "id": "a5-btnt", + "type": "text" + } + ], + "index": "a065", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a5-btnt", + "type": "text", + "x": 1766, + "y": 408, + "width": 248, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1507, + "text": "Return to SimpleX", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a5-btn", + "index": "a066", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Return to SimpleX", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "a5-sec", + "type": "text", + "x": 1756, + "y": 452, + "width": 268, + "height": 13, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1508, + "text": "Show code", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a067", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Show code", + "lineHeight": 1.3 + }, + { + "id": "a5-link", + "type": "rectangle", + "x": 1756, + "y": 496, + "width": 268, + "height": 34, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1509, + "index": "a068", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a5-linkt", + "type": "text", + "x": 1762, + "y": 507, + "width": 256, + "height": 13, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1510, + "text": "simplexchat:/badge/code/SB-4K2M9-...", + "fontSize": 9, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a069", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "simplexchat:/badge/code/SB-4K2M9-...", + "lineHeight": 1.4444444444444444 + }, + { + "id": "a5-note", + "type": "text", + "x": 1756, + "y": 544, + "width": 268, + "height": 33, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1511, + "text": "the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a06a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup", + "lineHeight": 1.2222222222222223 + }, + { + "id": "a5-capt", + "type": "text", + "x": 1740, + "y": 900, + "width": 300, + "height": 14, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1512, + "text": "A5. New: the code leaves by link, not by eye", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a06b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "A5. New: the code leaves by link, not by eye", + "lineHeight": 1.2727272727272727 + }, + { + "id": "a5-capb", + "type": "text", + "x": 1740, + "y": 920, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A5", + "lane1" + ], + "seed": 1513, + "text": "Replaces codeIssued when #app=true. The code is still\nminted and still saved in the browser, so Show code\nrecovers it if the link fails. simplexchat: is a scheme of its\nown, registered everywhere including mobile, so a badge link\ncan never be read as a connection link. Whether a browser\nwill follow it with no tap is the open question.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a06c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Replaces codeIssued when #app=true. The code is still\nminted and still saved in the browser, so Show code\nrecovers it if the link fails. simplexchat: is a scheme of its\nown, registered everywhere including mobile, so a badge link\ncan never be read as a connection link. Whether a browser\nwill follow it with no tap is the open question.", + "lineHeight": 1.2222222222222223 + }, + { + "id": "a6-panel", + "type": "rectangle", + "x": 2160, + "y": 260, + "width": 300, + "height": 620, + "strokeColor": "#0053d0", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1601, + "boundElements": [ + { + "id": "ar-a5a6", + "type": "arrow" + } + ], + "index": "a070", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a6-title", + "type": "text", + "x": 2180, + "y": 306, + "width": 260, + "height": 24, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1602, + "text": "Badge is being issued", + "fontSize": 18, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a071", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Badge is being issued", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a6-spin", + "type": "ellipse", + "x": 2288, + "y": 380, + "width": 44, + "height": 44, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1603, + "index": "a072", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a6-spint", + "type": "text", + "x": 2180, + "y": 440, + "width": 260, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1604, + "text": "progress", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a073", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "progress", + "lineHeight": 1.3333333333333333 + }, + { + "id": "a6-body", + "type": "text", + "x": 2186, + "y": 478, + "width": 248, + "height": 39, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1605, + "text": "This takes a moment. You do not\nneed to do anything, and you can\nclose the app.", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a074", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "This takes a moment. You do not\nneed to do anything, and you can\nclose the app.", + "lineHeight": 1.368421052631579 + }, + { + "id": "a6-then", + "type": "rectangle", + "x": 2176, + "y": 600, + "width": 268, + "height": 96, + "strokeColor": "#8f97a3", + "backgroundColor": "#f2f3f5", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1606, + "index": "a075", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a6-theni", + "type": "rectangle", + "x": 2190, + "y": 612, + "width": 240, + "height": 72, + "strokeColor": "transparent", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1607, + "index": "a076", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a6-theng", + "type": "rectangle", + "x": 2298, + "y": 620, + "width": 24, + "height": 24, + "strokeColor": "transparent", + "backgroundColor": "#dfe4ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1608, + "index": "a077", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "a6-thent", + "type": "text", + "x": 2180, + "y": 650, + "width": 260, + "height": 13, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1609, + "text": "Legend", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a078", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Legend", + "lineHeight": 1.3 + }, + { + "id": "a6-thens", + "type": "text", + "x": 2180, + "y": 666, + "width": 260, + "height": 11, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1610, + "text": "shown on your profile", + "fontSize": 8.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a079", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "shown on your profile", + "lineHeight": 1.2941176470588236 + }, + { + "id": "a6-thenn", + "type": "text", + "x": 2160, + "y": 708, + "width": 300, + "height": 11, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1611, + "text": "then BadgesYourBadgeView, unchanged", + "fontSize": 8.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a07a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "then BadgesYourBadgeView, unchanged", + "lineHeight": 1.2941176470588236 + }, + { + "id": "a6-capt", + "type": "text", + "x": 2160, + "y": 900, + "width": 300, + "height": 14, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1612, + "text": "A6. New screen, and the only new app state", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a07b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "A6. New screen, and the only new app state", + "lineHeight": 1.2727272727272727 + }, + { + "id": "a6-capb", + "type": "text", + "x": 2160, + "y": 920, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "A6", + "lane1" + ], + "seed": 1613, + "text": "The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a07c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.", + "lineHeight": 1.2222222222222223 + }, + { + "id": "ar-a1a2", + "type": "arrow", + "x": 368, + "y": 570, + "width": 104, + "height": 0, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 1701, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-a1a2-t", + "type": "text" + } + ], + "index": "a080", + "version": 2, + "versionNonce": 1538965901, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251194517, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-a1a2-t", + "type": "text", + "x": 365.810546875, + "y": 563.5, + "width": 108.37890625, + "height": 13, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 1702, + "text": "Buy in browser - no alert", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-a1a2", + "index": "a081", + "version": 3, + "versionNonce": 709045891, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251191288, + "link": null, + "locked": false, + "originalText": "Buy in browser - no alert", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-a2a3", + "type": "arrow", + "x": 788, + "y": 570, + "width": 104, + "height": 0, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 1703, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-a2a3-t", + "type": "text" + } + ], + "index": "a082", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-a2a3-t", + "type": "text", + "x": 800, + "y": 556, + "width": 80, + "height": 13, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 1704, + "text": "Continue", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-a2a3", + "index": "a083", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Continue", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-a3a4", + "type": "arrow", + "x": 1208, + "y": 570, + "width": 104, + "height": 0, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 1705, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-a3a4-t", + "type": "text" + } + ], + "index": "a084", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-a3a4-t", + "type": "text", + "x": 1220, + "y": 556, + "width": 80, + "height": 13, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 1706, + "text": "Continue", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-a3a4", + "index": "a085", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Continue", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-a4a5", + "type": "arrow", + "x": 1628, + "y": 570, + "width": 104, + "height": 0, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 1707, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-a4a5-t", + "type": "text" + } + ], + "index": "a086", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-a4a5-t", + "type": "text", + "x": 1640, + "y": 556, + "width": 80, + "height": 13, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 1708, + "text": "it settles", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-a4a5", + "index": "a087", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "it settles", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-a5a6", + "type": "arrow", + "x": 2048, + "y": 570, + "width": 104, + "height": 0, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 1709, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-a5a6-t", + "type": "text" + } + ], + "index": "a088", + "version": 2, + "versionNonce": 737650275, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251382068, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-a5a6-t", + "type": "text", + "x": 2056.93115234375, + "y": 557, + "width": 86.1376953125, + "height": 26, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 1710, + "text": "simplexchat: carries\nthe code", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-a5a6", + "index": "a089", + "version": 4, + "versionNonce": 1743168653, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251380246, + "link": null, + "locked": false, + "originalText": "simplexchat: carries the code", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "l2-band", + "type": "rectangle", + "x": 20, + "y": 1040, + "width": 2460, + "height": 850, + "strokeColor": "transparent", + "backgroundColor": "#f5f1ff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "lane2" + ], + "seed": 2010, + "index": "a100", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "l2-head", + "type": "text", + "x": 60, + "y": 1068, + "width": 900, + "height": 22, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "lane2" + ], + "seed": 2011, + "text": "Store builds - the store path in the app, the browser alongside it", + "fontSize": 17, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a101", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Store builds - the store path in the app, the browser alongside it", + "lineHeight": 1.2941176470588236 + }, + { + "id": "l2-sub", + "type": "text", + "x": 60, + "y": 1096, + "width": 1300, + "height": 14, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "lane2" + ], + "seed": 2012, + "text": "Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a102", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here", + "lineHeight": 1.2727272727272727 + }, + { + "id": "b1-panel", + "type": "rectangle", + "x": 60, + "y": 1150, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2101, + "boundElements": [ + { + "id": "ar-b1b2", + "type": "arrow" + } + ], + "index": "a110", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b1-title", + "type": "text", + "x": 80, + "y": 1196, + "width": 260, + "height": 24, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2102, + "text": "Support SimpleX", + "fontSize": 19, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a111", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Support SimpleX", + "lineHeight": 1.263157894736842 + }, + { + "id": "b1-body", + "type": "text", + "x": 76, + "y": 1238, + "width": 268, + "height": 39, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2103, + "text": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a112", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", + "lineHeight": 1.3 + }, + { + "id": "b1-info", + "type": "text", + "x": 80, + "y": 1294, + "width": 260, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2104, + "text": "(i) How badges protect your privacy", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a113", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "(i) How badges protect your privacy", + "lineHeight": 1.3 + }, + { + "id": "b1-hero", + "type": "rectangle", + "x": 132, + "y": 1338, + "width": 156, + "height": 156, + "strokeColor": "transparent", + "backgroundColor": "#efeaf9", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2105, + "index": "a114", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b1-herot", + "type": "text", + "x": 132, + "y": 1409, + "width": 156, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2106, + "text": "phone-supporter", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a115", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "phone-supporter", + "lineHeight": 1.3333333333333333 + }, + { + "id": "b1-btn", + "type": "rectangle", + "x": 90, + "y": 1620, + "width": 240, + "height": 38, + "strokeColor": "transparent", + "backgroundColor": "#5b21b6", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2107, + "boundElements": [ + { + "id": "b1-btnt", + "type": "text" + } + ], + "index": "a116", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b1-btnt", + "type": "text", + "x": 100, + "y": 1632, + "width": 220, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2108, + "text": "Choose your badge", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "b1-btn", + "index": "a117", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Choose your badge", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "b1-sec", + "type": "text", + "x": 80, + "y": 1676, + "width": 260, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2109, + "text": "Buy in browser", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a118", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Buy in browser", + "lineHeight": 1.3 + }, + { + "id": "b1-secn", + "type": "text", + "x": 80, + "y": 1696, + "width": 260, + "height": 24, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2110, + "text": "where the browser is not allowed,\nthis slot reads Redeem code instead", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a119", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "where the browser is not allowed,\nthis slot reads Redeem code instead", + "lineHeight": 1.3333333333333333 + }, + { + "id": "b1-capt", + "type": "text", + "x": 60, + "y": 1790, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2111, + "text": "B1. Two buttons, one of them gated", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a11a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "B1. Two buttons, one of them gated", + "lineHeight": 1.2727272727272727 + }, + { + "id": "b1-capb", + "type": "text", + "x": 60, + "y": 1810, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B1", + "lane2" + ], + "seed": 2112, + "text": "Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a11b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.", + "lineHeight": 1.2222222222222223 + }, + { + "id": "b2-panel", + "type": "rectangle", + "x": 480, + "y": 1150, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2201, + "boundElements": [ + { + "id": "ar-b1b2", + "type": "arrow" + }, + { + "id": "ar-b2b3", + "type": "arrow" + } + ], + "index": "a120", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b2-back", + "type": "text", + "x": 496, + "y": 1168, + "width": 20, + "height": 14, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2202, + "text": "<", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a121", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "<", + "lineHeight": 1.2727272727272727 + }, + { + "id": "b2-title", + "type": "text", + "x": 500, + "y": 1196, + "width": 260, + "height": 24, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2203, + "text": "Choose your badge", + "fontSize": 19, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a122", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Choose your badge", + "lineHeight": 1.263157894736842 + }, + { + "id": "b2-lede", + "type": "text", + "x": 500, + "y": 1236, + "width": 260, + "height": 13, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2204, + "text": "Larger files that stay available longer.", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a123", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Larger files that stay available longer.", + "lineHeight": 1.368421052631579 + }, + { + "id": "b2-avatar", + "type": "ellipse", + "x": 598, + "y": 1268, + "width": 56, + "height": 56, + "strokeColor": "transparent", + "backgroundColor": "#e3e6ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2205, + "index": "a124", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b2-name", + "type": "text", + "x": 500, + "y": 1334, + "width": 260, + "height": 15, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2206, + "text": "My nickname [badge] v", + "fontSize": 12, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a125", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "My nickname [badge] v", + "lineHeight": 1.25 + }, + { + "id": "b2-c1", + "type": "rectangle", + "x": 492, + "y": 1378, + "width": 132, + "height": 150, + "strokeColor": "#5b21b6", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2207, + "index": "a126", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b2-c1i", + "type": "rectangle", + "x": 542, + "y": 1398, + "width": 32, + "height": 32, + "strokeColor": "transparent", + "backgroundColor": "#dfe4ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2208, + "index": "a127", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b2-c1t", + "type": "text", + "x": 500, + "y": 1442, + "width": 116, + "height": 52, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2209, + "text": "Supporter\nSend 2GB files\n\n$6.99/month", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a128", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Supporter\nSend 2GB files\n\n$6.99/month", + "lineHeight": 1.3 + }, + { + "id": "b2-c2", + "type": "rectangle", + "x": 636, + "y": 1378, + "width": 132, + "height": 150, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2210, + "index": "a129", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b2-c2i", + "type": "rectangle", + "x": 686, + "y": 1398, + "width": 32, + "height": 32, + "strokeColor": "transparent", + "backgroundColor": "#dfe4ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2211, + "index": "a12a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b2-c2t", + "type": "text", + "x": 644, + "y": 1442, + "width": 116, + "height": 52, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2212, + "text": "Legend\nSend 5GB files\n\n$69.99/month", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a12b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Legend\nSend 5GB files\n\n$69.99/month", + "lineHeight": 1.3 + }, + { + "id": "b2-btn", + "type": "rectangle", + "x": 510, + "y": 1596, + "width": 240, + "height": 38, + "strokeColor": "transparent", + "backgroundColor": "#5b21b6", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2213, + "boundElements": [ + { + "id": "b2-btnt", + "type": "text" + } + ], + "index": "a12c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001521, + "link": null, + "locked": false + }, + { + "id": "b2-btnt", + "type": "text", + "x": 520, + "y": 1608, + "width": 220, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2214, + "text": "Continue", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "b2-btn", + "index": "a12d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "originalText": "Continue", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "b2-sec", + "type": "text", + "x": 500, + "y": 1652, + "width": 260, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2215, + "text": "Redeem code", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a12e", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Redeem code", + "lineHeight": 1.3 + }, + { + "id": "b2-infon", + "type": "text", + "x": 500, + "y": 1676, + "width": 260, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2216, + "text": "here only when B1 offers the browser;\nthe privacy link always stays on B1", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a12f", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "here only when B1 offers the browser;\nthe privacy link always stays on B1", + "lineHeight": 1.3333333333333333 + }, + { + "id": "b2-capt", + "type": "text", + "x": 480, + "y": 1790, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2217, + "text": "B2. BadgesChooseBadgeView", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a130", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "B2. BadgesChooseBadgeView", + "lineHeight": 1.2727272727272727 + }, + { + "id": "b2-capb", + "type": "text", + "x": 480, + "y": 1810, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B2", + "lane2" + ], + "seed": 2218, + "text": "Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a131", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001521, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.", + "lineHeight": 1.4666666666666666 + }, + { + "id": "b3-panel", + "type": "rectangle", + "x": 900, + "y": 1150, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2301, + "boundElements": [ + { + "id": "ar-b2b3", + "type": "arrow" + }, + { + "id": "ar-b3b4", + "type": "arrow" + } + ], + "index": "a132", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b3-back", + "type": "text", + "x": 916, + "y": 1168, + "width": 20, + "height": 14, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2302, + "text": "<", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a133", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "<", + "lineHeight": 1.2727272727272727 + }, + { + "id": "b3-title", + "type": "text", + "x": 920, + "y": 1196, + "width": 260, + "height": 24, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2303, + "text": "How long?", + "fontSize": 19, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a134", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "How long?", + "lineHeight": 1.263157894736842 + }, + { + "id": "b3-lede", + "type": "text", + "x": 920, + "y": 1236, + "width": 260, + "height": 13, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2304, + "text": "Legend - 5GB files, 21 days", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a135", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Legend - 5GB files, 21 days", + "lineHeight": 1.368421052631579 + }, + { + "id": "b3-avatar", + "type": "ellipse", + "x": 1022, + "y": 1268, + "width": 56, + "height": 56, + "strokeColor": "transparent", + "backgroundColor": "#e3e6ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2305, + "index": "a136", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b3-name", + "type": "text", + "x": 920, + "y": 1334, + "width": 260, + "height": 15, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2306, + "text": "My nickname [badge]", + "fontSize": 12, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a137", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "My nickname [badge]", + "lineHeight": 1.25 + }, + { + "id": "b3-c1", + "type": "rectangle", + "x": 912, + "y": 1386, + "width": 85, + "height": 124, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2307, + "index": "a138", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b3-c1t", + "type": "text", + "x": 917, + "y": 1426, + "width": 75, + "height": 39, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2308, + "text": "1 month\n\n$69.99", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a139", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "1 month\n\n$69.99", + "lineHeight": 1.3 + }, + { + "id": "b3-c2", + "type": "rectangle", + "x": 1007, + "y": 1386, + "width": 85, + "height": 124, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2309, + "index": "a13a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b3-c2t", + "type": "text", + "x": 1010, + "y": 1426, + "width": 79, + "height": 39, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2310, + "text": "Monthly\n\n$69.99/month", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a13b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Monthly\n\n$69.99/month", + "lineHeight": 1.3 + }, + { + "id": "b3-c3", + "type": "rectangle", + "x": 1102, + "y": 1386, + "width": 85, + "height": 124, + "strokeColor": "#5b21b6", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 2, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2311, + "index": "a13c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b3-c3t", + "type": "text", + "x": 1105, + "y": 1420, + "width": 79, + "height": 52, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2312, + "text": "Annual\n\n$699.99/year\nSave 17%", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a13d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Annual\n\n$699.99/year\nSave 17%", + "lineHeight": 1.3 + }, + { + "id": "b3-btn", + "type": "rectangle", + "x": 930, + "y": 1620, + "width": 240, + "height": 38, + "strokeColor": "transparent", + "backgroundColor": "#5b21b6", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2313, + "boundElements": [ + { + "id": "b3-btnt", + "type": "text" + } + ], + "index": "a13e", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b3-btnt", + "type": "text", + "x": 940, + "y": 1632, + "width": 220, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2314, + "text": "Continue", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "b3-btn", + "index": "a13f", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "Continue", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "b3-foot", + "type": "text", + "x": 920, + "y": 1676, + "width": 260, + "height": 13, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2315, + "text": "Renews on 24 August 2027. Cancel anytime.", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a140", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Renews on 24 August 2027. Cancel anytime.", + "lineHeight": 1.368421052631579 + }, + { + "id": "b3-capt", + "type": "text", + "x": 900, + "y": 1790, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2316, + "text": "B3. BadgesHowLongView, disarmed", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a141", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "B3. BadgesHowLongView, disarmed", + "lineHeight": 1.2727272727272727 + }, + { + "id": "b3-capb", + "type": "text", + "x": 900, + "y": 1810, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B3", + "lane2" + ], + "seed": 2317, + "text": "The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a142", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.", + "lineHeight": 1.2222222222222223 + }, + { + "id": "b4-panel", + "type": "rectangle", + "x": 1320, + "y": 1150, + "width": 300, + "height": 620, + "strokeColor": "#5b21b6", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2401, + "boundElements": [ + { + "id": "ar-b3b4", + "type": "arrow" + }, + { + "id": "ar-b4o1", + "type": "arrow" + }, + { + "id": "ar-b4o2", + "type": "arrow" + }, + { + "id": "ar-b4o3", + "type": "arrow" + } + ], + "index": "a143", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b4-back", + "type": "text", + "x": 1336, + "y": 1168, + "width": 20, + "height": 14, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2402, + "text": "<", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a144", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "<", + "lineHeight": 1.2727272727272727 + }, + { + "id": "b4-title", + "type": "text", + "x": 1340, + "y": 1196, + "width": 260, + "height": 24, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2403, + "text": "Check your order", + "fontSize": 19, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a145", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Check your order", + "lineHeight": 1.263157894736842 + }, + { + "id": "b4-rowsL", + "type": "text", + "x": 1344, + "y": 1270, + "width": 160, + "height": 36, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2404, + "text": "Your badge\nDuration\nTotal", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a146", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Your badge\nDuration\nTotal", + "lineHeight": 1.263157894736842 + }, + { + "id": "b4-rowsR", + "type": "text", + "x": 1500, + "y": 1270, + "width": 96, + "height": 36, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2405, + "text": "Legend\nAnnual\n$699.99/year", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "right", + "verticalAlign": "top", + "autoResize": false, + "index": "a147", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Legend\nAnnual\n$699.99/year", + "lineHeight": 1.263157894736842 + }, + { + "id": "b4-div", + "type": "rectangle", + "x": 1344, + "y": 1292, + "width": 252, + "height": 1, + "strokeColor": "#dfe4ea", + "backgroundColor": "#dfe4ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": null, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2406, + "index": "a148", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b4-note", + "type": "text", + "x": 1344, + "y": 1336, + "width": 252, + "height": 26, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2407, + "text": "Charged by the App Store to the\naccount on this device.", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a149", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Charged by the App Store to the\naccount on this device.", + "lineHeight": 1.368421052631579 + }, + { + "id": "b4-btn", + "type": "rectangle", + "x": 1350, + "y": 1620, + "width": 240, + "height": 38, + "strokeColor": "transparent", + "backgroundColor": "#5b21b6", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2408, + "boundElements": [ + { + "id": "b4-btnt", + "type": "text" + } + ], + "index": "a14a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "b4-btnt", + "type": "text", + "x": 1360, + "y": 1632, + "width": 220, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2409, + "text": "Pay $699.99/year", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "b4-btn", + "index": "a14b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "Pay $699.99/year", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "b4-foot", + "type": "text", + "x": 1340, + "y": 1676, + "width": 260, + "height": 13, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2410, + "text": "Renews on 24 August 2027. Cancel anytime.", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a14c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Renews on 24 August 2027. Cancel anytime.", + "lineHeight": 1.368421052631579 + }, + { + "id": "b4-capt", + "type": "text", + "x": 1320, + "y": 1790, + "width": 300, + "height": 14, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2411, + "text": "B4. New screen, one for every market", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a14d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "B4. New screen, one for every market", + "lineHeight": 1.2727272727272727 + }, + { + "id": "b4-capb", + "type": "text", + "x": 1320, + "y": 1810, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "B4", + "lane2" + ], + "seed": 2412, + "text": "No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a14e", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.", + "lineHeight": 1.4666666666666666 + }, + { + "id": "o1-card", + "type": "rectangle", + "x": 1740, + "y": 1160, + "width": 300, + "height": 120, + "strokeColor": "#5b21b6", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "O1", + "lane2" + ], + "seed": 2601, + "boundElements": [ + { + "id": "ar-b4o1", + "type": "arrow" + } + ], + "index": "a150", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "o1-t", + "type": "text", + "x": 1760, + "y": 1174, + "width": 260, + "height": 17, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O1", + "lane2" + ], + "seed": 2602, + "text": "Paid", + "fontSize": 13, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a151", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Paid", + "lineHeight": 1.3076923076923077 + }, + { + "id": "o1-s", + "type": "text", + "x": 1760, + "y": 1198, + "width": 260, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O1", + "lane2" + ], + "seed": 2603, + "text": "o Issuing your badge", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a152", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "o Issuing your badge", + "lineHeight": 1.3 + }, + { + "id": "o1-b", + "type": "text", + "x": 1766, + "y": 1222, + "width": 248, + "height": 26, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O1", + "lane2" + ], + "seed": 2604, + "text": "The store has taken the payment.\nKeeps trying if you close the app.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a153", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "The store has taken the payment.\nKeeps trying if you close the app.", + "lineHeight": 1.4444444444444444 + }, + { + "id": "o2-card", + "type": "rectangle", + "x": 1740, + "y": 1300, + "width": 300, + "height": 96, + "strokeColor": "#8f97a3", + "backgroundColor": "#f2f3f5", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "O2", + "lane2" + ], + "seed": 2605, + "index": "a154", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "o2-i", + "type": "rectangle", + "x": 1756, + "y": 1312, + "width": 268, + "height": 72, + "strokeColor": "transparent", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "O2", + "lane2" + ], + "seed": 2606, + "index": "a155", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "o2-g", + "type": "rectangle", + "x": 1878, + "y": 1320, + "width": 24, + "height": 24, + "strokeColor": "transparent", + "backgroundColor": "#dfe4ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "O2", + "lane2" + ], + "seed": 2607, + "index": "a156", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "o2-t", + "type": "text", + "x": 1760, + "y": 1350, + "width": 260, + "height": 13, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O2", + "lane2" + ], + "seed": 2608, + "text": "Legend", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a157", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Legend", + "lineHeight": 1.3 + }, + { + "id": "o2-s", + "type": "text", + "x": 1760, + "y": 1366, + "width": 260, + "height": 11, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O2", + "lane2" + ], + "seed": 2609, + "text": "shown on your profile", + "fontSize": 8.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a158", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "shown on your profile", + "lineHeight": 1.2941176470588236 + }, + { + "id": "o2-n", + "type": "text", + "x": 1740, + "y": 1402, + "width": 300, + "height": 11, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O2", + "lane2" + ], + "seed": 2610, + "text": "BadgesYourBadgeView, unchanged", + "fontSize": 8.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a159", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "BadgesYourBadgeView, unchanged", + "lineHeight": 1.2941176470588236 + }, + { + "id": "o3-card", + "type": "rectangle", + "x": 1740, + "y": 1424, + "width": 300, + "height": 130, + "strokeColor": "#5b21b6", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "dashed", + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "O3", + "lane2" + ], + "seed": 2611, + "boundElements": [ + { + "id": "ar-b4o2", + "type": "arrow" + } + ], + "index": "a15a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "o3-t", + "type": "text", + "x": 1760, + "y": 1438, + "width": 260, + "height": 15, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O3", + "lane2" + ], + "seed": 2612, + "text": "Waiting for approval", + "fontSize": 12, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a15b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Waiting for approval", + "lineHeight": 1.25 + }, + { + "id": "o3-s", + "type": "text", + "x": 1760, + "y": 1460, + "width": 260, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O3", + "lane2" + ], + "seed": 2613, + "text": "o Nothing has been charged", + "fontSize": 9.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a15c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "o Nothing has been charged", + "lineHeight": 1.368421052631579 + }, + { + "id": "o3-b", + "type": "text", + "x": 1746, + "y": 1484, + "width": 288, + "height": 39, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O3", + "lane2" + ], + "seed": 2614, + "text": "Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a15d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.", + "lineHeight": 1.4444444444444444 + }, + { + "id": "o4-card", + "type": "rectangle", + "x": 1760, + "y": 1584, + "width": 260, + "height": 120, + "strokeColor": "#d6dae0", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "O4", + "lane2" + ], + "seed": 2615, + "boundElements": [ + { + "id": "ar-b4o3", + "type": "arrow" + } + ], + "index": "a15e", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "o4-t", + "type": "text", + "x": 1776, + "y": 1598, + "width": 228, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O4", + "lane2" + ], + "seed": 2616, + "text": "Cannot verify this purchase", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a15f", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Cannot verify this purchase", + "lineHeight": 1.2727272727272727 + }, + { + "id": "o4-b", + "type": "text", + "x": 1776, + "y": 1620, + "width": 228, + "height": 39, + "strokeColor": "#4b5563", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O4", + "lane2" + ], + "seed": 2617, + "text": "The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a15g", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.", + "lineHeight": 1.4444444444444444 + }, + { + "id": "o4-d", + "type": "rectangle", + "x": 1760, + "y": 1668, + "width": 260, + "height": 1, + "strokeColor": "#dfe4ea", + "backgroundColor": "#dfe4ea", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": null, + "groupIds": [ + "O4", + "lane2" + ], + "seed": 2618, + "index": "a15h", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "o4-o", + "type": "text", + "x": 1760, + "y": 1678, + "width": 260, + "height": 14, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O4", + "lane2" + ], + "seed": 2619, + "text": "Ok", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a15i", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Ok", + "lineHeight": 1.2727272727272727 + }, + { + "id": "o-note", + "type": "text", + "x": 1740, + "y": 1712, + "width": 300, + "height": 44, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O", + "lane2" + ], + "seed": 2620, + "text": "iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.", + "fontSize": 8.5, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a15j", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.", + "lineHeight": 1.7254901960784312 + }, + { + "id": "o-capt", + "type": "text", + "x": 1740, + "y": 1790, + "width": 300, + "height": 14, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O", + "lane2" + ], + "seed": 2621, + "text": "B5. What the sheet returns", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a15k", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "B5. What the sheet returns", + "lineHeight": 1.2727272727272727 + }, + { + "id": "o-capb", + "type": "text", + "x": 1740, + "y": 1810, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "O", + "lane2" + ], + "seed": 2622, + "text": "Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a15l", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.", + "lineHeight": 1.2222222222222223 + }, + { + "id": "ar-b1b2", + "type": "arrow", + "x": 368, + "y": 1460, + "width": 104, + "height": 0, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 2701, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-b1b2-t", + "type": "text" + } + ], + "index": "a180", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-b1b2-t", + "type": "text", + "x": 380, + "y": 1446, + "width": 80, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 2702, + "text": "Choose your badge", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-b1b2", + "index": "a181", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "Choose your badge", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-b2b3", + "type": "arrow", + "x": 788, + "y": 1460, + "width": 104, + "height": 0, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 2703, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-b2b3-t", + "type": "text" + } + ], + "index": "a182", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-b2b3-t", + "type": "text", + "x": 800, + "y": 1446, + "width": 80, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 2704, + "text": "Continue", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-b2b3", + "index": "a183", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "Continue", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-b3b4", + "type": "arrow", + "x": 1208, + "y": 1460, + "width": 104, + "height": 0, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 2705, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-b3b4-t", + "type": "text" + } + ], + "index": "a184", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-b3b4-t", + "type": "text", + "x": 1220, + "y": 1446, + "width": 80, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 2706, + "text": "Continue", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-b3b4", + "index": "a185", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "Continue", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-b4o1", + "type": "arrow", + "x": 1628, + "y": 1220, + "width": 104, + "height": 0, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 2709, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-b4o1-t", + "type": "text" + } + ], + "index": "a186", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-b4o1-t", + "type": "text", + "x": 1640, + "y": 1206, + "width": 80, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 2710, + "text": "verified", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-b4o1", + "index": "a187", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "verified", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-b4o2", + "type": "arrow", + "x": 1628, + "y": 1489, + "width": 104, + "height": 0, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "strokeWidth": 2, + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 2711, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-b4o2-t", + "type": "text" + } + ], + "index": "a188", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-b4o2-t", + "type": "text", + "x": 1640, + "y": 1475, + "width": 80, + "height": 13, + "strokeColor": "#5b21b6", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 2712, + "text": "pending", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-b4o2", + "index": "a189", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "pending", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ar-b4o3", + "type": "arrow", + "x": 1628, + "y": 1644, + "width": 124, + "height": 0, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 2713, + "points": [ + [ + 0, + 0 + ], + [ + 124, + 0 + ] + ], + "startArrowhead": null, + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-b4o3-t", + "type": "text" + } + ], + "index": "a18a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-b4o3-t", + "type": "text", + "x": 1640, + "y": 1630, + "width": 100, + "height": 13, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 2714, + "text": "unverified", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-b4o3", + "index": "a18b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "unverified", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "l3-band", + "type": "rectangle", + "x": 20, + "y": 1930, + "width": 2460, + "height": 850, + "strokeColor": "transparent", + "backgroundColor": "#fff6ef", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "lane3" + ], + "seed": 3010, + "index": "a200", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "l3-head", + "type": "text", + "x": 60, + "y": 1958, + "width": 900, + "height": 22, + "strokeColor": "#a15c1e", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "lane3" + ], + "seed": 3011, + "text": "Desktop - where the deep link may not exist", + "fontSize": 17, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a201", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Desktop - where the deep link may not exist", + "lineHeight": 1.2941176470588236 + }, + { + "id": "l3-sub", + "type": "text", + "x": 60, + "y": 1986, + "width": 1300, + "height": 14, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "lane3" + ], + "seed": 3012, + "text": "the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a202", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there", + "lineHeight": 1.2727272727272727 + }, + { + "id": "d1-panel", + "type": "rectangle", + "x": 60, + "y": 2040, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3101, + "boundElements": [ + { + "id": "ar-d1d2", + "type": "arrow" + } + ], + "index": "a210", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d1-back", + "type": "text", + "x": 76, + "y": 2058, + "width": 20, + "height": 14, + "strokeColor": "#a15c1e", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3102, + "text": "<", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a211", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "<", + "lineHeight": 1.2727272727272727 + }, + { + "id": "d1-title", + "type": "text", + "x": 80, + "y": 2086, + "width": 260, + "height": 24, + "strokeColor": "#a15c1e", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3103, + "text": "Redeem code", + "fontSize": 19, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a212", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Redeem code", + "lineHeight": 1.263157894736842 + }, + { + "id": "d1-body", + "type": "text", + "x": 80, + "y": 2128, + "width": 260, + "height": 13, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3104, + "text": "Paste the code you received.", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a213", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Paste the code you received.", + "lineHeight": 1.3 + }, + { + "id": "d1-field", + "type": "rectangle", + "x": 76, + "y": 2160, + "width": 268, + "height": 40, + "strokeColor": "transparent", + "backgroundColor": "#eceef1", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3105, + "index": "a214", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d1-fieldt", + "type": "text", + "x": 80, + "y": 2174, + "width": 260, + "height": 13, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3106, + "text": "SB-XXXXX-XXXXX-XXXXX-XXXXX", + "fontSize": 10, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a215", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "SB-XXXXX-XXXXX-XXXXX-XXXXX", + "lineHeight": 1.3 + }, + { + "id": "d1-paste", + "type": "text", + "x": 80, + "y": 2216, + "width": 260, + "height": 13, + "strokeColor": "#a15c1e", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3107, + "text": "Paste", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a216", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Paste", + "lineHeight": 1.3 + }, + { + "id": "d1-scan", + "type": "rectangle", + "x": 126, + "y": 2254, + "width": 168, + "height": 168, + "strokeColor": "#d6dae0", + "backgroundColor": "#f2f3f5", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3108, + "index": "a217", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d1-scant", + "type": "text", + "x": 126, + "y": 2332, + "width": 168, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3109, + "text": "QR scanner", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a218", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "QR scanner", + "lineHeight": 1.3333333333333333 + }, + { + "id": "d1-btn", + "type": "rectangle", + "x": 90, + "y": 2510, + "width": 240, + "height": 38, + "strokeColor": "transparent", + "backgroundColor": "#dcc9b4", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3110, + "boundElements": [ + { + "id": "d1-btnt", + "type": "text" + } + ], + "index": "a219", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d1-btnt", + "type": "text", + "x": 100, + "y": 2522, + "width": 220, + "height": 14, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3111, + "text": "Redeem", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "d1-btn", + "index": "a21a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "Redeem", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "d1-note", + "type": "text", + "x": 80, + "y": 2566, + "width": 260, + "height": 24, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3112, + "text": "opened by Buy in browser at the same\nmoment as the browser, and waiting", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a21b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "opened by Buy in browser at the same\nmoment as the browser, and waiting", + "lineHeight": 1.3333333333333333 + }, + { + "id": "d1-capt", + "type": "text", + "x": 60, + "y": 2680, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3113, + "text": "D1. Both halves at once", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a21c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "D1. Both halves at once", + "lineHeight": 1.2727272727272727 + }, + { + "id": "d1-capb", + "type": "text", + "x": 60, + "y": 2700, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D1", + "lane3" + ], + "seed": 3114, + "text": "On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a21d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.", + "lineHeight": 1.4666666666666666 + }, + { + "id": "d2-panel", + "type": "rectangle", + "x": 480, + "y": 2040, + "width": 300, + "height": 620, + "strokeColor": "#8f97a3", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3201, + "boundElements": [ + { + "id": "ar-d1d2", + "type": "arrow" + } + ], + "index": "a220", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d2-url", + "type": "rectangle", + "x": 480, + "y": 2040, + "width": 300, + "height": 26, + "strokeColor": "transparent", + "backgroundColor": "#eceef1", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3202, + "index": "a221", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d2-urlt", + "type": "text", + "x": 496, + "y": 2048, + "width": 268, + "height": 12, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3203, + "text": "simplex.chat/badges/#app=desktop", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a222", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "simplex.chat/badges/#app=desktop", + "lineHeight": 1.3333333333333333 + }, + { + "id": "d2-title", + "type": "text", + "x": 496, + "y": 2090, + "width": 268, + "height": 22, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3204, + "text": "Paid. Here is your code.", + "fontSize": 16, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a223", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Paid. Here is your code.", + "lineHeight": 1.375 + }, + { + "id": "d2-code", + "type": "rectangle", + "x": 496, + "y": 2128, + "width": 268, + "height": 36, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3205, + "index": "a224", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d2-codet", + "type": "text", + "x": 500, + "y": 2140, + "width": 260, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3206, + "text": "SB-4K2M9-XQ7VT-H3BNP-R8WLC", + "fontSize": 10, + "fontFamily": 3, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a225", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "SB-4K2M9-XQ7VT-H3BNP-R8WLC", + "lineHeight": 1.4 + }, + { + "id": "d2-btn", + "type": "rectangle", + "x": 496, + "y": 2180, + "width": 268, + "height": 34, + "strokeColor": "#0053d0", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3207, + "boundElements": [ + { + "id": "d2-btnt", + "type": "text" + } + ], + "index": "a226", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d2-btnt", + "type": "text", + "x": 506, + "y": 2190, + "width": 248, + "height": 14, + "strokeColor": "#0053d0", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3208, + "text": "Copy code", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "d2-btn", + "index": "a227", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "originalText": "Copy code", + "autoResize": true, + "lineHeight": 1.2727272727272727 + }, + { + "id": "d2-qr", + "type": "rectangle", + "x": 496, + "y": 2236, + "width": 110, + "height": 110, + "strokeColor": "#d6dae0", + "backgroundColor": "#f7f8fa", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": null, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3209, + "index": "a228", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d2-qrt", + "type": "text", + "x": 496, + "y": 2352, + "width": 110, + "height": 24, + "strokeColor": "#9aa1ab", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3210, + "text": "scan to carry it\nto your phone", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "top", + "autoResize": false, + "index": "a229", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "scan to carry it\nto your phone", + "lineHeight": 1.3333333333333333 + }, + { + "id": "d2-redeem", + "type": "text", + "x": 622, + "y": 2236, + "width": 142, + "height": 36, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3211, + "text": "REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.", + "fontSize": 8.5, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a22a", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.", + "lineHeight": 1.411764705882353 + }, + { + "id": "d2-warn", + "type": "rectangle", + "x": 622, + "y": 2288, + "width": 142, + "height": 58, + "strokeColor": "transparent", + "backgroundColor": "#fdf3e7", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3212, + "index": "a22b", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "d2-warnt", + "type": "text", + "x": 632, + "y": 2298, + "width": 122, + "height": 38, + "strokeColor": "#8a5a1e", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3213, + "text": "This is the only copy.\nSaved in this browser\nand nowhere else.", + "fontSize": 8.5, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a22c", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "This is the only copy.\nSaved in this browser\nand nowhere else.", + "lineHeight": 1.4901960784313726 + }, + { + "id": "d2-capt", + "type": "text", + "x": 480, + "y": 2680, + "width": 300, + "height": 14, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3214, + "text": "D2. The page, told which app it is", + "fontSize": 11, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a22d", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "D2. The page, told which app it is", + "lineHeight": 1.2727272727272727 + }, + { + "id": "d2-capb", + "type": "text", + "x": 480, + "y": 2700, + "width": 340, + "height": 66, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "D2", + "lane3" + ], + "seed": 3215, + "text": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so #app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets #app=true and the A5 screen instead.", + "fontSize": 9, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a22e", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so #app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets #app=true and the A5 screen instead.", + "lineHeight": 1.4666666666666666 + }, + { + "id": "q-box", + "type": "rectangle", + "x": 900, + "y": 2040, + "width": 1540, + "height": 260, + "strokeColor": "#e0a672", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "Q", + "lane3" + ], + "seed": 3301, + "index": "a230", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "q-head", + "type": "text", + "x": 924, + "y": 2064, + "width": 900, + "height": 20, + "strokeColor": "#a15c1e", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "Q", + "lane3" + ], + "seed": 3302, + "text": "QUESTIONS - deep links, and whether the desktop can have them at all", + "fontSize": 15, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a231", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "QUESTIONS - deep links, and whether the desktop can have them at all", + "lineHeight": 1.3333333333333333 + }, + { + "id": "q-body", + "type": "text", + "x": 924, + "y": 2098, + "width": 1490, + "height": 130, + "strokeColor": "#4b5563", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "Q", + "lane3" + ], + "seed": 3303, + "text": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? Safari and Chrome both refuse some scheme navigations that no user gesture asked for, and a refused one can show an error page rather\n than nothing. That is what Return to SimpleX is for, and why Show code sits under it - but it decides whether the automatic attempt is worth making at all.", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a232", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? Safari and Chrome both refuse some scheme navigations that no user gesture asked for, and a refused one can show an error page rather\n than nothing. That is what Return to SimpleX is for, and why Show code sits under it - but it decides whether the automatic attempt is worth making at all.", + "lineHeight": 1.4444444444444444 + }, + { + "id": "n-box", + "type": "rectangle", + "x": 900, + "y": 2340, + "width": 1540, + "height": 300, + "strokeColor": "#c9ced6", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "roughness": 0, + "roundness": { + "type": 3 + }, + "groupIds": [ + "N", + "lane3" + ], + "seed": 3401, + "index": "a233", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false + }, + { + "id": "n-head", + "type": "text", + "x": 924, + "y": 2364, + "width": 900, + "height": 20, + "strokeColor": "#1c1e26", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "N", + "lane3" + ], + "seed": 3402, + "text": "What has to be built, and what it costs", + "fontSize": 15, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a234", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "What has to be built, and what it costs", + "lineHeight": 1.3333333333333333 + }, + { + "id": "n-body", + "type": "text", + "x": 924, + "y": 2398, + "width": 1490, + "height": 220, + "strokeColor": "#4b5563", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "N", + "lane3" + ], + "seed": 3403, + "text": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read ?app and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it; and for #app=desktop,\nkeep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": false, + "index": "a235", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read ?app and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it; and for #app=desktop,\nkeep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", + "lineHeight": 2 + }, + { + "id": "ar-d1d2", + "type": "arrow", + "x": 368, + "y": 2350, + "width": 104, + "height": 0, + "strokeColor": "#a15c1e", + "backgroundColor": "transparent", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 0, + "roundness": null, + "elbowed": true, + "groupIds": [], + "seed": 3501, + "points": [ + [ + 0, + 0 + ], + [ + 104, + 0 + ] + ], + "startArrowhead": "arrow", + "endArrowhead": "arrow", + "startBinding": null, + "endBinding": null, + "boundElements": [ + { + "id": "ar-d1d2-t", + "type": "text" + } + ], + "index": "a250", + "version": 2, + "versionNonce": 1589412771, + "isDeleted": false, + "fillStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "updated": 1790251896821, + "link": null, + "locked": false, + "lastCommittedPoint": null + }, + { + "id": "ar-d1d2-t", + "type": "text", + "x": 370.23193359375, + "y": 2337, + "width": 99.5361328125, + "height": 26, + "strokeColor": "#a15c1e", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [], + "seed": 3502, + "text": "opened together, code\ncarried by hand", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "ar-d1d2", + "index": "a251", + "version": 4, + "versionNonce": 1533799245, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251895408, + "link": null, + "locked": false, + "originalText": "opened together, code carried by hand", + "autoResize": true, + "lineHeight": 1.3 + }, + { + "id": "ft-note", + "type": "text", + "x": 60, + "y": 2830, + "width": 2400, + "height": 26, + "strokeColor": "#6b7280", + "backgroundColor": "transparent", + "roughness": 0, + "groupIds": [ + "footer" + ], + "seed": 4005, + "text": "Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.", + "fontSize": 10, + "fontFamily": 2, + "textAlign": "left", + "verticalAlign": "top", + "autoResize": true, + "index": "a300", + "version": 1, + "versionNonce": 0, + "isDeleted": false, + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "opacity": 100, + "angle": 0, + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1790251001522, + "link": null, + "locked": false, + "containerId": null, + "originalText": "Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.", + "lineHeight": 1.3 + } + ], + "appState": { + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file From 0dc85d3311346d758170d7249fd36ead2f611154 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:23:28 +0000 Subject: [PATCH 02/84] badges: page ends an app purchase with a link instead of the code (#7589) --- apps/simplex-badge-service/web/README.md | 37 ++++-- apps/simplex-badge-service/web/mock/server.py | 12 +- .../web/public/index.html | 10 +- apps/simplex-badge-service/web/public/sw.js | 2 +- apps/simplex-badge-service/web/src/domain.ts | 6 + apps/simplex-badge-service/web/src/flow.ts | 6 +- apps/simplex-badge-service/web/src/main.ts | 57 +++++++-- apps/simplex-badge-service/web/src/order.ts | 1 + apps/simplex-badge-service/web/src/routing.ts | 20 ++- apps/simplex-badge-service/web/src/screens.ts | 76 ++++++++--- apps/simplex-badge-service/web/src/store.ts | 9 +- .../web/test/main-app-desktop.test.ts | 50 ++++++++ .../web/test/main-app-mobile.test.ts | 119 ++++++++++++++++++ .../web/test/main-app-reload.test.ts | 31 +++++ .../web/test/main-app-return.test.ts | 38 ++++++ .../web/test/main-app-unsaved.test.ts | 49 ++++++++ .../web/test/main-card-full-store.test.ts | 2 +- .../web/test/main-card-return.test.ts | 3 + .../web/test/routing.test.ts | 19 ++- .../web/test/screens.test.ts | 60 +++++++++ .../web/test/store.test.ts | 21 ++++ plans/2026-09-24-badge-buy-in-browser.md | 2 +- ...026-09-23-badge-in-app-checkout.excalidraw | 40 +++--- 23 files changed, 594 insertions(+), 76 deletions(-) create mode 100644 apps/simplex-badge-service/web/test/main-app-desktop.test.ts create mode 100644 apps/simplex-badge-service/web/test/main-app-mobile.test.ts create mode 100644 apps/simplex-badge-service/web/test/main-app-reload.test.ts create mode 100644 apps/simplex-badge-service/web/test/main-app-return.test.ts create mode 100644 apps/simplex-badge-service/web/test/main-app-unsaved.test.ts diff --git a/apps/simplex-badge-service/web/README.md b/apps/simplex-badge-service/web/README.md index 0004b44944..ae2f5b37e5 100644 --- a/apps/simplex-badge-service/web/README.md +++ b/apps/simplex-badge-service/web/README.md @@ -56,10 +56,10 @@ is plain TypeScript, tested directly in Node. | `format.ts` | Money, countdowns and elapsed time as the words a screen prints | | `store.ts` | The three `localStorage` keys — session, orders and the chosen theme | | `order.ts` | What an order's state means: what to keep from a response, which screen it selects, and its history row | -| `routing.ts` | Reading `?order=` and the store into what to render | +| `routing.ts` | Reading `?order=`, the step hash with its `?app=` flag, and the store into what to render; the `simplexchat:` link that hands a code to the app | | `api.ts` | The three routes — create, cancel and read — and the long-poll wait loop, with `fetch`, sleep and the clock injected | | `flow.ts` | Payment flow control logic — pure, no DOM, no globals | -| `stripe.ts` | Loading Stripe.js, mounting the Payment Element, confirming, and the no-key stand-in | +| `stripe.ts` | Loading Stripe.js, mounting the Payment Element, confirming, and refusing the card path with no key | | `icons.ts` | The badge art, the payment marks and the hamburger, built with `createElementNS`. The brand mark is NOT here: it is a served file | | `qr.ts` | A QR encoder written for this page, with no dependency, no network and no raster | | `screens.ts` | Every screen of the spec, and the header and its menu, built node by node — markup is never assigned from a string | @@ -183,6 +183,29 @@ which is what the page's own wait loop relies on — no polling on a timer. Card invoices (`"method":"card"`) get a `clientSecret` in the response instead of an address; BTC and XMR get `address` and `cryptoAmount`. +### Opening the page as the app does + +The SimpleX app opens the page at a step hash carrying a flag, which decides +only how the code is handed over at the end. Both endings can be driven here: + +``` +http://localhost:8099/#/tier?app=true # Return to SimpleX, with Show code under it +http://localhost:8099/#/tier?app=desktop # the code screen, saying the Redeem code screen is already open +``` + +With no flag the page ends as it always has. + +**Open each in a fresh private window.** The flag is read once, at load, and +kept in the session; nothing re-reads it when the hash changes, so editing the +address bar of a page that is already open does nothing. A session left over +from an earlier purchase in the same browser has already spent its flag at +checkout, and the ending falls back to the plain code screen. + +Pay with Monero or Bitcoin and settle it with `/control/settle` above — the +card path needs a Stripe key. A code the browser could not store also falls +back to the plain screen, so a browser that blocks `localStorage` never shows +the `app=true` ending at all. + ### The Stripe key The publishable key lives in a `` element @@ -191,12 +214,10 @@ in `public/index.html`, committed empty. `mock/server.py` substitutes set to anything but a `pk_`-prefixed key (a secret or restricted key would otherwise be baked into a page anyone can read). -With no key set, the card path renders a labelled development stand-in -instead of a Stripe Payment Element: its button does what a successful -confirm does, and settling it calls the mock's `/control/settle` directly. -**This stand-in cannot appear when a key is set** — the code path that -builds it is unreachable once Stripe.js has actually loaded. To see the real -Payment Element, set a test key: +With no key set there is no card path at all: `cardPlan` answers +`unconfigured`, `renderCardForm` returns before it builds anything, and +choosing Card reaches "The card form did not load". Drive a purchase with +Monero or Bitcoin instead, or set a test key: ``` STRIPE_PUBLISHABLE_KEY=pk_test_... python3 mock/server.py --port 8099 diff --git a/apps/simplex-badge-service/web/mock/server.py b/apps/simplex-badge-service/web/mock/server.py index af7612a084..009aea31d4 100644 --- a/apps/simplex-badge-service/web/mock/server.py +++ b/apps/simplex-badge-service/web/mock/server.py @@ -9,9 +9,9 @@ Environment: STRIPE_PUBLISHABLE_KEY substituted into the served index.html. Public by design, but still not committed: unset, the page has NO card form and - renders the development stand-in instead, whose - button does what a successful confirm does and whose - settling is POST /control/settle/ below. + choosing Card reaches "The card form did not load", + so drive a purchase with btc or xmr and settle it + with POST /control/settle/ below. Set it to a `pk_test_...` key to drive the real Stripe path; a secret key here is refused at start. """ @@ -111,8 +111,8 @@ def public_view(inv): def with_publishable_key(html): """The publishable key is compiled into the page. Here it comes from the environment, so nothing that could be a real key is ever written - back into public/index.html. Unset leaves the committed empty value, which - is what selects the development stand-in.""" + back into public/index.html. Unset leaves the committed empty value, with + which the page offers no card form at all.""" if not STRIPE_PUBLISHABLE_KEY: return html text, found = KEY_META.subn(lambda m: m.group(1) + STRIPE_PUBLISHABLE_KEY + m.group(2), html.decode()) @@ -314,7 +314,7 @@ def main(): print(f"mock badge service on http://localhost:{port}", flush=True) print("stripe: " + (f"publishable key {STRIPE_PUBLISHABLE_KEY[:11]}… — the real card form" if STRIPE_PUBLISHABLE_KEY - else "no STRIPE_PUBLISHABLE_KEY — the card path renders the development stand-in"), + else "no STRIPE_PUBLISHABLE_KEY — no card form; pay with btc or xmr"), flush=True) server.serve_forever() diff --git a/apps/simplex-badge-service/web/public/index.html b/apps/simplex-badge-service/web/public/index.html index dcf5e7d4b2..6caac72b84 100644 --- a/apps/simplex-badge-service/web/public/index.html +++ b/apps/simplex-badge-service/web/public/index.html @@ -20,17 +20,17 @@ Support SimpleX - - + + - + - + - + diff --git a/apps/simplex-badge-service/web/public/sw.js b/apps/simplex-badge-service/web/public/sw.js index 8faa9d0c1f..c0c46f2942 100644 --- a/apps/simplex-badge-service/web/public/sw.js +++ b/apps/simplex-badge-service/web/public/sw.js @@ -2,7 +2,7 @@ // runs this exact file in a Node `vm` with a fake Cache API and reads `self.sw`. /** Rewritten by `build.js` from the bytes of the compiled modules, the stylesheet and the images. */ -const BUILD = "9539a668abb6a8c8"; +const BUILD = "55cd5080cd9b28a4"; const ASSETS = `/assets/${BUILD}/`; /** One cache per build, so eviction is "everything that is not this one". */ const CACHE = `sb-${BUILD}`; diff --git a/apps/simplex-badge-service/web/src/domain.ts b/apps/simplex-badge-service/web/src/domain.ts index 4bae3d13b9..3baba3656f 100644 --- a/apps/simplex-badge-service/web/src/domain.ts +++ b/apps/simplex-badge-service/web/src/domain.ts @@ -5,18 +5,22 @@ export const THEMES = ["light", "dark", "system"] as const; const ORDER_STATUSES = ["open", "paid", "expired"] as const; const METHODS = ["card", "btc", "xmr"] as const; const CHAINS = ["btc", "xmr"] as const; +const APPS = ["mobile", "desktop"] as const; export type Step = (typeof STEPS)[number]; export type Theme = (typeof THEMES)[number]; export type OrderStatus = (typeof ORDER_STATUSES)[number]; export type Method = (typeof METHODS)[number]; export type Chain = (typeof CHAINS)[number]; +/** The SimpleX app that opened the page, which decides how the code is handed over at the end. */ +export type App = (typeof APPS)[number]; export const readStep = oneOf(STEPS); export const readTheme = oneOf(THEMES); export const readStatus = oneOf(ORDER_STATUSES); export const readMethod = oneOf(METHODS); export const readChain = oneOf(CHAINS); +export const readApp = oneOf(APPS); /** The optional fields are written `| undefined` so a patch can set one back to undefined to clear it. */ export interface SessionRecord { @@ -24,6 +28,7 @@ export interface SessionRecord { priceId?: string | undefined; offerId?: string | undefined; method?: Method | undefined; + app?: App | undefined; } export interface OrderIdentity { @@ -39,6 +44,7 @@ export interface OrderLocalState { submitted: boolean | undefined; canceled: boolean | undefined; method: Method | undefined; + app: App | undefined; } /** Every field is a required key even when undefined, so a withdrawn field cannot survive in the stored record. */ diff --git a/apps/simplex-badge-service/web/src/flow.ts b/apps/simplex-badge-service/web/src/flow.ts index 13da0949d0..af675eac04 100644 --- a/apps/simplex-badge-service/web/src/flow.ts +++ b/apps/simplex-badge-service/web/src/flow.ts @@ -9,7 +9,7 @@ import { applyView, closedInvoice, offlineInvoice, recordFromView, serverState, viewFor, withoutCode, type PaymentView, } from "./order.js"; -import type { Method, OrderRecord, OrderStatus } from "./domain.js"; +import type { App, Method, OrderRecord, OrderStatus } from "./domain.js"; import type { Store } from "./store.js"; export const GIVE_UP_MS = 15 * 60 * 1000; @@ -285,7 +285,7 @@ export class Flow { constructor(private readonly d: FlowDeps) {} - async checkout(sel: Selection): Promise { + async checkout(sel: Selection, app?: App): Promise { for (let attempt = 0; attempt < CODE_ATTEMPTS; attempt++) { const wipes = this.d.store.wipeCount; const code = this.d.newCode(); @@ -316,7 +316,7 @@ export class Flow { months: created.months, createdAt: new Date(this.d.now()).toISOString(), }, - { code: display(code), submitted: undefined, canceled: undefined, method: sel.method }, + { code: display(code), submitted: undefined, canceled: undefined, method: sel.method, app }, serverState({ ...created, status: "open" }, undefined), ); // The buyer can empty this browser while the invoice is bought, so the order is saved only diff --git a/apps/simplex-badge-service/web/src/main.ts b/apps/simplex-badge-service/web/src/main.ts index 310fa93220..3bb4917ddc 100644 --- a/apps/simplex-badge-service/web/src/main.ts +++ b/apps/simplex-badge-service/web/src/main.ts @@ -5,12 +5,12 @@ import { Flow, type CheckoutOutcome, type Selection } from "./flow.js"; import { applyView, historyRows, selectionFromOrder, type PaymentView, withoutDestination } from "./order.js"; import { money, moneyCompact } from "./format.js"; import * as api from "./api.js"; -import { resolveLoad } from "./routing.js"; +import { appCodeLink, readHash, resolveLoad } from "./routing.js"; import * as screens from "./screens.js"; import { appearanceFor, cardPlan, loadStripeJs, mountCard, publishableKey, type CardFailure, type ConfirmOutcome } from "./stripe.js"; import { Store, type StorageLike } from "./store.js"; import { STEPS } from "./domain.js"; -import type { Method, OrderRecord, SessionRecord, Step, Theme } from "./domain.js"; +import type { App, Method, OrderRecord, SessionRecord, Step, Theme } from "./domain.js"; import { EMBED_READY, HEIGHT_MESSAGE, NAV_MESSAGE, THEME_MESSAGE, bgFromMessage, isNewPurchaseMessage, returnUrlFromMessage, routeFromMessage, themeFromMessage, trustedHost } from "./embed.js"; const app = document.getElementById("app"); @@ -268,7 +268,8 @@ const panels: HTMLElement[] = []; let index = 0; function landingIndex(): number { - const fromHash = STEPS.findIndex((s) => HASHES[s] === location.hash); + const { route } = readHash(location.hash); + const fromHash = STEPS.findIndex((s) => HASHES[s] === route); return fromHash >= 0 ? fromHash + 1 : 0; } @@ -486,7 +487,7 @@ async function pay(): Promise { const since = flow.epoch; let outcome: CheckoutOutcome; try { - outcome = await flow.checkout(sel); + outcome = await flow.checkout(sel, session.app); } finally { checkoutInFlight = false; } @@ -507,7 +508,7 @@ async function pay(): Promise { } case "catalogChanged": root.replaceChildren(screens.catalogChanged(() => { - newInvoice(); + newInvoice(session.app); })); return; case "rateLimited": { @@ -606,7 +607,7 @@ async function cancelInvoice(orderId: string): Promise { return; } store.markCanceled(orderId); - newInvoice(); + newInvoice(store.order(orderId)?.app); } // The Cancel button's disabled attribute does not survive a repaint, so this flag allows one cancel at a time. @@ -631,8 +632,10 @@ function resetToLanding(nav: "push" | "replace"): void { announceLocation(); } -function newInvoice(): void { +// A purchase started over keeps the app that opened the page, or its order would end by showing the code. +function newInvoice(openedBy: App | undefined): void { store.clearSession(); + if (openedBy !== undefined) store.saveSession({ app: openedBy }); resetToLanding("replace"); } @@ -664,10 +667,10 @@ function paint(view: PaymentView): void { syncChrome(); switch (view.screen) { case "unknownOrder": - root.replaceChildren(screens.unknownOrder(newInvoice)); + root.replaceChildren(screens.unknownOrder(() => { newInvoice(store.session().app); })); return; case "codeIssued": - root.replaceChildren(screens.codeIssued({ code: view.code, savedLocally: view.savedLocally })); + root.replaceChildren(codeScreen(view)); return; case "paidNoCode": root.replaceChildren(screens.paidNoCode({ order: view.order, settledAt: view.invoice?.settledAt })); @@ -695,7 +698,7 @@ function paint(view: PaymentView): void { root.replaceChildren(screens.windowClosed({ order: view.order, invoice: view.invoice, offline: isOffline(), canceled: store.order(view.order.orderId)?.canceled === true, - onNewInvoice: newInvoice, + onNewInvoice: () => { newInvoice(view.order.app); }, })); return; case "cardForm": @@ -704,12 +707,38 @@ function paint(view: PaymentView): void { case "detailsUnavailable": root.replaceChildren(screens.detailsUnavailable({ order: view.order, - onCheckAgain: () => checkAgain(view.order.orderId), onNewInvoice: newInvoice, + onCheckAgain: () => checkAgain(view.order.orderId), onNewInvoice: () => { newInvoice(view.order.app); }, })); return; } } +type CodeView = Extract; + +const codeRevealed = new Set(); +const appLaunched = new Set(); + +function codeScreen(view: CodeView): HTMLElement { + const { orderId } = view.order; + // An unsaved code exists only on this screen, so it is never held back behind a link that may fail. + if (view.order.app !== "mobile" || !view.savedLocally) { + return screens.codeIssued({ code: view.code, savedLocally: view.savedLocally, app: view.order.app }); + } + const link = appCodeLink(view.code); + const ending = screens.returnToApp({ + onReturn: () => { location.href = link; }, + code: codeRevealed.has(orderId) + ? { kind: "shown", code: view.code, savedLocally: view.savedLocally } + : { kind: "hidden", onShow: () => { codeRevealed.add(orderId); paint(view); } }, + }); + // A connectivity event repaints this screen, and each repaint must not fire the scheme again. + if (!appLaunched.has(orderId)) { + appLaunched.add(orderId); + ending.append(screens.appLauncher(link)); + } + return ending; +} + let cardElementDestroy: (() => void) | null = null; let cardConfirmPending = false; @@ -783,7 +812,7 @@ function cardFailureScreen(view: CardView, reason: CardFailure): HTMLElement { return screens.cardUnavailable({ order: view.order, reason, onRetry: () => { renderCardForm(view); }, - onNewInvoice: newInvoice, + onNewInvoice: () => { newInvoice(view.order.app); }, }); } @@ -925,6 +954,10 @@ function registerServiceWorker(): void { // The shell prerenders the welcome page, so the document is that page until the first render, and a load resolving to a codes or order screen never mounts the wizard for measureWelcome to read. if (embedded && welcomeHeight <= 0) welcomeHeight = Math.ceil(document.documentElement.getBoundingClientRect().height); +// Read before the first sync rewrites the hash, which drops the flag from the URL. +const entryApp = readHash(location.hash).app; +if (entryApp !== undefined) store.saveSession({ app: entryApp }); + syncFromLocation(true); document.documentElement.classList.remove("sb-booting"); registerServiceWorker(); diff --git a/apps/simplex-badge-service/web/src/order.ts b/apps/simplex-badge-service/web/src/order.ts index 0370c55135..6bb40aa1ba 100644 --- a/apps/simplex-badge-service/web/src/order.ts +++ b/apps/simplex-badge-service/web/src/order.ts @@ -138,6 +138,7 @@ function localState(base: OrderRecord, memory: OrderRecord | undefined, method: submitted: memory?.submitted === true || base.submitted === true ? true : undefined, canceled: memory?.canceled === true || base.canceled === true ? true : undefined, method: method ?? base.method, + app: memory?.app ?? base.app, }; } diff --git a/apps/simplex-badge-service/web/src/routing.ts b/apps/simplex-badge-service/web/src/routing.ts index f5b50cbcb2..8557e3e9c6 100644 --- a/apps/simplex-badge-service/web/src/routing.ts +++ b/apps/simplex-badge-service/web/src/routing.ts @@ -1,4 +1,4 @@ -import type { Method, OrderRecord } from "./domain.js"; +import type { App, Method, OrderRecord } from "./domain.js"; export type Screen = "awaitingPayment" | "awaitingConfirmation" | "cardForm"; @@ -6,6 +6,24 @@ export type Load = { kind: "order"; orderId: string } | { kind: "step" }; export interface UrlParts { search: string } +export interface HashParts { route: string; app: App | undefined } + +// The flag rides in the fragment, which is never sent to the service nor carried in a Referer. +export function readHash(hash: string): HashParts { + const at = hash.indexOf("?"); + if (at < 0) return { route: hash, app: undefined }; + const flag = new URLSearchParams(hash.slice(at + 1)).get("app"); + return { + route: hash.slice(0, at), + app: flag === "true" ? "mobile" : flag === "desktop" ? "desktop" : undefined, + }; +} + +// A bearer token: whoever reads this URL can redeem the badge. +export function appCodeLink(code: string): string { + return `simplexchat:/badge/code/${encodeURIComponent(code)}`; +} + export function resolveLoad(url: UrlParts, newestOpen: OrderRecord | undefined): Load { const orderId = new URLSearchParams(url.search).get("order"); if (orderId) return { kind: "order", orderId }; diff --git a/apps/simplex-badge-service/web/src/screens.ts b/apps/simplex-badge-service/web/src/screens.ts index 04cb54f756..84e2135a00 100644 --- a/apps/simplex-badge-service/web/src/screens.ts +++ b/apps/simplex-badge-service/web/src/screens.ts @@ -3,7 +3,7 @@ import { badgeIcon, chevronLeft, hamburger, hasBadgeArt, methodMark, wefunderMar import { paymentUri, qrSvg } from "./qr.js"; import type { HistoryRow, UnpaidOrder } from "./order.js"; import type { InvoiceView } from "./api.js"; -import { THEMES, type Method, type Theme } from "./domain.js"; +import { THEMES, type App, type Method, type Theme } from "./domain.js"; import type { CardFailure } from "./stripe.js"; import { CROWDFUNDING_ACTIVE, WEFUNDER_URL, dollars } from "./crowdfunding.js"; @@ -785,9 +785,18 @@ export function windowClosed(o: WindowClosedOptions): HTMLElement { export interface CodeIssuedOptions { code: string; savedLocally: boolean; + app?: App | undefined; } -export function codeIssued(o: CodeIssuedOptions): HTMLElement { +export const REDEEM_IN_SETTINGS = "Settings → Supporter perks → Redeem code"; +export const REDEEM_SCREEN_OPEN = "The Redeem code screen is already open."; + +function codeWithCopy(code: string): HTMLElement[] { + const copy = copyControl("Copy code", code, "primary outline"); + return [el("div", { class: "code" }, code), copy.control, copy.status]; +} + +function codeDetails(o: CodeIssuedOptions): HTMLElement { const onlyCopy = o.savedLocally ? el("div", { class: "warn" }, el("span", { class: "title" }, "This is the only copy."), @@ -796,29 +805,68 @@ export function codeIssued(o: CodeIssuedOptions): HTMLElement { : el("div", { class: "warn" }, el("span", { class: "title" }, "This code could not be saved in this browser."), el("p", {}, "Copy it now. It is shown here and nowhere else.")); - const copy = copyControl("Copy code", o.code, "primary outline"); - const p = panel( - el("div", { class: "tick" }, "✓"), - el("h1", { class: "tight center" }, "Paid. Here is your code."), - el("div", { class: "code" }, o.code), - copy.control, - copy.status, - ); - const qr = qrFigure(o.code, "Badge code as a scannable code", "scan to carry it to your phone"); - const details = el("div", { class: "details" }, + return el("div", { class: "details" }, el("div", { class: "rows plain" }, el("span", { class: "label" }, "Redeem it in the app"), - el("div", {}, "Settings → Supporter perks → Redeem code"), + el("div", {}, o.app === "desktop" ? REDEEM_SCREEN_OPEN : REDEEM_IN_SETTINGS), ), onlyCopy, ); +} + +export function codeIssued(o: CodeIssuedOptions): HTMLElement { + const p = panel( + el("div", { class: "tick" }, "✓"), + el("h1", { class: "tight center" }, "Paid. Here is your code."), + ...codeWithCopy(o.code), + ); + const qr = qrFigure(o.code, "Badge code as a scannable code", "scan to carry it to your phone"); const split = el("div", { class: "split" }); if (qr !== null) split.append(qr); - split.append(details); + split.append(codeDetails(o)); p.append(split); return p; } +export const RETURN_TO_APP = "Return to SimpleX"; +export const SHOW_CODE = "Show code"; + +export type AppCode = + | { kind: "hidden"; onShow: () => void } + | { kind: "shown"; code: string; savedLocally: boolean }; + +export interface ReturnToAppOptions { + onReturn: () => void; + code: AppCode; +} + +// The buyer is on the phone the code is for, so the revealed code comes without the QR that would carry it there. +export function returnToApp(o: ReturnToAppOptions): HTMLElement { + const p = panel( + el("div", { class: "tick" }, "✓"), + el("h1", { class: "tight" }, "Paid"), + el("p", { class: "lede" }, + el("span", { class: "line" }, "Opening SimpleX to add your badge."), " ", + el("span", { class: "line" }, "If nothing happens, use the button."), + ), + button(RETURN_TO_APP, o.onReturn), + ); + switch (o.code.kind) { + case "hidden": + p.append(el("p", { class: "row-line center" }, button(SHOW_CODE, o.code.onShow, "link"))); + return p; + case "shown": + p.append(...codeWithCopy(o.code.code), el("div", { class: "split" }, codeDetails(o.code))); + return p; + } +} + +// A refused or unhandled scheme fails inside this frame, so no error page replaces the screen and no entry +// joins the history; the frame's first navigation replaces its about:blank. +export function appLauncher(link: string): HTMLIFrameElement { + return el("iframe", { src: link, hidden: "", "aria-hidden": "true", tabindex: "-1" }); +} + const MONTHS: readonly string[] = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", diff --git a/apps/simplex-badge-service/web/src/store.ts b/apps/simplex-badge-service/web/src/store.ts index 767b63e780..339334c77a 100644 --- a/apps/simplex-badge-service/web/src/store.ts +++ b/apps/simplex-badge-service/web/src/store.ts @@ -1,5 +1,5 @@ import { - composeOrder, readChain, readMethod, readStatus, readStep, readTheme, + composeOrder, readApp, readChain, readMethod, readStatus, readStep, readTheme, type OrderRecord, type SessionRecord, type Theme, } from "./domain.js"; import { asObject, filledText, flag, positiveInteger, text, nonNegativeInteger } from "./parse.js"; @@ -36,7 +36,7 @@ function readOrder(value: unknown): OrderRecord | undefined { badgeType: text(o.badgeType) ?? "", months: positiveInteger(o.months) ?? 0, }, - { code: text(o.code), submitted: flag(o.submitted), canceled: flag(o.canceled), method: readMethod(o.method) }, + { code: text(o.code), submitted: flag(o.submitted), canceled: flag(o.canceled), method: readMethod(o.method), app: readApp(o.app) }, { status, amount: positiveInteger(o.amount), @@ -61,9 +61,11 @@ function readSession(value: unknown): SessionRecord | undefined { const priceId = text(s.priceId); const offerId = text(s.offerId); const method = readMethod(s.method); + const app = readApp(s.app); if (priceId !== undefined) session.priceId = priceId; if (offerId !== undefined) session.offerId = offerId; if (method !== undefined) session.method = method; + if (app !== undefined) session.app = app; return session; } @@ -119,7 +121,7 @@ export class Store { return list.flatMap((o) => readOrder(o) ?? []); } - // The record replaces the stored entry, but the browser-only fields (code, submitted, canceled) are preserved because the service never sends them back. + // The record replaces the stored entry, but the browser-only fields (code, submitted, canceled, app) are preserved because the service never sends them back. saveOrder(record: OrderRecord): boolean { const list = this.orders(); const at = list.findIndex((o) => o.orderId === record.orderId); @@ -130,6 +132,7 @@ export class Store { ...(record.code === undefined && kept.code !== undefined ? { code: kept.code } : {}), ...(kept.submitted === true ? { submitted: true } : {}), ...(kept.canceled === true ? { canceled: true } : {}), + ...(record.app === undefined && kept.app !== undefined ? { app: kept.app } : {}), }; } else { if (list.length >= CAP) { diff --git a/apps/simplex-badge-service/web/test/main-app-desktop.test.ts b/apps/simplex-badge-service/web/test/main-app-desktop.test.ts new file mode 100644 index 0000000000..4c6d956cb0 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-desktop.test.ts @@ -0,0 +1,50 @@ +// Opened by the desktop app, which has no scheme handler: the code is shown as ever, and the app has Redeem code open beside it. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import type { StubElement } from "./stub-dom.js"; + +const appTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ url: "/#/tier?app=desktop" }); +const { app, history, storage, fetches } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); + +appTest("main: #/tier?app=desktop lands on Choose your badge", () => { + assert.equal(headingOf(inView()), "Choose your badge"); + assert.equal(history.url, "/#/tier"); +}); + +appTest("main: paid from the desktop app, the code screen says Redeem code is already open", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("1 month"))!.click(); + primaryOf(inView())!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_desk", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "0.02", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 0.02 XMR", `the payment screen, not ${heading()}`); + for (const f of fetches) { + const sent = `${f.url} ${String(f.init?.body ?? "")}`; + assert.ok(!/app=|"app"|desktop/.test(sent), `no request names the app: ${sent}`); + } + + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_desk"); + await until(() => heading() === "Paid. Here is your code.", `the code screen, not ${heading()}`); + + const code = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<{ code: string }>)[0]!.code; + assert.equal(screenOf(app).all("div.code")[0]!.textContent, code, "the code, shown as it always was"); + assert.ok(screenOf(app).textContent.includes("The Redeem code screen is already open.")); + assert.ok(!screenOf(app).textContent.includes("Settings → Supporter perks"), "the one changed line"); + assert.equal(screenOf(app).all("iframe").length, 0, "desktop has no scheme to try"); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-mobile.test.ts b/apps/simplex-badge-service/web/test/main-app-mobile.test.ts new file mode 100644 index 0000000000..40c5682a48 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-mobile.test.ts @@ -0,0 +1,119 @@ +// Opened by the mobile app: the code leaves by the simplexchat: link, and the page shows it only when asked. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, settle, timedTest, until } from "./boot.js"; +import type { StubElement } from "./stub-dom.js"; + +const appTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ url: "/#/tier?app=true" }); +const { app, history, location, storage, fetches } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); +const stored = (): Record => + (JSON.parse(storage.getItem("sb.orders.v1") ?? "[]") as Array>)[0] ?? {}; + +appTest("main: #/tier?app=true lands on Choose your badge, with the flag in the session and off the URL", () => { + assert.equal(headingOf(inView()), "Choose your badge"); + assert.equal(history.url, "/#/tier", "the tail is dropped once read"); + assert.deepEqual(history.stack, ["/", "/#/tier"], "and Back still reaches the landing"); + assert.equal((JSON.parse(storage.getItem("sb.session.v1")!) as { app?: unknown }).app, "mobile"); +}); + +appTest("main: the flag survives the steps and is stamped on the order, and no request carries it", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!.click(); + primaryOf(inView())!.click(); + assert.equal(headingOf(inView()), "Check your order"); + + page.respondWith({ status: 200, body: { + invoiceId: "inv_mob", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "1.482", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 1.482 XMR", `the payment screen, not ${heading()}`); + + assert.equal(location.search, "?order=inv_mob"); + assert.equal(stored().app, "mobile", "the order carries the flag across the ?order= round trip"); + assert.equal(storage.getItem("sb.session.v1"), null, "the session that carried it here is done"); + const created = fetches.find((f) => f.url === "/api/invoice")!; + assert.deepEqual(Object.keys(JSON.parse(String(created.init!.body)) as object).sort(), + ["codeHash", "method", "offerId", "priceId"], "the service is told nothing about the app"); + for (const f of fetches) { + const sent = `${f.url} ${String(f.init?.body ?? "")}`; + assert.ok(!/app=|"app"|mobile/.test(sent), `no request names the app: ${sent}`); + } +}); + +appTest("main: once paid, the ending tries the link once and holds the code back", async () => { + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_mob"); + await until(() => heading() === "Paid", `the return-to-app ending, not ${heading()}`); + + const code = stored().code as string; + const link = `simplexchat:/badge/code/${code}`; + const frames = screenOf(app).all("iframe"); + assert.equal(frames.length, 1, "one automatic attempt"); + assert.equal(frames[0]!.getAttribute("src"), link); + assert.ok(frames[0]!.hasAttribute("hidden"), "a refusal stays inside a frame nobody sees"); + assert.equal(screenOf(app).serialize().split(code).length - 1, 1, "the frame's src is the only place the code appears"); + assert.ok(!screenOf(app).textContent.includes(code), "and none of it is text on the screen"); +}); + +appTest("main: a repaint keeps the ending but does not fire the link again", async () => { + page.setOffline(true); + await settle(); + page.setOffline(false); + await settle(); + assert.equal(heading(), "Paid"); + assert.equal(screenOf(app).all("iframe").length, 0); +}); + +appTest("main: with the automatic attempt refused, Return to SimpleX still navigates, off the page's history", () => { + const before = [...history.stack]; + screenOf(app).all("button.primary").find((b) => b.textContent === "Return to SimpleX")!.click(); + assert.equal((location as { href?: string }).href, `simplexchat:/badge/code/${stored().code as string}`); + assert.deepEqual(history.stack, before, "the page adds no history entry of its own"); + assert.equal(heading(), "Paid", "and the screen is still there to fall back on"); +}); + +appTest("main: Show code reveals the stored code in place, keeps Return to SimpleX, and it stays revealed", async () => { + screenOf(app).all("button.link").find((b) => b.textContent === "Show code")!.click(); + const code = stored().code as string; + const revealed = (): void => { + assert.equal(heading(), "Paid", "still the ending, not the plain code screen"); + assert.equal(screenOf(app).all("div.code")[0]!.textContent, code); + assert.equal(screenOf(app).all("svg").filter((s) => s.getAttribute("class") === "qr").length, 0, "no QR"); + assert.ok(screenOf(app).all("button.primary").some((b) => b.textContent === "Return to SimpleX")); + }; + revealed(); + assert.equal(screenOf(app).all("iframe").length, 0, "revealing does not fire the link again"); + + page.clipboard.writes.length = 0; + screenOf(app).all("button.primary").find((b) => b.textContent === "Copy code")!.click(); + await settle(); + assert.deepEqual(page.clipboard.writes, [code]); + + page.setOffline(true); + await settle(); + page.setOffline(false); + await settle(); + revealed(); + + screenOf(app).all("button.primary").find((b) => b.textContent === "Return to SimpleX")!.click(); + assert.equal((location as { href?: string }).href, `simplexchat:/badge/code/${code}`, "the button still works once the code is out"); +}); + +appTest("main: nothing the page wrote to history or storage outside the order holds the link", () => { + for (const url of history.stack) assert.ok(!url.includes("simplexchat") && !url.includes("SB-"), url); + for (const [key, value] of storage.m) { + if (key !== "sb.orders.v1") assert.ok(!value.includes("SB-") && !value.includes("simplexchat"), key); + } +}); diff --git a/apps/simplex-badge-service/web/test/main-app-reload.test.ts b/apps/simplex-badge-service/web/test/main-app-reload.test.ts new file mode 100644 index 0000000000..f372dd7ec8 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-reload.test.ts @@ -0,0 +1,31 @@ +// A reload mid-purchase: the flag is off the URL by now, so only the session can still carry it to the order. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage } from "./stub-dom.js"; + +const reloadTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +const storage = new MemStorage(); +storage.setItem("sb.session.v1", JSON.stringify({ step: "months", priceId: "price_legend", offerId: "offer_12m", app: "mobile" })); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage, url: "/#/months" }); +const { app } = page; +await import("../src/main.js"); + +reloadTest("main: after a reload the session still carries the flag, and the order it makes is stamped", async () => { + assert.equal(headingOf(inViewOf(app)), "How long?"); + assert.equal((JSON.parse(storage.getItem("sb.session.v1")!) as { app?: unknown }).app, "mobile"); + + primaryOf(inViewOf(app))!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_reload", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "1.482", cryptoCurrency: "xmr", + } }); + inViewOf(app).all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => headingOf(screenOf(app)) === "Send 1.482 XMR", "the payment screen"); + const order = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<{ app?: string }>)[0]!; + assert.equal(order.app, "mobile"); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-return.test.ts b/apps/simplex-badge-service/web/test/main-app-return.test.ts new file mode 100644 index 0000000000..cfb53206b4 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-return.test.ts @@ -0,0 +1,38 @@ +// A mobile-app order comes back from Stripe with neither the query nor the fragment, so the ending is read off the order alone. +// This is also the reload after Show code: a fresh load puts the ending back, with the code one tap away. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, installPage, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage } from "./stub-dom.js"; + +const returnTest = timedTest(3000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); +const CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP"; + +const storage = new MemStorage(); +storage.setItem("sb.orders.v1", JSON.stringify([{ + orderId: "inv_card", badgeType: "supporter", months: 1, + createdAt: new Date(NOW - 60_000).toISOString(), status: "paid", paidInFull: true, + submitted: true, code: CODE, amount: 700, currency: "usd", method: "card", app: "mobile", +}])); +storage.setItem("sb.cardReturn.v1", JSON.stringify({ orderId: "inv_card", at: NOW - 5_000 })); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage, url: "/?payment_intent=pi_1&redirect_status=succeeded" }); +const { app } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); + +returnTest("main: back from Stripe, the order's own flag picks the return-to-app ending", async () => { + await until(() => heading() === "Paid", `the return-to-app ending, not ${heading()}`); + assert.equal(page.location.search, "?order=inv_card"); + assert.equal(screenOf(app).all("iframe")[0]!.getAttribute("src"), `simplexchat:/badge/code/${CODE}`); + assert.ok(!screenOf(app).textContent.includes(CODE)); +}); + +returnTest("main: Show code reveals the stored code", () => { + screenOf(app).all("button.link").find((b) => b.textContent === "Show code")!.click(); + assert.equal(heading(), "Paid"); + assert.equal(screenOf(app).all("div.code")[0]!.textContent, CODE); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts b/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts new file mode 100644 index 0000000000..ddead5d2ef --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts @@ -0,0 +1,49 @@ +// Opened by the mobile app, but every stored row already holds a code, so the new order's code cannot be saved. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage, type StubElement } from "./stub-dom.js"; + +const unsavedTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +const full = new MemStorage(); +full.m.set("sb.orders.v1", JSON.stringify( + Array.from({ length: 50 }, (_, i) => ({ + orderId: `inv_old_${i}`, badgeType: "supporter", months: 1, + createdAt: new Date(NOW - (i + 2) * 86_400_000).toISOString(), status: "paid", paidInFull: true, + code: `SB-OLD${String(i).padStart(2, "0")}-YGQTM-PUYZ9-2TUXP`, amount: 700, currency: "usd", + })), +)); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage: full, url: "/#/tier?app=true" }); +const { app } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); + +unsavedTest("main: a code this browser could not save is shown at once, not held back behind the link", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("1 month"))!.click(); + primaryOf(inView())!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_unsaved", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "0.02", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 0.02 XMR", `the payment screen, not ${heading()}`); + + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_unsaved"); + await until(() => heading() === "Paid. Here is your code.", `the plain code screen, not ${heading()}`); + + assert.match(screenOf(app).all("div.code")[0]!.textContent, /^SB-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}$/); + assert.ok(screenOf(app).textContent.includes("This code could not be saved in this browser.")); + assert.equal(screenOf(app).all("iframe").length, 0, "no link is tried"); + assert.equal(screenOf(app).all("button").filter((b) => b.textContent === "Show code").length, 0); +}); diff --git a/apps/simplex-badge-service/web/test/main-card-full-store.test.ts b/apps/simplex-badge-service/web/test/main-card-full-store.test.ts index bad13b57ef..57cc836222 100644 --- a/apps/simplex-badge-service/web/test/main-card-full-store.test.ts +++ b/apps/simplex-badge-service/web/test/main-card-full-store.test.ts @@ -22,7 +22,7 @@ mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); const page = installPage({ storage: full }); const { app } = page; -// The publishable key makes the card path use the real Payment Element, not a stand-in. +// The publishable key is what makes the card path exist at all; with none, it refuses. const keyMeta = new StubElement("meta"); keyMeta.setAttribute("id", "stripe-publishable-key"); keyMeta.setAttribute("name", "stripe-publishable-key"); diff --git a/apps/simplex-badge-service/web/test/main-card-return.test.ts b/apps/simplex-badge-service/web/test/main-card-return.test.ts index 53752dff42..b4277a9b59 100644 --- a/apps/simplex-badge-service/web/test/main-card-return.test.ts +++ b/apps/simplex-badge-service/web/test/main-card-return.test.ts @@ -37,6 +37,9 @@ returnTest("main: a card return resumes the exact order it was for, not the newe assert.ok(codeShape.test(screenOf(app).serialize()), "the code the buyer paid for is on it"); assert.ok(screenOf(app).serialize().includes(CODE), "and it is the code of inv_card, the order that was paid"); assert.ok(!screenOf(app).serialize().includes("48HqK2Xmv"), "not the newer open order's address"); + assert.ok(screenOf(app).textContent.includes("Settings → Supporter perks → Redeem code"), + "an order bought with no app flag ends as the page always has"); + assert.equal(screenOf(app).all("iframe").length, 0, "with no link to try"); }); returnTest("main: the return is spent — the URL is the order and the remembered id is cleared", () => { diff --git a/apps/simplex-badge-service/web/test/routing.test.ts b/apps/simplex-badge-service/web/test/routing.test.ts index 67ff1c2151..f4f704505a 100644 --- a/apps/simplex-badge-service/web/test/routing.test.ts +++ b/apps/simplex-badge-service/web/test/routing.test.ts @@ -1,6 +1,6 @@ import { timedTest } from "./boot.js"; import assert from "node:assert/strict"; -import { resolveLoad, screenForOpenOrder } from "../src/routing.js"; +import { appCodeLink, readHash, resolveLoad, screenForOpenOrder } from "../src/routing.js"; import { type OrderRecord } from "../src/domain.js"; import { Store } from "../src/store.js"; @@ -68,3 +68,20 @@ routingTest("routing: the flag survives clearSession, a second order and a reloa assert.equal(reloaded.order("inv_second")!.submitted, undefined, "and it did not spread to the order that followed it"); }); + +routingTest("routing: the app flag is read off the hash's tail, and the route is the hash without it", () => { + assert.deepEqual(readHash("#/tier?app=true"), { route: "#/tier", app: "mobile" }); + assert.deepEqual(readHash("#/tier?app=desktop"), { route: "#/tier", app: "desktop" }); + assert.deepEqual(readHash("#/tier"), { route: "#/tier", app: undefined }, "no tail, no flag"); + assert.deepEqual(readHash(""), { route: "", app: undefined }); +}); + +routingTest("routing: an app value outside the contract is no flag at all", () => { + for (const tail of ["app=", "app=false", "app=TRUE", "app=ios", "other=true"]) { + assert.deepEqual(readHash(`#/tier?${tail}`), { route: "#/tier", app: undefined }, tail); + } +}); + +routingTest("routing: the link back to the app is the simplexchat: scheme with the code as its last segment", () => { + assert.equal(appCodeLink("SB-YDC8A-YGQTM-PUYZ9-2TUXP"), "simplexchat:/badge/code/SB-YDC8A-YGQTM-PUYZ9-2TUXP"); +}); diff --git a/apps/simplex-badge-service/web/test/screens.test.ts b/apps/simplex-badge-service/web/test/screens.test.ts index efe74e6535..00bac7561c 100644 --- a/apps/simplex-badge-service/web/test/screens.test.ts +++ b/apps/simplex-badge-service/web/test/screens.test.ts @@ -688,6 +688,66 @@ domTest("screens: the code screen is the one screen that prints a code, with the assert.deepEqual(copied, [HELD_CODE]); }); +domTest("screens: opened from the desktop app, the code screen says the Redeem code screen is already open", () => { + const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true, app: "desktop" })); + assert.ok(p.textContent.includes("Redeem it in the app")); + assert.ok(p.textContent.includes(screens.REDEEM_SCREEN_OPEN)); + assert.ok(!p.textContent.includes(screens.REDEEM_IN_SETTINGS), "one way to redeem, the one already on screen"); + assert.ok(p.textContent.includes(HELD_CODE), "and the code itself is unchanged"); +}); + +domTest("screens: the return-to-app ending opens on the tick, offers the link and Show code, and prints no code", () => { + let returned = 0; + let shown = 0; + const p = render(screens.returnToApp({ + onReturn: () => { returned += 1; }, + code: { kind: "hidden", onShow: () => { shown += 1; } }, + })); + assert.equal(p.children[0], p.all("div.tick")[0], "the payment went through, and this is the first thing that says so"); + assert.equal(p.all("div.tick")[0]!.textContent, "✓"); + assert.equal(p.all("h1")[0]!.textContent, "Paid"); + assert.ok(p.textContent.includes("Opening SimpleX to add your badge.")); + assert.ok(p.textContent.includes("If nothing happens, use the button.")); + assertNoCode(p, "the return-to-app ending"); + p.all("button.primary").find((b) => b.textContent === screens.RETURN_TO_APP)!.click(); + p.all("button.link").find((b) => b.textContent === screens.SHOW_CODE)!.click(); + assert.deepEqual([returned, shown], [1, 1]); +}); + +domTest("screens: Show code reveals the code in place, under a Return to SimpleX that stays, and with no QR", () => { + copied.length = 0; + let returned = 0; + const p = render(screens.returnToApp({ + onReturn: () => { returned += 1; }, + code: { kind: "shown", code: HELD_CODE, savedLocally: true }, + })); + assert.equal(p.all("h1")[0]!.textContent, "Paid", "the ending stays the ending"); + assert.equal(p.all("div.tick").length, 1); + assert.equal(p.all("div.code")[0]!.textContent, HELD_CODE); + assert.equal(p.all("button").filter((b) => b.textContent === screens.SHOW_CODE).length, 0, "Show code has given way"); + assert.equal(p.all("svg").filter((s) => s.getAttribute("class") === "qr").length, 0, + "the buyer is already on the phone, so there is nothing to scan it to"); + assert.ok(!p.textContent.includes("scan to carry it to your phone")); + for (const line of ["Redeem it in the app", screens.REDEEM_IN_SETTINGS, "This is the only copy.", + "Saved in this browser and nowhere else."]) { + assert.ok(p.textContent.includes(line), `the revealed code is missing: ${line}`); + } + + p.all("button.primary").find((b) => b.textContent === "Copy code")!.click(); + assert.deepEqual(copied, [HELD_CODE], "the copy control copies the code"); + p.all("button.primary").find((b) => b.textContent === screens.RETURN_TO_APP)!.click(); + assert.equal(returned, 1, "and the link is still one tap away"); +}); + +domTest("screens: the launcher is a hidden frame out of the tab order, pointed at the link", () => { + const f = render(screens.appLauncher("simplexchat:/badge/code/X")); + assert.equal(f.tagName, "iframe"); + assert.equal(f.getAttribute("src"), "simplexchat:/badge/code/X"); + assert.ok(f.hasAttribute("hidden")); + assert.equal(f.getAttribute("aria-hidden"), "true"); + assert.equal(f.getAttribute("tabindex"), "-1"); +}); + domTest("screens: the code screen drops its saved-copy clause where the local write failed", () => { const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: false })); assert.ok(p.textContent.includes("This code could not be saved in this browser.")); diff --git a/apps/simplex-badge-service/web/test/store.test.ts b/apps/simplex-badge-service/web/test/store.test.ts index d4e8c5a8c4..08ce22b8f5 100644 --- a/apps/simplex-badge-service/web/test/store.test.ts +++ b/apps/simplex-badge-service/web/test/store.test.ts @@ -190,6 +190,27 @@ storeTest("store: the two facts only this browser holds survive a write that omi assert.equal(s.order("1")!.submitted, true); }); +storeTest("store: the app that opened the page round-trips the session, and a value outside the contract reads as none", () => { + const mem = new MemoryStorage(); + const s = new Store(mem); + s.saveSession({ app: "mobile" }); + s.saveSession({ step: "months", priceId: "price_legend" }); + assert.equal(new Store(mem).session().app, "mobile", "a later step and a reload keep it"); + mem.setItem("sb.session.v1", JSON.stringify({ step: "tier", app: "true" })); + assert.equal(s.session().app, undefined, "the stored value is the page's own word, never the URL's"); +}); + +storeTest("store: an order keeps the app it was bought from through a write that omits it and a reload", () => { + const mem = new MemoryStorage(); + const s = new Store(mem); + s.saveOrder(order("1", { app: "desktop" })); + s.saveOrder(order("1", { status: "paid" })); + assert.equal(s.order("1")!.app, "desktop", "the service never sends it, so its answer cannot clear it"); + assert.equal(new Store(mem).order("1")!.app, "desktop"); + s.saveOrder(order("2")); + assert.equal(s.order("2")!.app, undefined, "and it is not a page-wide flag"); +}); + storeTest("store: markSubmitted is per order, sticky, and survives everything but Forget", () => { const mem = new MemoryStorage(); const s = new Store(mem); diff --git a/plans/2026-09-24-badge-buy-in-browser.md b/plans/2026-09-24-badge-buy-in-browser.md index 4dea4782f4..d0a4b9c18f 100644 --- a/plans/2026-09-24-badge-buy-in-browser.md +++ b/plans/2026-09-24-badge-buy-in-browser.md @@ -104,7 +104,7 @@ Default to none. Add one only where behaviour or a consequence cannot be carried ## Open questions -- **Will a browser follow the scheme without a gesture?** Safari and Chrome both block some automatic scheme navigations, and a blocked one can show an error page rather than nothing. *Return to SimpleX* exists for this; the question is only whether the automatic attempt is worth making. +- **Will a browser follow the scheme without a gesture?** The attempt is made from a hidden frame, so a refusal stays inside the frame instead of replacing the screen with an error page and putting the code into history. Expect it to do nothing on iOS Safari, which has ignored custom schemes in frames for years, and on Chrome, which blocks an external-protocol launch with no user gesture — and a payment settling minutes after the last tap has none. So *Return to SimpleX* is the real path and the automatic attempt is a bonus where it happens to work. What remains is to try it on real devices once the app registers the scheme. - **Where the browser button is allowed.** Which markets, and whether the same answer governs both builds. ## Part 2: subscriptions diff --git a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw index 78f7d9987a..25caf53711 100644 --- a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw +++ b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw @@ -858,7 +858,7 @@ "lane1" ], "seed": 1203, - "text": "simplex.chat/badges/#app=true", + "text": "badges.simplex.chat/#/tier?app=true", "fontSize": 9, "fontFamily": 2, "textAlign": "left", @@ -880,7 +880,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "simplex.chat/badges/#app=true", + "originalText": "badges.simplex.chat/#/tier?app=true", "lineHeight": 1.3333333333333333 }, { @@ -1308,7 +1308,7 @@ "lane1" ], "seed": 1216, - "text": "The page's own second screen, quoted as it is. #app=true\nskips its Support SimpleX, which the app has just shown.\nNEW on the page: read that flag, start here, and remember it\nthrough the flow. The app builds the URL, so it can also say\nwhich kind of app it is - better than the page guessing from\nthe user agent (see lane C).", + "text": "The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.", "fontSize": 9, "fontFamily": 2, "textAlign": "left", @@ -1330,7 +1330,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "The page's own second screen, quoted as it is. #app=true\nskips its Support SimpleX, which the app has just shown.\nNEW on the page: read that flag, start here, and remember it\nthrough the flow. The app builds the URL, so it can also say\nwhich kind of app it is - better than the page guessing from\nthe user agent (see lane C).", + "originalText": "The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.", "lineHeight": 1.2222222222222223 }, { @@ -1423,7 +1423,7 @@ "lane1" ], "seed": 1303, - "text": "simplex.chat/badges/#app=true", + "text": "badges.simplex.chat/#/tier?app=true", "fontSize": 9, "fontFamily": 2, "textAlign": "left", @@ -1445,7 +1445,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "simplex.chat/badges/#app=true", + "originalText": "badges.simplex.chat/#/tier?app=true", "lineHeight": 1.3333333333333333 }, { @@ -2035,7 +2035,7 @@ "lane1" ], "seed": 1403, - "text": "simplex.chat/badges/?order=8f3a...#app=true", + "text": "badges.simplex.chat/?order=8f3a...", "fontSize": 9, "fontFamily": 2, "textAlign": "left", @@ -2057,7 +2057,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "simplex.chat/badges/?order=8f3a...#app=true", + "originalText": "badges.simplex.chat/?order=8f3a...", "lineHeight": 1.3333333333333333 }, { @@ -2687,7 +2687,7 @@ "lane1" ], "seed": 1503, - "text": "simplex.chat/badges/?order=8f3a...#app=true", + "text": "badges.simplex.chat/?order=8f3a...", "fontSize": 9, "fontFamily": 2, "textAlign": "left", @@ -2709,7 +2709,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "simplex.chat/badges/?order=8f3a...#app=true", + "originalText": "badges.simplex.chat/?order=8f3a...", "lineHeight": 1.3333333333333333 }, { @@ -3078,7 +3078,7 @@ "lane1" ], "seed": 1513, - "text": "Replaces codeIssued when #app=true. The code is still\nminted and still saved in the browser, so Show code\nrecovers it if the link fails. simplexchat: is a scheme of its\nown, registered everywhere including mobile, so a badge link\ncan never be read as a connection link. Whether a browser\nwill follow it with no tap is the open question.", + "text": "Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.", "fontSize": 9, "fontFamily": 2, "textAlign": "left", @@ -3100,7 +3100,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "Replaces codeIssued when #app=true. The code is still\nminted and still saved in the browser, so Show code\nrecovers it if the link fails. simplexchat: is a scheme of its\nown, registered everywhere including mobile, so a badge link\ncan never be read as a connection link. Whether a browser\nwill follow it with no tap is the open question.", + "originalText": "Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.", "lineHeight": 1.2222222222222223 }, { @@ -8510,7 +8510,7 @@ "lane3" ], "seed": 3203, - "text": "simplex.chat/badges/#app=desktop", + "text": "badges.simplex.chat/#/tier?app=desktop", "fontSize": 9, "fontFamily": 2, "textAlign": "left", @@ -8532,7 +8532,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "simplex.chat/badges/#app=desktop", + "originalText": "badges.simplex.chat/#/tier?app=desktop", "lineHeight": 1.3333333333333333 }, { @@ -8965,7 +8965,7 @@ "lane3" ], "seed": 3215, - "text": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so #app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets #app=true and the A5 screen instead.", + "text": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.", "fontSize": 9, "fontFamily": 2, "textAlign": "left", @@ -8987,7 +8987,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so #app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets #app=true and the A5 screen instead.", + "originalText": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.", "lineHeight": 1.4666666666666666 }, { @@ -9078,7 +9078,7 @@ "lane3" ], "seed": 3303, - "text": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? Safari and Chrome both refuse some scheme navigations that no user gesture asked for, and a refused one can show an error page rather\n than nothing. That is what Return to SimpleX is for, and why Show code sits under it - but it decides whether the automatic attempt is worth making at all.", + "text": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.", "fontSize": 10, "fontFamily": 2, "textAlign": "left", @@ -9100,7 +9100,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? Safari and Chrome both refuse some scheme navigations that no user gesture asked for, and a refused one can show an error page rather\n than nothing. That is what Return to SimpleX is for, and why Show code sits under it - but it decides whether the automatic attempt is worth making at all.", + "originalText": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.", "lineHeight": 1.4444444444444444 }, { @@ -9191,7 +9191,7 @@ "lane3" ], "seed": 3403, - "text": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read ?app and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it; and for #app=desktop,\nkeep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", + "text": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", "fontSize": 10, "fontFamily": 2, "textAlign": "left", @@ -9213,7 +9213,7 @@ "link": null, "locked": false, "containerId": null, - "originalText": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read ?app and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it; and for #app=desktop,\nkeep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", + "originalText": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", "lineHeight": 2 }, { From 36da024394cc9c1a643e1995018c300a19c4d889 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:30:57 +0000 Subject: [PATCH 03/84] ui: buy a badge in the browser, with a deep link back on mobile (#7592) --- apps/ios/Shared/ContentView.swift | 25 +- apps/ios/Shared/SimpleXApp.swift | 3 +- apps/ios/Shared/Views/Badges/BadgeStore.swift | 3 + .../Views/Badges/BadgesRedeemCodeView.swift | 271 ++++++++++++++++-- .../Badges/BadgesSupportSimplexView.swift | 18 +- apps/ios/Shared/Views/Helpers/AppSheet.swift | 8 +- apps/ios/SimpleX--iOS--Info.plist | 10 + apps/ios/spec/client/navigation.md | 2 + .../android/src/main/AndroidManifest.xml | 9 + .../simplex/common/views/badges/BadgeStore.kt | 5 + .../views/badges/BadgesRedeemCodeView.kt | 265 ++++++++++++++--- .../views/badges/BadgesSupportSimplexView.kt | 28 +- .../common/views/chatlist/ChatListView.kt | 25 ++ .../simplex/common/views/helpers/ModalView.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 13 +- apps/multiplatform/spec/client/navigation.md | 6 + .../web/public/index.html | 10 +- .../web/public/styles.css | 4 + apps/simplex-badge-service/web/public/sw.js | 2 +- apps/simplex-badge-service/web/src/screens.ts | 5 +- .../web/test/screens.test.ts | 4 +- 21 files changed, 623 insertions(+), 97 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 22d0da3829..32dffbf2e2 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -461,9 +461,14 @@ struct ContentView: View { } func connectViaUrl_(_ url: URL) { + // an app link must not tear down a redemption in flight + if isAppLink(url) && isBadgeLinkIssuing() { return } 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 +503,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..c31e45439a 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 +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..ed53b21173 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,39 +217,221 @@ 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 - } - 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() - } - } - } catch let error { - logger.error("apiRedeemBadgeCode: \(responseError(error))") - await MainActor.run { - submitting = false - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error)) + 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 } } } } } +private weak var badgeLinkSheet: UIViewController? +// the open screen's own state, not a copy of its step, so the two cannot disagree +private weak var badgeLinkState: BadgeLinkState? + +func isBadgeLinkIssuing() -> Bool { + badgeLinkSheet?.presentingViewController != nil && badgeLinkState?.step == .issuing +} + +func openBadgeLink(_ codeText: String) { + guard let code = parseBadgeCode(codeText) else { + return showCannotRedeemAlert(NSLocalizedString("This code is not valid.", comment: "alert message")) + } + let state = BadgeLinkState() + badgeLinkSheet = showAppSheet { + NavigationView { + BadgesRedeemLinkView(code: code, state: state) + .modifier(ThemedBackground()) + } + } + badgeLinkState = state +} + +enum BadgeLinkStep { + case confirming + case issuing + case redeemed + case viewingBadge +} + +final class BadgeLinkState: ObservableObject { + @Published var step = BadgeLinkStep.confirming +} + +// 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. +struct BadgesRedeemLinkView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var badgeModel = BadgeModel.shared + let code: String + @ObservedObject var state: BadgeLinkState + + var body: some View { + switch state.step { + case .confirming: if profileHasBadge { badgeHeld() } else { confirming() } + case .issuing: beingIssued() + case .redeemed, .viewingBadge: BadgesView(showsAsSheet: true) + } + } + + // 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 + private var profileHasBadge: Bool { + badgeModel.userId == chatModel.currentUser?.userId && badgeModel.badgeState?.shown == true + } + + // the screen cannot tell a repeated link for the badge it shows from a code for another badge, + // so it asserts neither + private func badgeHeld() -> some View { + linkStep( + "Profile already has a badge", + primary: ("View your badge", { state.step = .viewingBadge }), + textButton: ("Cancel", { closeIfShowing() }) + ) { + linkText(NSLocalizedString("A badge can be added to another profile, or here once this badge ends.", comment: "badge link, profile has a badge")) + linkText(NSLocalizedString("Its code is on the page you bought it on, under Show code.", comment: "badge link, profile has a badge")) + } + } + + private func confirming() -> some View { + linkStep( + "Add badge to your profile?", + primary: ("Add badge", { redeemFromLink() }), + textButton: ("Cancel", { closeIfShowing() }) + ) { + linkText(String.localizedStringWithFormat(NSLocalizedString("The badge will be added to the profile %@.", comment: "badge link confirmation"), chatModel.currentUser?.displayName ?? "")) + } + } + + // leaving does not cancel: the badge is still added after the screen closes + private func beingIssued() -> some View { + linkStep("Badge is being issued", textButton: ("Dismiss", { closeIfShowing() })) { + Spacer() + ProgressView().scaleEffect(2) + } + } + + // with no primary, the text button takes its place, so a button does not move between steps + private func linkStep( + _ title: LocalizedStringKey, + primary: (label: LocalizedStringKey, action: () -> Void)? = nil, + textButton: (label: LocalizedStringKey, action: () -> Void), + @ViewBuilder content: () -> Content + ) -> some View { + let textButtonLabel = Text(textButton.label) + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + return VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + content() + + Spacer() + + VStack(spacing: 10) { + if let primary { + Button { + primary.action() + } label: { + Text(primary.label) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .padding(.vertical, 10) + + Button { + textButton.action() + } label: { + textButtonLabel + } + .frame(height: 22) + } else { + Button { + textButton.action() + } label: { + // the inset OnboardingButtonStyle gives a primary + textButtonLabel.padding() + } + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + } + } + .padding(.horizontal, 25) + .padding(.top, 48) + .padding(.bottom, 20) + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func linkText(_ text: String) -> some View { + Text(text) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + private func redeemFromLink() { + // a second tap before the screen changes must not send the code again + guard state.step == .confirming else { return } + guard let user = chatModel.currentUser else { return closeIfShowing() } + state.step = .issuing + Task { + let outcome = await redeemBadgeCode(user, code) + 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) + ) + } + // 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) } + case .cancelled: closeIfShowing() + } + } + } + } + + private var isOpen: Bool { + badgeLinkState === state && badgeLinkSheet?.presentingViewController != nil + } + + // an alert over this screen still counts: dismissing from the presenter dismisses it too + private var isShowing: Bool { + guard isOpen, let sheet = badgeLinkSheet else { return false } + return sheet.presentedViewController == nil || sheet.presentedViewController is UIAlertController + } + + // 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. + private func closeIfShowing(then: @escaping () -> Void = {}) { + if isShowing, let presenter = badgeLinkSheet?.presentingViewController { + presenter.dismiss(animated: true, completion: then) + } else { + if isOpen { state.step = .confirming } + then() + } + } +} + struct BadgesRedeemCodeView_Previews: PreviewProvider { static var previews: some View { NavigationView { 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/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift index 51ae63f07e..d505cdead7 100644 --- a/apps/ios/Shared/Views/Helpers/AppSheet.swift +++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift @@ -31,14 +31,18 @@ private struct PrivacySensitive: ViewModifier { // Presented from the top view controller instead of a .sheet on a parent view, so an alert button or // a view that is itself in a sheet can open it. -func showAppSheet(@ViewBuilder content: () -> Content) { +@discardableResult +func showAppSheet(@ViewBuilder content: () -> Content) -> UIViewController? { if let topController = getTopViewController() { let v = content() .modifier(PrivacySensitive()) .environmentObject(ChatModel.shared) .environmentObject(AppTheme.shared) - topController.present(UIHostingController(rootView: v), animated: true) + let sheet = UIHostingController(rootView: v) + topController.present(sheet, animated: true) + return sheet } + return nil } extension View { 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..329f764efa 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 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. + ### 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..2c7dfd05f6 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 +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..7494229aaa 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 @@ -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(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? = 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, 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 Unit>? = null, + textButton: Pair 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() + ) +} 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..dc65897547 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,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) } + } + } ) } 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..5fe3b7d5f6 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,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, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState) { Box { 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 02c0b45de4..0511bb1cba 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 @@ -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) { 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..957160041b 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,18 @@ 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 + Profile already has a badge + A badge can be added to another profile, or here once this badge ends. + Its code is on the page you bought it on, under Show code. + View your badge + Badge is being issued + Badge added + The badge was added to the profile %1$s. Paste the code you received. SB-XXXXX-XXXXX-XXXXX-XXXXX Redeem diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 34a3b502ee..427e31d9d8 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -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/`) 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 | diff --git a/apps/simplex-badge-service/web/public/index.html b/apps/simplex-badge-service/web/public/index.html index 6caac72b84..9885481483 100644 --- a/apps/simplex-badge-service/web/public/index.html +++ b/apps/simplex-badge-service/web/public/index.html @@ -20,17 +20,17 @@ Support SimpleX - - + + - + - + - + diff --git a/apps/simplex-badge-service/web/public/styles.css b/apps/simplex-badge-service/web/public/styles.css index 2fee434fcd..760cc259e7 100644 --- a/apps/simplex-badge-service/web/public/styles.css +++ b/apps/simplex-badge-service/web/public/styles.css @@ -477,6 +477,10 @@ footer a:hover { text-decoration: underline; } .panel > .primary { margin-top: auto; } .panel > .notes { margin-top: auto; } .notes + .primary { margin-top: 20px; } +/* The ending's button keeps the foot position every screen gives it, but the text above it + keeps a floor: once Show code fills the screen there is no free space for the auto margin, + and the button would otherwise land against the line it follows. */ +.panel.ending > .lede { margin-bottom: 24px; } .notes .muted { margin: 4px auto 0; max-width: 480px; } /* The checkout's note slot holds one line's height whether or not the line is shown, so choosing a method moves nothing above or below it. */ diff --git a/apps/simplex-badge-service/web/public/sw.js b/apps/simplex-badge-service/web/public/sw.js index c0c46f2942..447530421a 100644 --- a/apps/simplex-badge-service/web/public/sw.js +++ b/apps/simplex-badge-service/web/public/sw.js @@ -2,7 +2,7 @@ // runs this exact file in a Node `vm` with a fake Cache API and reads `self.sw`. /** Rewritten by `build.js` from the bytes of the compiled modules, the stylesheet and the images. */ -const BUILD = "55cd5080cd9b28a4"; +const BUILD = "c1e049e40dfe7be6"; const ASSETS = `/assets/${BUILD}/`; /** One cache per build, so eviction is "everything that is not this one". */ const CACHE = `sb-${BUILD}`; diff --git a/apps/simplex-badge-service/web/src/screens.ts b/apps/simplex-badge-service/web/src/screens.ts index 84e2135a00..8ebda80496 100644 --- a/apps/simplex-badge-service/web/src/screens.ts +++ b/apps/simplex-badge-service/web/src/screens.ts @@ -846,11 +846,12 @@ export function returnToApp(o: ReturnToAppOptions): HTMLElement { el("div", { class: "tick" }, "✓"), el("h1", { class: "tight" }, "Paid"), el("p", { class: "lede" }, - el("span", { class: "line" }, "Opening SimpleX to add your badge."), " ", - el("span", { class: "line" }, "If nothing happens, use the button."), + el("span", { class: "line" }, "Your badge is ready."), " ", + el("span", { class: "line" }, "Return to SimpleX to add it."), ), button(RETURN_TO_APP, o.onReturn), ); + p.classList.add("ending"); switch (o.code.kind) { case "hidden": p.append(el("p", { class: "row-line center" }, button(SHOW_CODE, o.code.onShow, "link"))); diff --git a/apps/simplex-badge-service/web/test/screens.test.ts b/apps/simplex-badge-service/web/test/screens.test.ts index 00bac7561c..50370be92b 100644 --- a/apps/simplex-badge-service/web/test/screens.test.ts +++ b/apps/simplex-badge-service/web/test/screens.test.ts @@ -706,8 +706,8 @@ domTest("screens: the return-to-app ending opens on the tick, offers the link an assert.equal(p.children[0], p.all("div.tick")[0], "the payment went through, and this is the first thing that says so"); assert.equal(p.all("div.tick")[0]!.textContent, "✓"); assert.equal(p.all("h1")[0]!.textContent, "Paid"); - assert.ok(p.textContent.includes("Opening SimpleX to add your badge.")); - assert.ok(p.textContent.includes("If nothing happens, use the button.")); + assert.ok(p.textContent.includes("Your badge is ready.")); + assert.ok(p.textContent.includes("Return to SimpleX to add it.")); assertNoCode(p, "the return-to-app ending"); p.all("button.primary").find((b) => b.textContent === screens.RETURN_TO_APP)!.click(); p.all("button.link").find((b) => b.textContent === screens.SHOW_CODE)!.click(); From 479548ee53ffb73db73841e77acbeee5a78dbbd5 Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Sat, 3 Oct 2026 07:36:00 +0000 Subject: [PATCH 04/84] 7.0.4: android 384, desktop 166, ios 361 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++---------- apps/multiplatform/gradle.properties | 8 ++-- packages/simplex-chat-nodejs/package.json | 2 +- .../simplex-chat-nodejs/src/download-libs.js | 2 +- .../src/simplex_chat/_version.py | 4 +- .../flatpak/chat.simplex.simplex.metainfo.xml | 17 ++++++++ 6 files changed, 45 insertions(+), 28 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 66b884fc7e..720aa305dd 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -2085,7 +2085,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2110,7 +2110,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2135,7 +2135,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2160,7 +2160,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2177,11 +2177,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2197,11 +2197,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2222,7 +2222,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2237,7 +2237,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2259,7 +2259,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2274,7 +2274,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2296,7 +2296,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2322,7 +2322,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2347,7 +2347,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2374,7 +2374,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2401,7 +2401,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2416,7 +2416,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2435,7 +2435,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2450,7 +2450,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 2cdcfe90bf..76e2da4719 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.0.3 -android.version_code=381 +android.version_name=7.0.4 +android.version_code=384 -desktop.version_name=7.0.3 -desktop.version_code=164 +desktop.version_name=7.0.4 +desktop.version_code=166 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 9526f080b2..02194ffac2 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.0.3", + "version": "7.0.4", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index cc6b5b4cbd..438451587f 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.0.3'; +const RELEASE_TAG = 'v7.0.4'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 8337c0102f..a816a5d751 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.0.3" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.0.3" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.0.4" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.0.4" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 71192f34e2..51bd7b33ae 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,23 @@ + + https://simplex.chat/blog/20260722-simplex-public-names.html + +

New in v7.0-v7.0.4.

+

SimpleX public names for channels and businesses (BETA).

+

Better channels:

+
    +
  • Promote subscribers to contributors.
  • +
  • Publish your channel on your website.
  • +
  • Verify security code with contributors.
  • +
  • Wider messages, easier to read.
  • +
  • Host your own chat relays.
  • +
+

Add a longer description to your profile.

+

Simplified app settings.

+
+
https://simplex.chat/blog/20260722-simplex-public-names.html From 27d12f230d81296b83962bbe83d09a16b3e0c1ce Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:32:48 +0000 Subject: [PATCH 05/84] core, ui: buy a badge with an App Store or Google Play purchase (#7590) --- apps/ios/Shared/Model/AppAPITypes.swift | 44 +- apps/ios/Shared/Model/SimpleXAPI.swift | 69 +- apps/ios/Shared/SimpleXApp.swift | 1 + apps/ios/Shared/Views/Badges/BadgeStore.swift | 228 ++++- .../Views/Badges/BadgeUserPreview.swift | 17 +- .../Views/Badges/BadgesCheckOrderView.swift | 131 +++ .../Views/Badges/BadgesChooseBadgeView.swift | 75 +- .../Views/Badges/BadgesHowLongView.swift | 154 ++- .../Badges/BadgesPurchaseStateView.swift | 100 ++ .../Views/Badges/BadgesRedeemCodeView.swift | 5 +- .../Badges/BadgesSupportSimplexView.swift | 53 +- apps/ios/Shared/Views/Badges/BadgesView.swift | 16 + .../Views/Badges/BadgesYourBadgeView.swift | 10 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 + apps/ios/SimpleXChat/APITypes.swift | 13 +- apps/ios/SimpleXChat/ChatTypes.swift | 10 + .../foss/java/chat/simplex/app/PlayStore.kt | 9 + .../google/java/chat/simplex/app/PlayStore.kt | 94 +- .../main/java/chat/simplex/app/SimplexApp.kt | 9 + .../chat/simplex/common/model/ChatModel.kt | 6 + .../chat/simplex/common/model/SimpleXAPI.kt | 102 +- .../chat/simplex/common/platform/Platform.kt | 4 + .../simplex/common/views/badges/BadgeStore.kt | 242 ++++- .../common/views/badges/BadgeUserPreview.kt | 19 +- .../views/badges/BadgesCheckOrderView.kt | 107 +++ .../views/badges/BadgesChooseBadgeView.kt | 53 +- .../common/views/badges/BadgesHowLongView.kt | 142 +-- .../views/badges/BadgesPurchaseStateView.kt | 87 ++ .../views/badges/BadgesRedeemCodeView.kt | 11 +- .../views/badges/BadgesSupportSimplexView.kt | 42 +- .../simplex/common/views/badges/BadgesView.kt | 51 +- .../views/badges/BadgesYourBadgeView.kt | 8 + .../commonMain/resources/MR/base/strings.xml | 33 +- apps/simplex-badge-service/README.md | 23 +- .../badge_service.ini.example | 3 + .../src/BadgeService/Catalog.hs | 21 +- .../src/BadgeService/Config.hs | 10 +- .../src/BadgeService/Poller.hs | 5 +- .../src/BadgeService/Service.hs | 216 +++-- .../src/BadgeService/Store.hs | 137 ++- .../src/BadgeService/Store/Invoices.hs | 30 +- .../BadgeService/Store/Postgres/Migrations.hs | 41 +- .../BadgeService/Store/SQLite/Migrations.hs | 41 +- .../src/BadgeService/StoreReceipts.hs | 101 ++ .../src/BadgeService/StoreReceipts/Mock.hs | 41 + .../apple/transaction-legend.json | 19 + .../apple/transaction-sandbox.json | 19 + .../apple/transaction-supporter.json | 19 + bots/api/TYPES.md | 4 + bots/src/API/Docs/Commands.hs | 2 + bots/src/API/Docs/Events.hs | 1 + bots/src/API/Docs/Responses.hs | 1 + docs/protocol/badges-rpc.md | 19 +- docs/protocol/badges-rpc.schema.json | 9 +- .../types/typescript/src/types.ts | 7 + .../src/simplex_chat/types/_types.py | 8 +- plans/2026-09-24-badge-buy-in-browser.md | 2 +- .../badge-service/badge_service.ini.example | 7 +- simplex-chat.cabal | 7 + src/Simplex/Chat.hs | 3 + src/Simplex/Chat/Badges/Ledger.hs | 17 +- src/Simplex/Chat/Badges/Service.hs | 4 +- src/Simplex/Chat/Badges/Types.hs | 19 +- src/Simplex/Chat/Controller.hs | 14 +- src/Simplex/Chat/Library/Commands.hs | 233 ++++- src/Simplex/Chat/PaymentService.hs | 26 +- src/Simplex/Chat/PaymentService/Types.hs | 38 + src/Simplex/Chat/Store/Badges.hs | 252 ++++- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260915_user_badges.hs | 13 +- .../M20260925_badge_store_receipts.hs | 51 + .../Store/Postgres/Migrations/chat_schema.sql | 66 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260915_user_badges.hs | 13 +- .../M20260925_badge_store_receipts.hs | 50 + .../SQLite/Migrations/chat_query_plans.txt | 124 ++- .../Store/SQLite/Migrations/chat_schema.sql | 22 + src/Simplex/Chat/View.hs | 17 +- tests/BadgeTests.hs | 144 ++- tests/Bots/BadgeService/BotTests.hs | 883 +++++++++++++++++- tests/Bots/BadgeService/ConfigTests.hs | 24 + tests/Bots/BadgeService/FakeStore.hs | 141 +++ tests/Bots/BadgeService/WebTests.hs | 7 +- 83 files changed, 4183 insertions(+), 732 deletions(-) create mode 100644 apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt create mode 100644 apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs create mode 100644 apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs create mode 100644 apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json create mode 100644 apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json create mode 100644 apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs create mode 100644 tests/Bots/BadgeService/FakeStore.hs diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index eadca9f71e..bf7ee67a7e 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -193,6 +193,8 @@ enum ChatCommand: ChatCmdProtocol { case apiStandaloneFileInfo(url: String) // badges case apiRedeemBadgeCode(userId: Int64, code: String) + case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, payment: ServicePayment) + case apiCreateBadgeInvoice(userId: Int64) case apiGetBadgeState(userId: Int64) case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64) case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) @@ -419,6 +421,9 @@ enum ChatCommand: ChatCmdProtocol { case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" + case let .apiPurchaseBadge(userId, echoedInvoiceId, payment): + return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "") \(encodeJSON(payment))" + case let .apiCreateBadgeInvoice(userId): return "/_badge invoice \(userId)" case let .apiGetBadgeState(userId): return "/_badge state \(userId)" case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)" case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): @@ -611,6 +616,8 @@ enum ChatCommand: ChatCmdProtocol { case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" + case .apiPurchaseBadge: return "apiPurchaseBadge" + case .apiCreateBadgeInvoice: return "apiCreateBadgeInvoice" case .apiGetBadgeState: return "apiGetBadgeState" case .apiGetBadgeLedger: return "apiGetBadgeLedger" case .apiAckBadgeAlert: return "apiAckBadgeAlert" @@ -667,9 +674,11 @@ enum ChatCommand: ChatCmdProtocol { return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) case let .testStorageEncryption(key): return .testStorageEncryption(key: obfuscate(key)) - // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString case let .apiRedeemBadgeCode(userId, code): return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code)) + case let .apiPurchaseBadge(userId, echoedInvoiceId, .apple(jws)): + return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .apple(jws: obfuscate(jws))) default: return self } } @@ -719,6 +728,29 @@ enum ChatCommand: ChatCmdProtocol { } } +enum ServicePayment: Encodable { + case apple(jws: String) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .apple(jws): + try container.encode("apple", forKey: .type) + try container.encode(jws, forKey: .jws) + } + } + + private enum CodingKeys: String, CodingKey { + case type, jws + } +} + +struct OpenStorePurchase: Decodable { + var invoiceId: String? + var transactionRef: String? + var creditError: BadgeIssueFailure? +} + // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. // Spec: spec/api.md#ChatResponse0 enum ChatResponse0: Decodable, ChatAPIResult { @@ -1051,7 +1083,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) - case badgeState(user: UserRef, badgeState: BadgeState?) + case badgeInvoice(user: UserRef, invoiceId: String) + case badgeState(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]?) case badgeLedger(user: UserRef, badgeLedger: [StatementEntry]) var responseType: String { @@ -1105,6 +1138,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .archiveImported: "archiveImported" case .appSettings: "appSettings" case .badgeRedeemed: "badgeRedeemed" + case .badgeInvoice: "badgeInvoice" case .badgeState: "badgeState" case .badgeLedger: "badgeLedger" } @@ -1161,7 +1195,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") - case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeInvoice(u, invoiceId): return withUser(u, invoiceId) + case let .badgeState(u, badgeState, storePurchases): return withUser(u, "\(String(describing: badgeState))\nstorePurchases: \(String(describing: storePurchases ?? []))") case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger)) } } @@ -1248,6 +1283,7 @@ enum ChatEvent: Decodable, ChatAPIResult { // badges case badgeChanged(user: User, badgeState: BadgeState?) case badgeAlert(user: UserRef, badgeAlert: BadgeAlert) + case storePurchaseResolved(user: UserRef) var responseType: String { switch self { @@ -1322,6 +1358,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .contactPQEnabled: "contactPQEnabled" case .badgeChanged: "badgeChanged" case .badgeAlert: "badgeAlert" + case .storePurchaseResolved: "storePurchaseResolved" } } @@ -1406,6 +1443,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState)) case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert)) + case .storePurchaseResolved: return noDetails } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 742ef20688..dc7cfb059e 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2196,13 +2196,42 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: throw r.unexpected } +// a refusal is thrown, as BREServiceError with the code +enum BadgePurchaseResult { + case held(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) + case credited(user: User, badgeState: BadgeState?) +} + +// log: false because a store receipt is a bearer secret, like a badge code - it is in the command. +func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment) async throws -> BadgePurchaseResult { + let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment), log: false) + switch r { + case let .badgeState(user, badgeState, storePurchases): return .held(user: user, badgeState: badgeState, storePurchases: storePurchases ?? []) + case let .badgeRedeemed(user, _, _, badgeState): return .credited(user: user, badgeState: badgeState) + default: throw r.unexpected + } +} + +func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String { + let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId)) + if case let .badgeInvoice(_, invoiceId) = r { return invoiceId } + throw r.unexpected +} + // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened -func redeemErrorText(_ error: Error) -> String { +func redeemErrorText(_ error: Error, purchase: Bool) -> String { if case let .error(.badgeRedeemError(e)) = error as? ChatError { switch e { case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message") - case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") - case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") + case .invalidReceipt: break + case .serviceNotConfigured: + return purchase + ? NSLocalizedString("This app version cannot buy badges.", comment: "alert message") + : NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") + case .badgeActive: + return purchase + ? NSLocalizedString("This profile already has a badge.", comment: "alert message") + : NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text } case let .invalidResponse(message): return String.localizedStringWithFormat(NSLocalizedString("The badge service sent an unexpected response: %@", comment: "alert message"), message) @@ -2212,15 +2241,15 @@ func redeemErrorText(_ error: Error) -> String { return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error)) } -func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? { +func apiGetBadgeState(_ userId: Int64) async throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) { let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) } throw r.unexpected } -func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? { +func apiGetBadgeStateSync(_ userId: Int64) throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) { let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) } throw r.unexpected } @@ -2232,7 +2261,7 @@ func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws - func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? { let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, _) = r { return badgeState } throw r.unexpected } @@ -2361,6 +2390,7 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws ChatReceiver.shared.start() m.chatRunning = true chatLastStartGroupDefault.set(Date.now) + Task { await BadgeStore.shared.presentUnfinished() } } func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? { @@ -2385,6 +2415,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws { m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd) m.users = try listUsers() try getUserChatData() + Task { await BadgeStore.shared.presentUnfinished() } } func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws { @@ -2400,6 +2431,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: S m.users = users } try await getUserChatDataAsync(keepingChatId: keepingChatId) + Task { await BadgeStore.shared.presentUnfinished() } await MainActor.run { if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) { invitation.user = currentUser @@ -2427,22 +2459,32 @@ func getUserChatData() throws { private func loadBadgeState() { do { let userId = try currentUserId("loadBadgeState") - let badgeState = try apiGetBadgeStateSync(userId) + let (badgeState, storePurchases) = try apiGetBadgeStateSync(userId) BadgeModel.shared.set(userId: userId, badgeState: badgeState) + BadgeStore.shared.setStorePurchases(userId, storePurchases) } catch let error { logger.error("loadBadgeState: \(responseError(error))") } } -private func loadBadgeStateAsync(_ userId: Int64) async { +func loadBadgeStateAsync(_ userId: Int64) async { do { - let badgeState = try await apiGetBadgeState(userId) - await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) } + let (badgeState, storePurchases) = try await apiGetBadgeState(userId) + await MainActor.run { + BadgeModel.shared.set(userId: userId, badgeState: badgeState) + BadgeStore.shared.setStorePurchases(userId, storePurchases) + } } catch let error { logger.error("loadBadgeState: \(responseError(error))") } } +func loadCurrentBadgeState() async { + if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) { + await loadBadgeStateAsync(userId) + } +} + private func getUserChatDataAsync(keepingChatId: String?) async throws { let m = ChatModel.shared let tm = ChatTagsModel.shared @@ -3052,6 +3094,9 @@ func processReceivedMsg(_ res: ChatEvent) async { BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert) } } + case .storePurchaseResolved: + // whichever profile owns it: only the app can finish the store transaction + Task { await BadgeStore.shared.presentUnfinished() } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 28aa7e4002..654a31c001 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -101,6 +101,7 @@ struct SimpleXApp: App { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { await updateCallInvitations() } + Task { await BadgeStore.shared.presentUnfinished() } if let url = chatModel.appOpenUrlLater { await MainActor.run { chatModel.appOpenUrlLater = nil diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index c31e45439a..0c338655cd 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -9,6 +9,7 @@ import Foundation import Combine import StoreKit +import SimpleXChat // TODO [badges] product ids will come from app config and prices from the badge service catalog; // hardcoded here so the App Store integration can be tested before the purchase API lands. @@ -27,13 +28,26 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in BadgePeriod.allCases.map { badgeProductId(level, $0) } } -// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. -// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction, -// which is how the service learns which invoice a store transaction settles. +// the only products sent to the badge service: nothing delivers a subscription yet +let badgeOneTimeProductIds: Set = Set(BadgeLevel.allCases.map { badgeProductId($0, .oneMonth) }) + +// subscriptions are for sale once renewals are delivered +let badgePeriodsForSale: [BadgePeriod] = [.oneMonth] + +// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. +// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction. func newBadgeInvoiceId() -> UUID { UUID() } // the page's app flag rides in the fragment, which never reaches the service -let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true" +// TEST ONLY: pointed at the dev deployment; restore the line below before merging +let badgePageUrl = "https://smp7.simplex.im/#/tier?app=true" +// let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true" + +// where the store allows a link out to the badge page: the US for now, a set expected to widen. +// An unknown storefront does not count, as this decides whether the store sees a link out of the app. +var badgeBrowserAllowed: Bool { + SKPaymentQueue.default().storefront?.countryCode == "USA" +} enum BadgePrice { case loading @@ -52,21 +66,32 @@ struct BadgeStoreReceipt { // the signed token the badge service verifies - never transaction.jsonRepresentation let jws: String let productId: String - let transactionId: UInt64 let invoiceId: UUID? - let environment: String? let signatureVerified: Bool + let transaction: Transaction + + var echoedInvoiceId: String? { invoiceId.map(coreInvoiceId) } } +// core mints the id in lower case, and UUID formats it in upper case +func coreInvoiceId(_ invoiceId: UUID) -> String { invoiceId.uuidString.lowercased() } + enum BadgePurchaseOutcome { case purchased(BadgeStoreReceipt) - case pending + case pending(invoiceId: String?) case cancelled } +enum BadgePurchaseState { + case issuing + case waitingForApproval +} + enum BadgeStoreError: Error { case productUnavailable(productId: String) case unknownPurchaseResult + case noActiveProfile + case invalidInvoiceId(String) } final class BadgeStore: ObservableObject { @@ -76,14 +101,52 @@ final class BadgeStore: ObservableObject { @Published private var state: LoadState = .notLoaded private var products: [String: Product] = [:] + // core's open store purchases for the profile they were read for: core knows whose a purchase is + @Published private var storePurchases: (userId: Int64, purchases: [OpenStorePurchase])? = nil + // whether a store sheet this run opened has not returned + @Published private var buying = false + // kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing + // by invoice id, so a pending purchase shows only under the profile whose record it names + @Published private var waitingForApproval: Set = [] + // set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was + // closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again + @Published private var reconciledOnce = false + let refusals = PassthroughSubject() + private var transactionUpdates: Task? = nil private init() {} - func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice { + func purchaseState(_ userId: Int64?) -> BadgePurchaseState? { + let purchases = openStorePurchases(userId) + if purchases.contains(where: { $0.transactionRef != nil }) { return .issuing } + if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval } + return nil + } + + func creditError(_ userId: Int64?) -> BadgeIssueFailure? { + openStorePurchases(userId).compactMap(\.creditError).first + } + + var checkingPurchases: Bool { !reconciledOnce } + + func canBuy(_ userId: Int64?) -> Bool { + reconciledOnce && !buying && purchaseState(userId) == nil + } + + func setStorePurchases(_ userId: Int64, _ purchases: [OpenStorePurchase]) { + storePurchases = (userId, purchases) + } + + private func openStorePurchases(_ userId: Int64?) -> [OpenStorePurchase] { + guard let storePurchases, storePurchases.userId == userId else { return [] } + return storePurchases.purchases + } + + func price(_ level: BadgeLevel, _ period: BadgePeriod, compact: Bool = true) -> BadgePrice { switch state { case .notLoaded, .loading: return .loading case .loaded, .failed: - if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) } + if let p = products[badgeProductId(level, period)] { return .price(compact ? compactPrice(p) : p.displayPrice) } return .unavailable } } @@ -122,33 +185,123 @@ final class BadgeStore: ObservableObject { } } - func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome { + // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> BadgePurchaseOutcome { let productId = badgeProductId(level, period) guard let product = await MainActor.run(body: { products[productId] }) else { throw BadgeStoreError.productUnavailable(productId: productId) } - switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { - case let .success(verification): - let transaction: Transaction - let signatureVerified: Bool - switch verification { - case let .verified(t): - transaction = t - signatureVerified = true - case let .unverified(t, _): - transaction = t - signatureVerified = false + // a subscription is never sent to core, so nothing would ever finish it later + guard badgeOneTimeProductIds.contains(productId) else { + let outcome = try await storePurchase(product, newBadgeInvoiceId()) + if case let .purchased(receipt) = outcome { await receipt.transaction.finish() } + return outcome + } + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { + throw BadgeStoreError.noActiveProfile + } + let invoice = try await apiCreateBadgeInvoice(userId) + guard let invoiceId = UUID(uuidString: invoice) else { throw BadgeStoreError.invalidInvoiceId(invoice) } + await MainActor.run { buying = true } + await loadBadgeStateAsync(userId) + do { + let outcome = try await storePurchase(product, invoiceId) + if case let .purchased(receipt) = outcome, receipt.signatureVerified { + try await handOver(receipt) } - // nothing is delivered in this build, so the transaction is finished right away; once the - // service issues credentials it must only be finished after the credential is stored - await transaction.finish() - return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified)) - case .pending: return .pending + await MainActor.run { buying = false } + return outcome + } catch let error { + await MainActor.run { buying = false } + throw error + } + } + + private func storePurchase(_ product: Product, _ invoiceId: UUID) async throws -> BadgePurchaseOutcome { + switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { + case let .success(verification): return .purchased(storeReceipt(verification)) + case .pending: + let pending = coreInvoiceId(invoiceId) + await MainActor.run { _ = waitingForApproval.insert(pending) } + return .pending(invoiceId: pending) case .userCancelled: return .cancelled @unknown default: throw BadgeStoreError.unknownPurchaseResult } } + // Core holds the receipt and credits it; the transaction stays unfinished until core answers it credited + // or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way. + private func handOver(_ receipt: BadgeStoreReceipt) async throws { + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return } + // a refusal is announced where it reads as its own, so only an unexpected answer reaches the buyer + do { + switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) { + case let .held(user, badgeState, storePurchases): + await MainActor.run { + // the answer is the owner's, which may be another profile and a hidden one + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + setStorePurchases(user.userId, storePurchases) + } + } + case let .credited(user, badgeState): + await MainActor.run { + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + ChatModel.shared.updateUser(user) + if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) } + } + } + await resolve(receipt, refusal: nil) + } + } catch let error where badgeReceiptRefused(error) { + logger.error("BadgeStore.handOver: \(responseError(error))") + await resolve(receipt, refusal: error) + } + } + + private func resolve(_ receipt: BadgeStoreReceipt, refusal: Error?) async { + await receipt.transaction.finish() + if let refusal { + await MainActor.run { refusals.send(refusal) } + } + } + + // at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer + func presentUnfinished() async { + await listenForTransactions() + for await verification in Transaction.unfinished { + await reconcile(verification) + } + await loadCurrentBadgeState() + await MainActor.run { reconciledOnce = true } + } + + // transactions the store settles outside a purchase call, such as an approved Ask to Buy + @MainActor + private func listenForTransactions() { + if transactionUpdates == nil { + transactionUpdates = Task.detached { + for await verification in Transaction.updates { + await BadgeStore.shared.reconcile(verification) + } + } + } + } + + // an unverified one-time transaction is never sent, and left unfinished rather than lost + private func reconcile(_ verification: VerificationResult) async { + let receipt = storeReceipt(verification) + if !badgeOneTimeProductIds.contains(receipt.productId) { + await receipt.transaction.finish() + } else if receipt.signatureVerified { + // no buyer to tell: the receipt stays unfinished and the next sweep hands it over again + do { try await handOver(receipt) } catch let error { + logger.error("BadgeStore.reconcile: \(responseError(error))") + } + } + } + @MainActor private func startLoading() -> Bool { switch state { @@ -172,15 +325,24 @@ private func compactPrice(_ product: Product) -> String { : product.displayPrice } -private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt { - var environment: String? = nil - if #available(iOS 16.0, *) { environment = t.environment.rawValue } +private func storeReceipt(_ verification: VerificationResult) -> BadgeStoreReceipt { + let (t, signatureVerified) = switch verification { + case let .verified(t): (t, true) + case let .unverified(t, _): (t, false) + } return BadgeStoreReceipt( - jws: jws, + jws: verification.jwsRepresentation, productId: t.productID, - transactionId: t.id, invoiceId: t.appAccountToken, - environment: environment, - signatureVerified: signatureVerified + signatureVerified: signatureVerified, + transaction: t ) } + +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it +private func badgeReceiptRefused(_ error: Error) -> Bool { + if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { + return code == .receiptInvalid || code == .receiptUsed + } + return false +} diff --git a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift index 45d94c3ed6..10b68be473 100644 --- a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift +++ b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift @@ -9,15 +9,9 @@ import SwiftUI import SimpleXChat -struct BadgeUserPreview: View { +struct BadgeUserPreview: View { @EnvironmentObject var chatModel: ChatModel let level: BadgeLevel - let trailing: () -> Trailing - - init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) { - self.level = level - self.trailing = trailing - } var body: some View { let user = chatModel.currentUser @@ -29,12 +23,9 @@ struct BadgeUserPreview: View { ) return VStack(spacing: 12) { ProfileImage(imageStr: user?.image, size: 128) - HStack(alignment: .center, spacing: 6) { - NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) - .lineLimit(1) - .minimumScaleFactor(0.75) - trailing() - } + NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) + .lineLimit(1) + .minimumScaleFactor(0.75) } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift new file mode 100644 index 0000000000..2c0dd851ed --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift @@ -0,0 +1,131 @@ +// +// BadgesCheckOrderView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesCheckOrderView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var store = BadgeStore.shared + let level: BadgeLevel + let period: BadgePeriod + @State private var purchasing = false + // presented from this view, not AlertManager: its host is behind the sheet these views open in + @State private var alert: SomeAlert? + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("Check your order") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 0) { + orderRow("Your badge", Text(level.title)) + orderRow("Duration", Text(period.label)) + Divider() + .padding(.top, 8) + .padding(.bottom, 6) + orderRow("Total", period.priceText(store.price(level, period, compact: false))) + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.top, 20) + + Spacer(minLength: 20) + + VStack(spacing: 10) { + payButton() + .padding(.vertical, 10) + BadgeBillingFooter(period: period) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + .alert(item: $alert) { $0.alert } + } + + private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View { + HStack { + Text(title) + .foregroundColor(theme.colors.secondary) + Spacer() + value + .fontWeight(.medium) + } + .font(.body) + .padding(.vertical, 8) + } + + private func payButton() -> some View { + let price = store.price(level, period, compact: false) + let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId) + return Button { + purchase() + } label: { + period.payText(price) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func purchase() { + purchasing = true + Task { + do { + if case let .purchased(receipt) = try await store.purchase(level, period), !receipt.signatureVerified { + await MainActor.run { + alert = SomeAlert( + alert: mkAlert( + title: "Cannot verify this purchase", + message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store." + ), + id: "badgePurchaseUnverified" + ) + } + } + await MainActor.run { purchasing = false } + } catch let error { + logger.error("BadgesCheckOrderView.purchase: \(String(describing: error))") + await MainActor.run { + purchasing = false + alert = SomeAlert( + alert: Alert( + title: Text("Purchase error"), + message: Text(verbatim: redeemErrorText(error, purchase: true)) + ), + id: "badgePurchaseError" + ) + } + } + } + } +} + +struct BadgesCheckOrderView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesCheckOrderView(level: .supporter, period: .oneMonth) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift index 077c9e5810..ba7b5b8613 100644 --- a/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift @@ -23,10 +23,17 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } - var filesDescription: LocalizedStringKey { + var fileSize: LocalizedStringKey { switch self { - case .supporter: "Send 2GB files" - case .legend: "Send 5GB files" + case .supporter: "Files up to 2 GB" + case .legend: "Files up to 5 GB" + } + } + + var fileStorage: LocalizedStringKey { + switch self { + case .supporter: "Stored for 7 days" + case .legend: "Stored for 21 days" } } @@ -50,7 +57,6 @@ struct BadgesChooseBadgeView: View { @ObservedObject private var store = BadgeStore.shared @State private var selectedLevel: BadgeLevel = .supporter @State private var continueActive = false - @State private var howItWorksActive = false var body: some View { GeometryReader { g in @@ -69,14 +75,10 @@ struct BadgesChooseBadgeView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - BadgeUserPreview(level: selectedLevel) { - Image(systemName: "chevron.down") - .font(.body) - .foregroundColor(theme.colors.primary) - } - .padding(.top, 4) + BadgeUserPreview(level: selectedLevel) + .padding(.top, 4) - Spacer(minLength: 20) + Spacer(minLength: 12) // fixedSize + maxHeight on the cards so both match the taller one when a // store price wraps in one of them @@ -86,13 +88,20 @@ struct BadgesChooseBadgeView: View { } .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 20) + Spacer(minLength: 12) VStack(spacing: 10) { continueButton() .padding(.vertical, 10) - howItWorksButton() - .frame(height: 22) + // redeeming a code is here only when Support SimpleX offers the browser instead + Group { + if badgeBrowserAllowed { + RedeemCodeButton() + } else { + Color.clear + } + } + .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -116,17 +125,20 @@ struct BadgesChooseBadgeView: View { Image(badgeImageName(level.badgeType)) .resizable() .scaledToFit() - .frame(width: 60, height: 60) - .padding(.top, 20) + .frame(width: 44, height: 44) + .padding(.top, 16) Text(level.title) .font(.title3) .fontWeight(.bold) - Text(level.filesDescription) - .font(.subheadline) - .foregroundColor(theme.colors.secondary) BadgePeriod.monthly.priceText(store.price(level, .monthly)) .font(.body) - .padding(.bottom, 20) + VStack(spacing: 2) { + Text(level.fileSize) + Text(level.fileStorage) + } + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + .padding(.bottom, 16) } .multilineTextAlignment(.center) .padding(.horizontal, 12) @@ -160,29 +172,6 @@ struct BadgesChooseBadgeView: View { .hidden() } } - - private func howItWorksButton() -> some View { - ZStack { - Button { - howItWorksActive = true - } label: { - HStack(spacing: 4) { - Image(systemName: "info.circle") - Text("How badges protect your privacy").fontWeight(.medium) - } - .font(.body) - } - - NavigationLink(isActive: $howItWorksActive) { - BadgesHowItWorksView() - .modifier(ThemedBackground()) - } label: { - EmptyView() - } - .frame(width: 1, height: 1) - .hidden() - } - } } struct BadgesChooseBadgeView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift index d2783383de..2377a79a3b 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -33,6 +33,14 @@ enum BadgePeriod: String, CaseIterable, Identifiable { } } + var months: Int { + switch self { + case .oneMonth: 1 + case .monthly: 1 + case .annual: 12 + } + } + func priceText(_ price: BadgePrice) -> Text { switch price { case .loading: return Text(verbatim: "…") @@ -64,10 +72,8 @@ struct BadgesHowLongView: View { @EnvironmentObject var theme: AppTheme @ObservedObject private var store = BadgeStore.shared let level: BadgeLevel - @State private var selectedPeriod: BadgePeriod = .monthly - @State private var purchasing = false - // presented from this view, not AlertManager: its host is behind the sheet these views open in - @State private var alert: SomeAlert? + @State private var selectedPeriod: BadgePeriod = badgePeriodsForSale.contains(.monthly) ? .monthly : .oneMonth + @State private var continueActive = false var body: some View { GeometryReader { g in @@ -91,26 +97,19 @@ struct BadgesHowLongView: View { Spacer(minLength: 20) - // fixedSize + maxHeight on the cards so all three match the tallest one - + // fixedSize + maxHeight on the cards so they all match the tallest one - // only Annual carries a savings line, and prices wrap at large fonts HStack(alignment: .top, spacing: 12) { - periodCard(.oneMonth) - periodCard(.monthly) - periodCard(.annual) + ForEach(BadgePeriod.allCases) { periodCard($0) } } .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 20) VStack(spacing: 10) { - payButton() + continueButton() .padding(.vertical, 10) - Text(billingFooter) - .font(.footnote) - .foregroundColor(theme.colors.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .frame(height: 22) + BadgeBillingFooter(period: selectedPeriod) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -123,11 +122,11 @@ struct BadgesHowLongView: View { .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) .task { await store.load() } - .alert(item: $alert) { $0.alert } } private func periodCard(_ period: BadgePeriod) -> some View { let isSelected = period == selectedPeriod + let forSale = badgePeriodsForSale.contains(period) return Button { selectedPeriod = period } label: { @@ -137,17 +136,18 @@ struct BadgesHowLongView: View { .scaledToFit() .frame(width: 32, height: 32) .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + .opacity(forSale ? 1 : 0.4) Text(period.label) - .font(.title3) - .fontWeight(.bold) + .font(.subheadline) period.priceText(store.price(level, period)) - .font(.body) + .font(.headline) if let percent = savingsPercent(period) { - Text("Save \(percent)%") + Text("\(percent)% off") .font(.footnote) .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) } } + .foregroundColor(forSale ? nil : theme.colors.secondary) .multilineTextAlignment(.center) .padding(.vertical, 25) .padding(.horizontal, 12) @@ -160,99 +160,53 @@ struct BadgesHowLongView: View { ) } .buttonStyle(.plain) + .disabled(!forSale) } private func savingsPercent(_ period: BadgePeriod) -> Int? { period == .annual ? store.annualSavings(level) : nil } - private func payButton() -> some View { - let price = store.price(level, selectedPeriod) - let disabled = !price.canPurchase || purchasing - return Button { - purchase() - } label: { - selectedPeriod.payText(price) - } - .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) - .disabled(disabled) - } - - private func purchase() { - let period = selectedPeriod - let invoiceId = newBadgeInvoiceId() - purchasing = true - Task { - do { - let outcome = try await store.purchase(level, period, invoiceId: invoiceId) - await MainActor.run { - purchasing = false - switch outcome { - case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId) - case .pending: - alert = SomeAlert( - alert: mkAlert( - title: "Purchase pending", - message: "The purchase is awaiting approval. This build does not deliver purchases approved later." - ), - id: "badgePurchasePending" - ) - case .cancelled: break - } - } - } catch let error { - logger.error("BadgesHowLongView.purchase: \(String(describing: error))") - await MainActor.run { - purchasing = false - alert = SomeAlert( - alert: Alert( - title: Text("Purchase error"), - message: Text(verbatim: String(describing: error)) - ), - id: "badgePurchaseError" - ) - } + private func continueButton() -> some View { + ZStack { + Button { + continueActive = true + } label: { + Text("Continue") } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $continueActive) { + BadgesCheckOrderView(level: level, period: selectedPeriod) + .modifier(ThemedBackground(grouped: true)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } +} - // TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. - private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) { - let returnedInvoice: String - if let returned = receipt.invoiceId { - returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)" - } else { - returnedInvoice = "none" - } - var lines = [ - "Product: \(receipt.productId)", - "Invoice: \(invoiceId.uuidString)", - "Invoice returned by Apple: \(returnedInvoice)", - "Transaction: \(receipt.transactionId)" - ] - if let environment = receipt.environment { lines.append("Environment: \(environment)") } - lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")") - lines.append("Token: \(receipt.jws.count) bytes") - let summary = lines.joined(separator: "\n") - // logged as well as shown: the alert races StoreKit's own sheets, the log always lands - logger.debug("badge purchase succeeded\n\(summary)") - alert = SomeAlert( - alert: Alert( - title: Text("Purchase successful"), - message: Text(verbatim: summary), - primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws }, - secondaryButton: .cancel(Text("Ok")) - ), - id: "badgePurchased" - ) +struct BadgeBillingFooter: View { + @EnvironmentObject var theme: AppTheme + let period: BadgePeriod + + var body: some View { + Text(billingFooter) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(height: 22) } private var billingFooter: LocalizedStringKey { - // TODO [badges] source the actual date from the purchase state machine when wired. - var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22 - let stubDate = Calendar.current.date(from: comps) ?? Date() - let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) - switch selectedPeriod { + // TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld); + // a top-up must count from the end of the existing balance + let endDate = Calendar.current.date(byAdding: .month, value: period.months, to: Date()) ?? Date() + let date = DateFormatter.localizedString(from: endDate, dateStyle: .long, timeStyle: .none) + switch period { case .monthly, .annual: return "Renews on \(date). Cancel anytime." case .oneMonth: return "Ends on \(date)." } diff --git a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift new file mode 100644 index 0000000000..702304acd4 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift @@ -0,0 +1,100 @@ +// +// BadgesPurchaseStateView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesPurchaseStateView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) private var dismiss + let title: LocalizedStringKey + var message: LocalizedStringKey? = nil + var failure: BadgeIssueFailure? = nil + var showsAsSheet: Bool = false + + var body: some View { + GeometryReader { g in + ScrollView { + content + .padding(.horizontal, 25) + .padding(.top, showsAsSheet ? 48 : 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private var content: some View { + VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + if let message { + Text(message) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + if let failure { + VStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text(failure.purchaseText) + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + ProgressView().scaleEffect(2) + + Spacer() + + VStack(spacing: 10) { + Button { + dismiss() + } label: { + Text("Dismiss") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + .padding() + } + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + } + } +} + +extension BadgePurchaseState { + var title: LocalizedStringKey { + switch self { + case .issuing: "Issuing your badge" + case .waitingForApproval: "Waiting for approval" + } + } + + var message: LocalizedStringKey { + switch self { + case .issuing: "Your payment is complete. The badge will be added to this profile." + case .waitingForApproval: "Nothing has been charged." + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index ed53b21173..322dbf1864 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -59,7 +59,7 @@ func redeemBadgeCode(_ user: User, _ code: String) async -> BadgeRedeemOutcome { } } catch let error { logger.error("apiRedeemBadgeCode: \(responseError(error))") - return .refused(message: redeemErrorText(error)) + return .refused(message: redeemErrorText(error, purchase: false)) } } @@ -313,7 +313,8 @@ struct BadgesRedeemLinkView: View { // leaving does not cancel: the badge is still added after the screen closes private func beingIssued() -> some View { - linkStep("Badge is being issued", textButton: ("Dismiss", { closeIfShowing() })) { + linkStep("Issuing your badge", textButton: ("Dismiss", { closeIfShowing() })) { + linkText(NSLocalizedString("The badge will be added to this profile.", comment: "badge link, being issued")) Spacer() ProgressView().scaleEffect(2) } diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 22cec4a4f4..df13abdc97 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -11,13 +11,14 @@ import SimpleXChat struct BadgesSupportSimplexView: View { @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var store = BadgeStore.shared // set true when presented as a sheet root (from the chat-list banner) — that path doesn't // reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top var showsAsSheet: Bool = false @State private var whyBuiltActive = false @State private var howItWorksActive = false @State private var chooseLevelActive = false - @State private var redeemCodeActive = false var body: some View { GeometryReader { g in @@ -34,8 +35,6 @@ struct BadgesSupportSimplexView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - // TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen - // returns to the flow and howItWorksButton() moves there, leaving this one alone here. howItWorksButton() Spacer(minLength: 0) @@ -47,10 +46,16 @@ struct BadgesSupportSimplexView: View { Spacer(minLength: 0) VStack(spacing: 10) { - buyInBrowserButton() + chooseLevelButton() .padding(.vertical, 10) - redeemCodeButton() - .frame(height: 22) + Group { + if badgeBrowserAllowed { + buyInBrowserButton() + } else { + RedeemCodeButton() + } + } + .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -65,15 +70,16 @@ struct BadgesSupportSimplexView: View { .navigationBarTitleDisplayMode(.inline) } - // the in-app purchase path, kept compiling and uncalled until payments return after the MVP private func chooseLevelButton() -> some View { - ZStack { + let disabled = !store.canBuy(chatModel.currentUser?.userId) + return ZStack { Button { chooseLevelActive = true } label: { - Text("Choose your level") + Text("Choose your badge") } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) NavigationLink(isActive: $chooseLevelActive) { BadgesChooseBadgeView() @@ -125,7 +131,23 @@ struct BadgesSupportSimplexView: View { } } - private func redeemCodeButton() -> some View { + private func buyInBrowserButton() -> some View { + Button { + UIApplication.shared.open(URL(string: badgePageUrl)!) + } label: { + Text("Buy in browser") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + } +} + +struct RedeemCodeButton: View { + @EnvironmentObject var theme: AppTheme + @State private var redeemCodeActive = false + + var body: some View { ZStack { Button { redeemCodeActive = true @@ -146,15 +168,6 @@ struct BadgesSupportSimplexView: View { .hidden() } } - - private func buyInBrowserButton() -> some View { - Button { - UIApplication.shared.open(URL(string: badgePageUrl)!) - } label: { - Text("Buy in browser") - } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) - } } struct PhoneSupporterHero: View { diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index df12094a6b..28fa1a02e8 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct BadgesView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject private var badgeModel = BadgeModel.shared + @ObservedObject private var store = BadgeStore.shared var showsAsSheet: Bool = false private var shownBadge: BadgeState? { @@ -25,12 +26,27 @@ struct BadgesView: View { if let badgeState = shownBadge { BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet) .transition(.opacity) + } else if let purchaseState = store.purchaseState(chatModel.currentUser?.userId) { + // holds the purchase screens' slot, so a consumable cannot be bought twice + BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet) + .transition(.opacity) + .onReceive(store.refusals) { refusal in + // only the issuing screen belongs to the active profile's held purchase, so a refusal shown there reads as its own + if purchaseState == .issuing { + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(refusal, purchase: true)) + } + } + } else if store.checkingPurchases { + BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet) + .transition(.opacity) } else { BadgesSupportSimplexView(showsAsSheet: showsAsSheet) .transition(.opacity) } } .animation(.default, value: shownBadge != nil) + .animation(.default, value: store.purchaseState(chatModel.currentUser?.userId)) + .animation(.default, value: store.checkingPurchases) } } diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 60c6a0ab77..6a67dad8c2 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -14,6 +14,7 @@ struct BadgesYourBadgeView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) private var dismiss @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @ObservedObject private var store = BadgeStore.shared let badgeState: BadgeState var showsAsSheet: Bool = false @@ -78,6 +79,15 @@ struct BadgesYourBadgeView: View { } } } + if store.purchaseState(chatModel.currentUser?.userId) != nil { + Section { + Text("A store purchase is being processed.") + .foregroundColor(theme.colors.secondary) + } header: { + Text("Store purchase") + .foregroundColor(theme.colors.secondary) + } + } if developerTools { Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { if let badge = chatModel.currentUser?.profile.localBadge { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d4171b89ae..05a28d21ce 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -168,6 +168,8 @@ 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */; }; 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2B30666C640032110E /* BadgesHowLongView.swift */; }; + 64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */; }; + 64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; @@ -560,6 +562,8 @@ 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = ""; }; 647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = ""; }; + 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesCheckOrderView.swift; sourceTree = ""; }; + 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPurchaseStateView.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; @@ -1219,6 +1223,8 @@ children = ( 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */, 647C9E2B30666C640032110E /* BadgesHowLongView.swift */, + 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */, + 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */, 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */, 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, @@ -1672,6 +1678,8 @@ 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */, 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */, + 64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */, + 64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */, 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 75d66ebb6f..1815a53f4f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -826,6 +826,7 @@ public enum ChatErrorType: Decodable, Hashable { public enum BadgeRedeemError: Decodable, Hashable { case invalidCode + case invalidReceipt case serviceNotConfigured case badgeActive case serviceError(serviceError: BadgeServiceErrorCode) @@ -846,6 +847,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case paymentNotEntitled case paymentPending case providerUnavailable + case providerNotConfigured case rateLimited case codeInvalid case codeUsed @@ -867,6 +869,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case .paymentNotEntitled: "payment_not_entitled" case .paymentPending: "payment_pending" case .providerUnavailable: "provider_unavailable" + case .providerNotConfigured: "provider_not_configured" case .rateLimited: "rate_limited" case .codeInvalid: "code_invalid" case .codeUsed: "code_used" @@ -890,6 +893,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case "payment_not_entitled": self = .paymentNotEntitled case "payment_pending": self = .paymentPending case "provider_unavailable": self = .providerUnavailable + case "provider_not_configured": self = .providerNotConfigured case "rate_limited": self = .rateLimited case "code_invalid": self = .codeInvalid case "code_used": self = .codeUsed @@ -911,8 +915,13 @@ public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? { case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") case .unknownPurchaseKey: NSLocalizedString("The badge service does not recognize this badge.", comment: "alert message") case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message") - case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable, - .paymentNotEntitled, .paymentPending, .providerUnavailable, .receiptInvalid, .receiptUsed, .unknown: nil + case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message") + case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message") + case .paymentPending: NSLocalizedString("The payment has not been confirmed yet.", comment: "alert message") + case .providerUnavailable: NSLocalizedString("The store could not be reached.", comment: "alert message") + case .providerNotConfigured: NSLocalizedString("Badges cannot be bought in the app yet.", comment: "alert message") + case .productUnavailable: NSLocalizedString("This badge is not on sale.", comment: "alert message") + case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .paymentNotEntitled, .unknown: nil } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index cf520a65c7..9e6ff84b96 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -368,6 +368,16 @@ public enum BadgeIssueFailure: Decodable, Hashable { } } + public var purchaseText: String { + switch self { + case let .serviceError(code, _): + badgeServiceErrorText(code) + ?? String.localizedStringWithFormat(NSLocalizedString("The badge service refused the purchase: %@", comment: "badge purchase error"), code.text) + case .invalidCredential: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") + case .serviceTimeout, .network, .unexpected: text + } + } + // the stored form, for support public var tag: String { switch self { diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index 1d8fc5e151..0cad378da6 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -5,6 +5,7 @@ import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} @@ -22,3 +23,11 @@ suspend fun loadBadgeProducts(oneTimeIds: List, subscriptio @Suppress("UNUSED_PARAMETER") suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + +@Suppress("UNUSED_PARAMETER") +suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} + +@Suppress("UNUSED_PARAMETER") +suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {} + +suspend fun unfinishedBadgePurchases(): List = emptyList() diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 4075421e52..85b9da1316 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -5,6 +5,7 @@ import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry import chat.simplex.common.platform.mainActivity import chat.simplex.common.views.badges.* +import chat.simplex.common.views.helpers.withLongRunningApi import com.android.billingclient.api.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -40,7 +41,7 @@ fun loadPlayStoreCountry() { // One long-lived client for badges: ProductDetails obtained from it are passed back to it when the // purchase is launched, and the purchase result arrives on its listener rather than as a return value. -// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher +// volatile: written by the purchase on a background dispatcher, read by the listener on another thread @Volatile private var badgeBillingClient: BillingClient? = null @Volatile private var badgeOffers: Map = emptyMap() @Volatile private var badgePurchase: CompletableDeferred? = null @@ -80,8 +81,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable val client = connectedBadgeBillingClient() val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId) - val details = offer.details - val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details) + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(offer.details) offer.offerToken?.let { productParams.setOfferToken(it) } val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productParams.build())) @@ -94,19 +94,42 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc if (launched.responseCode != BillingClient.BillingResponseCode.OK) { throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) } - val outcome = purchase.await() - if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt) - return outcome + return purchase.await() } finally { badgePurchase = null } } +// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment +suspend fun unfinishedBadgePurchases(): List { + val client = connectedBadgeBillingClient() + val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build() + val queried = CompletableDeferred>() + client.queryPurchasesAsync(params) { result, purchases -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queried.complete(purchases) + } else { + queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + } + } + return queried.await().mapNotNull(::badgePurchaseOutcome) +} + private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> - val pending = badgePurchase ?: return@PurchasesUpdatedListener + val pending = badgePurchase + if (pending == null) { + // settled outside a purchase call, such as a slow payment completing + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } } + } + return@PurchasesUpdatedListener + } when { - result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> - pending.complete(badgePurchaseOutcome(purchases)) + result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> { + val outcomes = purchases.mapNotNull(::badgePurchaseOutcome) + pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled) + withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } } + } result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> pending.complete(BadgePurchaseOutcome.Cancelled) else -> @@ -114,17 +137,17 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p } } -private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcome { - val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled - if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending - return BadgePurchaseOutcome.Purchased( +private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) { + Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId) + Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = purchase.purchaseToken, productId = purchase.products.firstOrNull() ?: "", - orderId = purchase.orderId, - invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId + invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId, + acknowledged = purchase.isAcknowledged ) ) + else -> null } private suspend fun connectedBadgeBillingClient(): BillingClient { @@ -203,21 +226,34 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { return BadgeOffer(id, product, this, offer.offerToken) } -// nothing is delivered in this build, so the purchase is finished right away; once the service issues -// credentials it must only be finished after the credential is stored. One-time products are consumed -// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged -// purchase after 3 days. -private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) { - val done = CompletableDeferred() - if (details.productType == BillingClient.ProductType.SUBS) { - val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() - client.acknowledgePurchase(params) { done.complete(it) } - } else { +// acknowledged without consuming, so Play stops its 3-day refund clock and still lists the purchase until it is finished +suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) { + if (!receipt.acknowledged) acknowledge(connectedBadgeBillingClient(), receipt.token) +} + +// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase +// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase +suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) { + val client = connectedBadgeBillingClient() + if (receipt.productId in badgeOneTimeProductIds) { + val done = CompletableDeferred() val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() client.consumeAsync(params) { result, _ -> done.complete(result) } - } - val result = done.await() - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}") + checkBillingResult(done.await()) + } else { + acknowledge(client, receipt.token) + } +} + +private suspend fun acknowledge(client: BillingClient, token: String) { + val done = CompletableDeferred() + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build() + client.acknowledgePurchase(params) { done.complete(it) } + checkBillingResult(done.await()) +} + +private fun checkBillingResult(result: BillingResult) { + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 1a4c836b2e..9093b1427b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -28,7 +28,9 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStore import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt import chat.simplex.common.views.call.* import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* @@ -113,6 +115,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } }.onFailure { Log.e(TAG, it.stackTraceToString()) } } + BadgeStore.presentUnfinished() } } Lifecycle.Event.ON_RESUME -> { @@ -352,6 +355,12 @@ class SimplexApp: Application(), LifecycleEventObserver { override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) + override suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) = acknowledgeBadgePurchase(receipt) + + override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt) + + override suspend fun androidUnfinishedBadgePurchases(): List = unfinishedBadgePurchases() + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 9d65606a9c..6a345bb612 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2271,6 +2271,12 @@ sealed class BadgeIssueFailure { is Unexpected -> String.format(generalGetString(MR.strings.badges_error_unexpected), message) } + val purchaseText: String get() = when (this) { + is ServiceError -> badgeServiceErrorText(code) ?: String.format(generalGetString(MR.strings.badges_error_purchase_refused), code.text) + is InvalidCredential -> generalGetString(MR.strings.badges_error_credential_not_verified) + is ServiceTimeout, is Network, is Unexpected -> text + } + // the stored form, for support val tag: String get() = when (this) { is ServiceError -> "serviceError ${if (retryable) "retry" else "final"} ${code.text}" 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 9100b5ecea..cf453d7f88 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 @@ -27,6 +27,8 @@ import chat.simplex.common.model.SMPProxyMode.Always import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgeStore +import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert @@ -587,13 +589,38 @@ object ChatController { } } + // log = false because a store receipt is a bearer secret, like a badge code - it is in the command. + suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment): BadgePurchaseResult { + val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment), log = false) + return when { + r is API.Result && r.res is CR.BadgeStateR -> BadgePurchaseResult.Held(r.res.user, r.res.badgeState, r.res.storePurchases) + r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Credited(r.res.user.updateRemoteHostId(rh), r.res.badgeState) + r is API.Error -> BadgePurchaseResult.Failed(r.err) + else -> { + // the response type alone - it names a case or a JSON key, never the service's message + Log.e(TAG, "apiPurchaseBadge: unexpected ${r.responseType}") + BadgePurchaseResult.Failed(null) + } + } + } + + suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String { + val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId)) + if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId + if (r is API.Error) throw BadgeStoreError.ApiError(r.err) + throw Exception("apiCreateBadgeInvoice: unexpected ${r.responseType}") + } + // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened - fun redeemErrorText(err: ChatError?): String { + fun redeemErrorText(err: ChatError?, purchase: Boolean): String { if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) { when (val e = err.errorType.badgeRedeemError) { is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code) - is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured) - is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active) + is BadgeRedeemError.InvalidReceipt -> {} + is BadgeRedeemError.ServiceNotConfigured -> + return generalGetString(if (purchase) MR.strings.badges_error_service_not_configured_purchase else MR.strings.badges_error_service_not_configured) + is BadgeRedeemError.BadgeActive -> + return generalGetString(if (purchase) MR.strings.badges_error_already_active_purchase else MR.strings.badges_error_already_active) is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it } is BadgeRedeemError.InvalidResponse -> return String.format(generalGetString(MR.strings.badges_error_bad_service_response), e.message) is BadgeRedeemError.UnknownKeyIndex, is BadgeRedeemError.CredentialNotVerified -> return generalGetString(MR.strings.badges_error_credential_not_verified) @@ -602,9 +629,9 @@ object ChatController { return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}" } - suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? { + suspend fun apiGetBadgeState(rh: Long?, userId: Long): Pair> { val r = sendCmd(rh, CC.ApiGetBadgeState(userId)) - if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState + if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState to r.res.storePurchases throw Exception("apiGetBadgeState: unexpected ${r.responseType}") } @@ -637,11 +664,14 @@ object ChatController { // Not thrown: a failed badge read must not stop the app starting, and the model is left alone // rather than set to nil, which would read as "no badge". - private suspend fun loadBadgeState(rhId: Long?) { + suspend fun loadBadgeState(rhId: Long?) { try { val userId = currentUserId("loadBadgeState") - val badgeState = apiGetBadgeState(rhId, userId) - withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) } + val (badgeState, storePurchases) = apiGetBadgeState(rhId, userId) + withContext(Dispatchers.Main) { + BadgeModel.set(rhId, userId, badgeState) + BadgeStore.setStorePurchases(rhId, userId, storePurchases) + } } catch (e: Exception) { Log.e(TAG, "loadBadgeState: ${e.message}") } @@ -686,6 +716,7 @@ object ChatController { } apiStartChat() appPrefs.chatStopped.set(false) + withLongRunningApi { BadgeStore.presentUnfinished() } } catch (e: Throwable) { Log.e(TAG, "failed starting chat $e") throw e @@ -751,6 +782,7 @@ object ChatController { chatModel.users.clear() chatModel.users.addAll(users) getUserChatData(rhId, keepingChatId = keepingChatId) + withLongRunningApi { BadgeStore.presentUnfinished() } val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId } if (invitation != null && currentUser != null) { chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser)) @@ -3581,6 +3613,9 @@ object ChatController { BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert) } } + is CR.StorePurchaseResolved -> + // whichever profile owns it: only the app can finish the store purchase + withLongRunningApi { BadgeStore.presentUnfinished() } else -> Log.d(TAG , "unsupported event: ${msg.responseType}") } @@ -3622,7 +3657,7 @@ object ChatController { getUserChatData(null) } - private fun activeUser(rhId: Long?, user: UserLike): Boolean = + fun activeUser(rhId: Long?, user: UserLike): Boolean = rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { @@ -3884,6 +3919,17 @@ sealed class BadgeRedeemResult { class Failed(val err: ChatError?): BadgeRedeemResult() } +@Serializable +data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null, val creditError: BadgeIssueFailure? = null) + +// a refusal is Failed, with the code in BREServiceError +sealed class BadgePurchaseResult { + class Held(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List): BadgePurchaseResult() + class Credited(val user: User, val badgeState: BadgeState?): BadgePurchaseResult() + // err is null for a response of an unexpected type, which is logged where it is received + class Failed(val err: ChatError?): BadgePurchaseResult() +} + // ChatCommand // Spec: spec/api.md#CC sealed class CC { @@ -4064,6 +4110,8 @@ sealed class CC { class ApiStandaloneFileInfo(val url: String): CC() // badges class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() + class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC() + class ApiCreateBadgeInvoice(val userId: Long): CC() class ApiGetBadgeState(val userId: Long): CC() class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC() class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() @@ -4289,6 +4337,8 @@ sealed class CC { is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" is ApiStandaloneFileInfo -> "/_download info $url" is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" + is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}" + is ApiCreateBadgeInvoice -> "/_badge invoice $userId" is ApiGetBadgeState -> "/_badge state $userId" is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId" is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" @@ -4473,6 +4523,8 @@ sealed class CC { is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" + is ApiPurchaseBadge -> "apiPurchaseBadge" + is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice" is ApiGetBadgeState -> "apiGetBadgeState" is ApiGetBadgeLedger -> "apiGetBadgeLedger" is ApiAckBadgeAlert -> "apiAckBadgeAlert" @@ -4497,8 +4549,11 @@ sealed class CC { is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) - // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code)) + is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) { + is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token)) + }) else -> this } @@ -4532,6 +4587,12 @@ sealed class CC { } } +// the field must stay typed as the sealed base, or kotlinx omits the "type" tag core parses by +@Serializable +sealed class ServicePayment { + @Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment() +} + fun onOff(b: Boolean): String = if (b) "on" else "off" // /_badge ack takes the kind in core's text encoding, not the JSON tag @@ -6865,10 +6926,12 @@ sealed class CR { // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR() - @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List = emptyList()): CR() @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() + @Serializable @SerialName("storePurchaseResolved") class StorePurchaseResolved(val user: UserRef): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -7056,10 +7119,12 @@ sealed class CR { is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" is BadgeRedeemed -> "badgeRedeemed" + is BadgeInvoice -> "badgeInvoice" is BadgeStateR -> "badgeState" is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" is BadgeAlertR -> "badgeAlert" + is StorePurchaseResolved -> "storePurchaseResolved" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7264,10 +7329,12 @@ sealed class CR { is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") - is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) + is BadgeInvoice -> withUser(user, invoiceId) + is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}") is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) + is StorePurchaseResolved -> withUser(user, noDetails()) is Response -> json is Invalid -> str } @@ -7323,6 +7390,7 @@ sealed class SimplexDomainError { sealed class BadgeRedeemError { val string: String get() = when (this) { is InvalidCode -> "invalidCode" + is InvalidReceipt -> "invalidReceipt" is ServiceNotConfigured -> "serviceNotConfigured" is BadgeActive -> "badgeActive" is ServiceError -> "serviceError ${serviceError.text}" @@ -7331,6 +7399,7 @@ sealed class BadgeRedeemError { is CredentialNotVerified -> "credentialNotVerified" } @Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError() + @Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError() @Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError() @Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError() @Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError() @@ -7352,6 +7421,7 @@ sealed class BadgeServiceErrorCode { object PaymentNotEntitled: BadgeServiceErrorCode() object PaymentPending: BadgeServiceErrorCode() object ProviderUnavailable: BadgeServiceErrorCode() + object ProviderNotConfigured: BadgeServiceErrorCode() object RateLimited: BadgeServiceErrorCode() object CodeInvalid: BadgeServiceErrorCode() object CodeUsed: BadgeServiceErrorCode() @@ -7373,6 +7443,7 @@ sealed class BadgeServiceErrorCode { is PaymentNotEntitled -> "payment_not_entitled" is PaymentPending -> "payment_pending" is ProviderUnavailable -> "provider_unavailable" + is ProviderNotConfigured -> "provider_not_configured" is RateLimited -> "rate_limited" is CodeInvalid -> "code_invalid" is CodeUsed -> "code_used" @@ -7392,6 +7463,12 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) { is BadgeServiceErrorCode.UnsupportedVersion -> generalGetString(MR.strings.badges_error_unsupported_version) is BadgeServiceErrorCode.UnknownPurchaseKey -> generalGetString(MR.strings.badges_error_unknown_purchase) is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal) + is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid) + is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used) + is BadgeServiceErrorCode.PaymentPending -> generalGetString(MR.strings.badges_error_payment_pending) + is BadgeServiceErrorCode.ProviderUnavailable -> generalGetString(MR.strings.badges_error_provider_unavailable) + is BadgeServiceErrorCode.ProviderNotConfigured -> generalGetString(MR.strings.badges_error_provider_not_configured) + is BadgeServiceErrorCode.ProductUnavailable -> generalGetString(MR.strings.badges_error_product_unavailable) else -> null } @@ -7409,6 +7486,7 @@ object BadgeServiceErrorCodeSerializer : KSerializer { "payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled "payment_pending" -> BadgeServiceErrorCode.PaymentPending "provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable + "provider_not_configured" -> BadgeServiceErrorCode.ProviderNotConfigured "rate_limited" -> BadgeServiceErrorCode.RateLimited "code_invalid" -> BadgeServiceErrorCode.CodeInvalid "code_used" -> BadgeServiceErrorCode.CodeUsed diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 7d126096e8..f4e902b967 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -13,6 +13,7 @@ import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt import kotlinx.coroutines.Job import java.io.Closeable @@ -44,6 +45,9 @@ interface PlatformInterface { return emptyList() } suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} + suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {} + suspend fun androidUnfinishedBadgePurchases(): List = emptyList() val androidApiLevel: Int? get() = null // The build distributed via Google Play, which has to follow its policies val androidIsPlayStoreBuild: Boolean get() = false 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 2c7dfd05f6..ae6ecd3876 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 @@ -1,7 +1,16 @@ package chat.simplex.common.views.badges +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.withContext import java.text.NumberFormat import java.util.Currency import java.util.UUID @@ -29,15 +38,33 @@ val badgeStoreProductIds: List = BadgeLevel.entries.flatMap BadgePeriod.entries.map { badgeStoreProductId(level, it) } } -// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. -// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service -// learns which invoice a store transaction settles. +// the only products sent to the badge service: nothing delivers a subscription yet +val badgeOneTimeProductIds: Set = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet() + +// subscriptions are for sale once renewals are delivered +val badgePeriodsForSale: List = listOf(BadgePeriod.OneMonth) + +// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. +// Sent to Play as obfuscatedAccountId and echoed back on the purchase. fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() // the page's app flag rides in the fragment, which never reaches the service +// TEST ONLY: pointed at the dev deployment; restore the two lines below before merging val badgePageUrl: String = - if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true" - else "https://badges.simplex.chat/#/tier?app=desktop" + if (appPlatform.isAndroid) "https://smp7.simplex.im/#/tier?app=true" + else "https://smp7.simplex.im/#/tier?app=desktop" +// if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true" +// else "https://badges.simplex.chat/#/tier?app=desktop" + +// where the store allows a link out to the badge page: the US for now, a set expected to widen. +// An unknown country does not count, as this decides whether the store sees a link out of the app. +@Composable +fun badgeBrowserAllowed(): Boolean { + if (androidPlayStoreCountry.value == null) { + LaunchedEffect(Unit) { platform.androidLoadPlayStoreCountry() } + } + return androidPlayStoreCountry.value == "US" +} // what the platform store knows about one product; ProductDetails cannot cross into commonMain data class BadgeProduct( @@ -63,10 +90,8 @@ data class BadgeStoreReceipt( // the token the badge service verifies with the Publisher API val token: String, val productId: String, - val orderId: String?, val invoiceId: String?, - // only set for test products - a real Play purchase has no environment to report - val environment: String? = null + val acknowledged: Boolean = false ) // TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens @@ -74,6 +99,9 @@ data class BadgeStoreReceipt( // receipt says so. const val useBadgeTestProducts = false +// test products stand in for a store, so the lane is offered without one +val badgeStoreAvailable: Boolean get() = useBadgeTestProducts || platform.androidHasPlatformStore + private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) = BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD") @@ -88,20 +116,29 @@ private val testBadgeProducts: List = listOf( sealed class BadgePurchaseOutcome { class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome() - object Pending: BadgePurchaseOutcome() + class Pending(val invoiceId: String?): BadgePurchaseOutcome() object Cancelled: BadgePurchaseOutcome() } +enum class BadgePurchaseState { + Issuing, + WaitingForApproval +} + sealed class BadgeStoreError: Exception() { class ProductUnavailable(val productId: String): BadgeStoreError() class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() object StoreUnavailable: BadgeStoreError() + object NoActiveProfile: BadgeStoreError() + class ApiError(val err: ChatError): BadgeStoreError() override val message: String get() = when (this) { is ProductUnavailable -> "productUnavailable(productId: $productId)" is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" is StoreUnavailable -> "storeUnavailable" + is NoActiveProfile -> "noActiveProfile" + is ApiError -> "apiError(${err.string})" } } @@ -111,12 +148,49 @@ object BadgeStore { private val state = mutableStateOf(LoadState.NotLoaded) // snapshot state so a composable reading only the products still recomposes when they arrive private val products = mutableStateOf>(emptyMap()) + // core's open store purchases for the profile they were read for: core knows whose a purchase is + private val storePurchases = mutableStateOf>?>(null) + // whether a store sheet this run opened has not returned + private val buying = mutableStateOf(false) + // by invoice id, so a pending purchase shows only under the profile whose record it names + private val waitingForApproval = mutableStateOf>(emptySet()) + // set once presentUnfinished has read the store, or failed to: until then, a slow payment completed while + // the app was closed, or a purchase it died before handing over, are both unknown, so canBuy refuses + private val reconciledOnce = mutableStateOf(false) + val refusals = MutableSharedFlow() - fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) { + fun purchaseState(userId: Long?): BadgePurchaseState? { + if (!badgeStoreAvailable) return null + val purchases = openStorePurchases(userId) + return when { + purchases.any { it.transactionRef != null } -> BadgePurchaseState.Issuing + purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval + else -> null + } + } + + fun creditError(userId: Long?): BadgeIssueFailure? = + openStorePurchases(userId).firstNotNullOfOrNull { it.creditError } + + val checkingPurchases: Boolean get() = badgeStoreAvailable && !reconciledOnce.value + + fun canBuy(userId: Long?): Boolean = + badgeStoreAvailable && reconciledOnce.value && !buying.value && purchaseState(userId) == null + + fun setStorePurchases(rhId: Long?, userId: Long, purchases: List) { + storePurchases.value = Triple(rhId, userId, purchases) + } + + private fun openStorePurchases(userId: Long?): List { + val (rhId, readFor, purchases) = storePurchases.value ?: return emptyList() + return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList() + } + + fun price(level: BadgeLevel, period: BadgePeriod, compact: Boolean = true): BadgePrice = when (state.value) { LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading LoadState.Loaded, LoadState.Failed -> { val p = products.value[badgeStoreProductId(level, period)] - if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable + if (p != null) BadgePrice.Price(if (compact) compactPrice(p) else p.displayPrice) else BadgePrice.Unavailable } } @@ -151,21 +225,146 @@ object BadgeStore { } } - suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { + // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + suspend fun purchase(level: BadgeLevel, period: BadgePeriod): BadgePurchaseOutcome { val id = badgeStoreProductId(level, period) if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) - if (useBadgeTestProducts) { - return BadgePurchaseOutcome.Purchased( + // a subscription is never sent to core, so nothing would ever finish it later + if (id.productId !in badgeOneTimeProductIds) { + val outcome = storePurchase(id, newBadgeInvoiceId()) + if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt) + return outcome + } + val rhId = chatModel.remoteHostId() + val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile + val invoiceId = chatModel.controller.apiCreateBadgeInvoice(rhId, userId) + withContext(Dispatchers.Main) { buying.value = true } + chatModel.controller.loadBadgeState(rhId) + try { + val outcome = storePurchase(id, invoiceId) + if (outcome is BadgePurchaseOutcome.Purchased) { + handOver(outcome.receipt) + } + return outcome + } finally { + withContext(Dispatchers.Main + NonCancellable) { buying.value = false } + } + } + + private suspend fun storePurchase(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome { + val outcome = if (useBadgeTestProducts) { + BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = "test-${UUID.randomUUID()}", productId = id.productId, - orderId = null, - invoiceId = invoiceId, - environment = "test products" + invoiceId = invoiceId ) ) + } else { + platform.androidPurchaseBadge(id, invoiceId) + } + if (outcome is BadgePurchaseOutcome.Pending && outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } + return outcome + } + + // Core holds the receipt and credits it; the purchase stays unfinished until core answers it credited + // or refused, as an unfinished purchase is what the store re-delivers if anything is lost on the way. + private suspend fun handOver(receipt: BadgeStoreReceipt) { + val rhId = chatModel.remoteHostId() + val userId = chatModel.currentUser.value?.userId ?: return + when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token))) { + is BadgePurchaseResult.Held -> { + // core holds it durably now, so Play must not refund it after three days unacknowledged + acknowledge(receipt) + withContext(Dispatchers.Main) { + // the answer is the owner's, which may be another profile and a hidden one + if (chatModel.controller.activeUser(rhId, r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + setStorePurchases(rhId, r.user.userId, r.storePurchases) + } + } + } + is BadgePurchaseResult.Credited -> { + withContext(Dispatchers.Main) { + if (chatModel.controller.activeUser(rhId, r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + chatModel.updateUser(r.user) + if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true) + } + } + resolve(receipt, refusal = null) + } + // a refusal is announced where it reads as its own, so only an unexpected answer reaches the buyer + is BadgePurchaseResult.Failed -> + if (badgeReceiptRefused(r.err)) { + Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}") + resolve(receipt, refusal = r.err) + } else { + throw r.err?.let(BadgeStoreError::ApiError) ?: Exception("apiPurchaseBadge: unexpected response") + } + } + } + + private suspend fun resolve(receipt: BadgeStoreReceipt, refusal: ChatError?) { + finish(receipt) + if (refusal != null) { + withContext(Dispatchers.Main) { refusals.emit(refusal) } + } + } + + // at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer + suspend fun presentUnfinished() { + try { + if (!useBadgeTestProducts && platform.androidHasPlatformStore) { + try { + val purchases = platform.androidUnfinishedBadgePurchases() + // Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here + withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() } + purchases.forEach { reconcile(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}") + } + } + chatModel.controller.loadBadgeState(chatModel.remoteHostId()) + } finally { + withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true } + } + } + + suspend fun reconcile(outcome: BadgePurchaseOutcome) { + when (outcome) { + is BadgePurchaseOutcome.Purchased -> + if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt) + // no buyer to tell: the purchase stays unfinished and the next sweep hands it over again + else try { + handOver(outcome.receipt) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}") + } + is BadgePurchaseOutcome.Pending -> + if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } + is BadgePurchaseOutcome.Cancelled -> {} + } + } + + private suspend fun acknowledge(receipt: BadgeStoreReceipt) { + try { + if (!useBadgeTestProducts) platform.androidAcknowledgeBadgePurchase(receipt) + } catch (e: Exception) { + // the next sweep hands the purchase over again, and acknowledges it then + Log.e(TAG, "BadgeStore.acknowledge: ${e.message}") + } + } + + private suspend fun finish(receipt: BadgeStoreReceipt) { + try { + if (!useBadgeTestProducts) platform.androidFinishBadgePurchase(receipt) + } catch (e: Exception) { + // still unfinished: the store re-delivers it, and the next answer for it finishes it + Log.e(TAG, "BadgeStore.finish: ${e.message}") } - return platform.androidPurchaseBadge(id, invoiceId) } private fun startLoading(): Boolean = when (state.value) { @@ -190,3 +389,10 @@ private fun compactPrice(product: BadgeProduct): String { product.displayPrice } } + +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it +private fun badgeReceiptRefused(err: ChatError?): Boolean { + val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError + val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError + return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt index 03fabf817e..53c9f98f60 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt @@ -19,7 +19,7 @@ import chat.simplex.res.MR import kotlinx.datetime.Instant @Composable -fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { +fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier) { val user = chatModel.currentUser.value val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname) val previewBadge = LocalBadge( @@ -29,15 +29,12 @@ fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: ) Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { ProfileImage(size = 128.dp, image = user?.image) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - NameWithBadge( - name = displayName, - badge = previewBadge, - style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - trailing() - } + NameWithBadge( + name = displayName, + badge = previewBadge, + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt new file mode 100644 index 0000000000..90f459a520 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -0,0 +1,107 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +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 chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.res.MR + +@Composable +fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { + val purchasing = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) + + 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_check_your_order_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Column( + Modifier + .fillMaxWidth() + .padding(top = 20.dp) + .clip(RoundedCornerShape(16.dp)) + .background(sectionCardColor()) + .padding(horizontal = 24.dp, vertical = 12.dp) + ) { + OrderRow(MR.strings.badges_your_badge, stringResource(level.title)) + OrderRow(MR.strings.badges_order_duration, stringResource(period.label)) + Divider(Modifier.padding(top = 8.dp, bottom = 6.dp)) + OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period, compact = false))) + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PayButton(level, period, purchasing) + BadgeBillingFooter(period) + } + } +} + +@Composable +private fun OrderRow(title: StringResource, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary) + Spacer(Modifier.weight(1f)) + Text(value, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun PayButton(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState) { + val price = BadgeStore.price(level, period, compact = false) + val (labelId, labelArg) = period.payLabel(price) + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = labelId, + labelArg = labelArg, + onboarding = null, + enabled = price.canPurchase && !purchasing.value && BadgeStore.canBuy(chatModel.currentUser.value?.userId), + onclick = { purchase(level, period, purchasing) } + ) +} + +private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState) { + purchasing.value = true + // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls + withLongRunningApi { + try { + BadgeStore.purchase(level, period) + purchasing.value = false + } catch (e: Exception) { + Log.e(TAG, "BadgesCheckOrderView.purchase: ${e.stackTraceToString()}") + purchasing.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) + else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt index b8324cc9c9..0fac45ac81 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt @@ -34,10 +34,16 @@ enum class BadgeLevel { Legend -> MR.strings.badges_level_legend } - val filesDescription: StringResource + val fileSize: StringResource get() = when (this) { - Supporter -> MR.strings.badges_level_supporter_files - Legend -> MR.strings.badges_level_legend_files + Supporter -> MR.strings.badges_level_supporter_file_size + Legend -> MR.strings.badges_level_legend_file_size + } + + val fileStorage: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_file_storage + Legend -> MR.strings.badges_level_legend_file_storage } val summary: StringResource @@ -54,10 +60,11 @@ enum class BadgeLevel { } @Composable -fun BadgesChooseBadgeView(modalManager: ModalManager) { +fun BadgesChooseBadgeView(modalManager: ModalManager, unwindToDepth: Int) { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -82,15 +89,9 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) { modifier = Modifier.fillMaxWidth() ) - BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) { - Icon( - painterResource(MR.images.ic_keyboard_arrow_down), - contentDescription = null, - tint = MaterialTheme.colors.primary - ) - } + BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 12.dp)) // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height // when 2-line labels at large fonts would otherwise size them differently. @@ -102,17 +103,18 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) { LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } } - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 12.dp)) // Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under // the action button (matches onboarding pattern where its own 7.5dp top padding is the gap). Column(horizontalAlignment = Alignment.CenterHorizontally) { - ContinueButton(selectedLevel, modalManager) - TextButtonBelowOnboardingButton( - text = stringResource(MR.strings.badges_how_it_works_button), - icon = painterResource(MR.images.ic_info), - onClick = { modalManager.showModal { BadgesHowItWorksView() } } - ) + ContinueButton(selectedLevel, modalManager, unwindToDepth) + // redeeming a code is here only when Support SimpleX offers the browser instead + if (badgeBrowserAllowed()) { + RedeemCodeButton(modalManager, unwindToDepth) + } else { + TextButtonBelowOnboardingButton("", null) + } } } } @@ -131,7 +133,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo .background(cardBackground, shape) .border(2.dp, borderColor, shape) .clickable { onSelect(level) } - .padding(vertical = 20.dp, horizontal = 12.dp), + .padding(vertical = 16.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp) ) { @@ -139,22 +141,25 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo painterResource(badgeImage(level.badgeType)), contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.size(60.dp) + modifier = Modifier.size(44.dp) ) Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(level.fileSize), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Text(stringResource(level.fileStorage), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } } } @Composable -private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager) { +private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), labelId = MR.strings.badges_continue, onboarding = null, onclick = { - modalManager.showModal { BadgesHowLongView(selectedLevel) } + modalManager.showModal { BadgesHowLongView(selectedLevel, modalManager, unwindToDepth) } } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index 2845e3986a..0dfc3ac742 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -9,10 +9,9 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -45,6 +44,13 @@ enum class BadgePeriod { Annual -> MR.strings.badges_period_annual } + val months: Int + get() = when (this) { + OneMonth -> 1 + Monthly -> 1 + Annual -> 12 + } + @Composable fun priceText(price: BadgePrice): String = when (price) { is BadgePrice.Loading -> "…" @@ -68,12 +74,11 @@ enum class BadgePeriod { } @Composable -fun BadgesHowLongView(level: BadgeLevel) { - var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } - val purchasing = remember { mutableStateOf(false) } - val clipboard = LocalClipboardManager.current +fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) { + var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) } LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -102,39 +107,46 @@ fun BadgesHowLongView(level: BadgeLevel) { Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - + // IntrinsicSize.Max + fillMaxHeight on children so the cards all match the tallest one - // only Annual carries a savings line, and prices wrap at large fonts. Row( Modifier.fillMaxWidth().height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + BadgePeriod.entries.forEach { period -> + PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + } } Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a - // TextButton so the footer has no hover/click affordance. Column(horizontalAlignment = Alignment.CenterHorizontally) { - PayButton(level, selectedPeriod, purchasing, clipboard) - Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), - Modifier.padding(vertical = 5.dp), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.secondary, - textAlign = TextAlign.Center - ) - } + ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) + BadgeBillingFooter(selectedPeriod) } } } +// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a +// TextButton so the footer has no hover/click affordance. +@Composable +fun BadgeBillingFooter(period: BadgePeriod) { + Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + stringResource(billingFooter(period)).format(billingDate(period)), + Modifier.padding(vertical = 5.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } +} + @Composable private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { val isSelected = period == selectedPeriod + val forSale = period in badgePeriodsForSale + val textColor = if (forSale) Color.Unspecified else MaterialTheme.colors.secondary val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) @@ -145,7 +157,7 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B .clip(shape) .background(cardBackground, shape) .border(2.dp, borderColor, shape) - .clickable { onSelect(period) } + .clickable(enabled = forSale) { onSelect(period) } .padding(vertical = 20.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) @@ -154,10 +166,10 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B painterResource(period.icon), contentDescription = null, tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp).alpha(if (forSale) 1f else 0.4f) ) - Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Text(stringResource(period.label), style = MaterialTheme.typography.body2, color = textColor, textAlign = TextAlign.Center) + Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, fontWeight = FontWeight.SemiBold, color = textColor, textAlign = TextAlign.Center) val percent = savingsPercent(level, period) if (percent != null) { Text( @@ -173,76 +185,15 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null -// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and -// this button stays disabled - it will offer Stripe/crypto payment instead @Composable -private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { - val price = BadgeStore.price(level, selectedPeriod) - val (labelId, labelArg) = selectedPeriod.payLabel(price) +private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = labelId, - labelArg = labelArg, + labelId = MR.strings.badges_continue, onboarding = null, - enabled = price.canPurchase && !purchasing.value, - onclick = { purchase(level, selectedPeriod, purchasing, clipboard) } - ) -} - -private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { - val invoiceId = newBadgeInvoiceId() - purchasing.value = true - // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls - withLongRunningApi { - try { - val outcome = BadgeStore.purchase(level, period, invoiceId) - purchasing.value = false - when (outcome) { - is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard) - is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_pending), - text = generalGetString(MR.strings.badges_purchase_pending_desc) - ) - is BadgePurchaseOutcome.Cancelled -> {} - } - } catch (e: Exception) { - Log.e(TAG, "BadgesHowLongView.purchase: ${e.stackTraceToString()}") - purchasing.value = false - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_error), - text = e.toString() - ) + onclick = { + modalManager.showModal(cardScreen = true) { BadgesCheckOrderView(level, selectedPeriod, modalManager, unwindToDepth) } } - } -} - -// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. -private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) { - val returnedInvoice = when (receipt.invoiceId) { - null -> "none" - invoiceId -> "yes" - else -> "mismatch: ${receipt.invoiceId}" - } - val lines = mutableListOf( - "Product: ${receipt.productId}", - "Invoice: $invoiceId", - "Invoice returned by Google: $returnedInvoice", - "Order: ${receipt.orderId ?: "none"}" - ) - if (receipt.environment != null) lines.add("Environment: ${receipt.environment}") - lines.add("Token: ${receipt.token.length} bytes") - val summary = lines.joinToString("\n") - Log.d(TAG, "badge purchase succeeded\n$summary") - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.badges_purchase_successful), - text = summary, - confirmText = "Copy token", - onConfirm = { - clipboard.setText(AnnotatedString(receipt.token)) - showToast(generalGetString(MR.strings.copied)) - }, - dismissText = generalGetString(MR.strings.ok), - parseHtml = false ) } @@ -251,9 +202,10 @@ private fun billingFooter(period: BadgePeriod): StringResource = when (period) { BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month } -// TODO [badges] source the actual date from the purchase state machine when wired. -private fun stubBillingDate(): String { - val date = java.time.LocalDate.of(2026, 7, 22) +// TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld); +// a top-up must count from the end of the existing balance +private fun billingDate(period: BadgePeriod): String { + val date = java.time.LocalDate.now().plusMonths(period.months.toLong()) val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG) return date.format(formatter) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt new file mode 100644 index 0000000000..4c808d60a6 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt @@ -0,0 +1,87 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +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.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeIssueFailure +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.MR + +@Composable +fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, failure: BadgeIssueFailure? = null, onDismiss: () -> Unit) { + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).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() + ) + + if (message != null) { + Text( + stringResource(message), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + if (failure != null) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_warning), contentDescription = null, tint = Color.Red) + Text( + failure.purchaseText, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.secondary, + 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)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TextButtonBelowOnboardingButton(stringResource(MR.strings.badges_dismiss), onDismiss) + TextButtonBelowOnboardingButton("", null) + } + } +} + +val BadgePurchaseState.title: StringResource + get() = when (this) { + BadgePurchaseState.Issuing -> MR.strings.badges_issuing_title + BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_title + } + +val BadgePurchaseState.message: StringResource + get() = when (this) { + BadgePurchaseState.Issuing -> MR.strings.badges_issuing_body + BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_body + } 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 7494229aaa..11367134f7 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 @@ -85,7 +85,7 @@ suspend fun redeemBadgeCode(rhId: Long?, user: User, code: String): BadgeRedeemO } is BadgeRedeemResult.Failed -> { Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") - BadgeRedeemOutcome.Refused(chatModel.controller.redeemErrorText(result.err)) + BadgeRedeemOutcome.Refused(chatModel.controller.redeemErrorText(result.err, purchase = false)) } } @@ -94,11 +94,12 @@ fun showCannotRedeemAlert(message: String) { } @Composable -fun BadgesRedeemCodeView(modalManager: ModalManager) { +fun BadgesRedeemCodeView(modalManager: ModalManager, unwindToDepth: Int) { val rhId = remember { chatModel.remoteHostId() } val code = remember { mutableStateOf(TextFieldValue("")) } val canonicalCode = remember { mutableStateOf(null) } val submitting = remember { mutableStateOf(false) } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) // when the text is unchanged, the field's own value is kept: it carries the cursor position and the // keyboard's composition state, which BasicTextField loses unless they are passed back to it @@ -117,7 +118,8 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) { withContext(Dispatchers.Main) { submitting.value = false when (outcome) { - is BadgeRedeemOutcome.Redeemed -> modalManager.closeModal() + // a shown badge closes this screen itself, and a second close would take the screen beneath + is BadgeRedeemOutcome.Redeemed -> if (currentShownBadge() == null) modalManager.closeModal() is BadgeRedeemOutcome.Refused -> showCannotRedeemAlert(outcome.message) is BadgeRedeemOutcome.Cancelled -> {} } @@ -382,7 +384,8 @@ private fun Confirming(onConfirm: () -> Unit, onCancel: () -> Unit) { // 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) { + LinkStep(MR.strings.badges_issuing_title, textButton = MR.strings.badges_dismiss to onDismiss) { + LinkText(stringResource(MR.strings.badges_link_issuing_profile)) Spacer(Modifier.weight(1f)) CircularProgressIndicator( Modifier.size(30.dp), 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 dc65897547..aa2144ff59 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 @@ -32,7 +32,7 @@ import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton import chat.simplex.res.MR @Composable -fun BadgesSupportSimplexView(modalManager: ModalManager) { +fun BadgesSupportSimplexView(modalManager: ModalManager, unwindToDepth: Int) { ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -55,8 +55,6 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { modifier = Modifier.fillMaxWidth() ) - // TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen - // returns to the flow and HowItWorksButton() moves there, leaving this one alone here. HowItWorksButton(modalManager) Spacer(Modifier.weight(1f)) @@ -66,21 +64,30 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { Spacer(Modifier.weight(1f)) Column(horizontalAlignment = Alignment.CenterHorizontally) { - BuyInBrowserButton(modalManager) - RedeemCodeButton(modalManager) + if (badgeStoreAvailable) { + ChooseLevelButton(modalManager, unwindToDepth) + if (badgeBrowserAllowed()) { + BuyInBrowserTextButton() + } else { + RedeemCodeButton(modalManager, unwindToDepth) + } + } else { + BuyInBrowserButton(modalManager, unwindToDepth) + RedeemCodeButton(modalManager, unwindToDepth) + } } } } -// the in-app purchase path, kept compiling and uncalled until payments return after the MVP @Composable -private fun ChooseLevelButton(modalManager: ModalManager) { +private fun ChooseLevelButton(modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.badges_choose_your_level, + labelId = MR.strings.badges_choose_your_badge_title, onboarding = null, + enabled = BadgeStore.canBuy(chatModel.currentUser.value?.userId), onclick = { - modalManager.showModal { BadgesChooseBadgeView(modalManager) } + modalManager.showModal { BadgesChooseBadgeView(modalManager, unwindToDepth) } } ) } @@ -111,15 +118,24 @@ private fun HowItWorksButton(modalManager: ModalManager) { } @Composable -private fun RedeemCodeButton(modalManager: ModalManager) { +fun RedeemCodeButton(modalManager: ModalManager, unwindToDepth: Int) { TextButtonBelowOnboardingButton( text = stringResource(MR.strings.badges_redeem_code_button), - onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager) } } + onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager, unwindToDepth) } } ) } @Composable -private fun BuyInBrowserButton(modalManager: ModalManager) { +private fun BuyInBrowserTextButton() { + val uriHandler = LocalUriHandler.current + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_buy_in_browser), + onClick = { uriHandler.openUriCatching(badgePageUrl) } + ) +} + +@Composable +private fun BuyInBrowserButton(modalManager: ModalManager, unwindToDepth: Int) { val uriHandler = LocalUriHandler.current OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), @@ -129,7 +145,7 @@ private fun BuyInBrowserButton(modalManager: ModalManager) { 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) } + modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) } } } ) 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 ff2660497e..d631735e3e 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 @@ -2,33 +2,70 @@ package chat.simplex.common.views.badges import androidx.compose.animation.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import chat.simplex.common.model.BadgeModel import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR @OptIn(ExperimentalAnimationApi::class) @Composable fun BadgesView(modalManager: ModalManager, close: () -> Unit) { - val shownBadge: BadgeState? = run { - if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null - val badgeState = BadgeModel.badgeState.value - if (badgeState != null && badgeState.shown) badgeState else null - } + val shownBadge = currentShownBadge() + val unwindToDepth = remember { modalManager.openModalCount() } // the card look is a modal setting, so the modal is composed here to follow the screen shown ModalView(close, cardScreen = shownBadge != null) { - AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState -> + AnimatedContent( + targetState = Triple(shownBadge, BadgeStore.purchaseState(chatModel.currentUser.value?.userId), BadgeStore.checkingPurchases), + transitionSpec = { fadeIn() with fadeOut() }, + contentKey = { (badgeState, purchaseState, checkingPurchases) -> Triple(badgeState != null, purchaseState, checkingPurchases) } + ) { (badgeState, purchaseState, checkingPurchases) -> if (badgeState != null) { BadgesYourBadgeView(badgeState, modalManager) + } else if (purchaseState != null) { + // holds the purchase screens' slot, so a consumable cannot be bought twice + BadgesPurchaseStateView(purchaseState.title, purchaseState.message, BadgeStore.creditError(chatModel.currentUser.value?.userId), onDismiss = close) + LaunchedEffect(purchaseState) { + BadgeStore.refusals.collect { refusal -> + // only the issuing screen belongs to the active profile's held purchase, so a refusal shown there reads as its own + if (purchaseState == BadgePurchaseState.Issuing) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = chatModel.controller.redeemErrorText(refusal, purchase = true)) + } + } + } + } else if (checkingPurchases) { + BadgesPurchaseStateView(MR.strings.badges_checking_purchases_title, null, onDismiss = close) } else { - BadgesSupportSimplexView(modalManager) + BadgesSupportSimplexView(modalManager, unwindToDepth) } } } } +fun currentShownBadge(): BadgeState? { + if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return null + val badgeState = BadgeModel.badgeState.value + return if (badgeState != null && badgeState.shown) badgeState else null +} + +// The badges modal shows the purchase state itself, so screens pushed over it have to go once a purchase +// appears. Only the top one is composed, so this runs there, closing them in one effect because a close per +// recomposition restarts showInView's transition, and stopping on a depth because closeModal defers removal. +// TODO [badges] ModalManager has no close-above and records no parentage; with either, the depth goes away. +@Composable +fun CloseWhenSupportGivesWay(modalManager: ModalManager, unwindToDepth: Int) { + val gaveWay = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null || currentShownBadge() != null + LaunchedEffect(gaveWay) { + if (gaveWay) while (modalManager.openModalCount() > unwindToDepth) modalManager.closeModal() + } +} + // 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) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt index c2ca7d7f10..6be88831a4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -78,6 +78,14 @@ fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { } SectionSpacer() } + if (BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null) { + SectionView(stringResource(MR.strings.badges_store_purchase_title)) { + SectionItemView { + Text(stringResource(MR.strings.badges_store_purchase_processing), color = MaterialTheme.colors.secondary) + } + } + SectionSpacer() + } if (appPrefs.developerTools.get()) { val clipboard = LocalClipboardManager.current SectionView(stringResource(MR.strings.badges_credential)) { 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 957160041b..deaf291138 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3142,8 +3142,10 @@ The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. Supporter Legend - Send 2GB files - Send 5GB files + Files up to 2 GB + Stored for 7 days + Files up to 5 GB + Stored for 21 days %1$s/month %1$s/year Pay %1$s/month @@ -3151,7 +3153,7 @@ Pay %1$s Loading… Not available - Save %1$s + %1$s off Supporter: 2 GB files available for 7 days. Legend: 5 GB files available for 21 days. 1 month @@ -3165,7 +3167,6 @@ Support SimpleX Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile. Why SimpleX is built. - Choose your level Redeem badge code Continue How badges protect your privacy @@ -3179,10 +3180,17 @@ Get badge + better files You can support SimpleX later in Settings. Support SimpleX to send larger files that stay available longer - Purchase successful - Purchase pending - The purchase is awaiting approval. This build does not deliver purchases approved later. Purchase error + Check your order + Duration + Total + Issuing your badge + Your payment is complete. The badge will be added to this profile. + Waiting for approval + Nothing has been charged. + Checking your purchases + Store purchase + A store purchase is being processed. Buy in browser Redeem code Add badge to your profile? @@ -3192,7 +3200,7 @@ A badge can be added to another profile, or here once this badge ends. Its code is on the page you bought it on, under Show code. View your badge - Badge is being issued + The badge will be added to this profile. Badge added The badge was added to the profile %1$s. Paste the code you received. @@ -3201,7 +3209,9 @@ Cannot redeem code This code is not valid. This app version cannot redeem badge codes. + This app version cannot buy badges. This profile already has a badge. Redeem the code on another profile, or once this badge ends. + This profile already has a badge. This code was not recognized. This code has already been used. This code has expired. @@ -3211,6 +3221,12 @@ This app version is too old for the badge service. Please update the app. The badge service does not recognize this badge. The badge service reported an internal error. + The store did not confirm this purchase. + This purchase was already credited to a badge on another device or profile. + The payment has not been confirmed yet. + The store could not be reached. + Badges cannot be bought in the app yet. + This badge is not on sale. The code was accepted, but the badge it grants has already ended. The code could not be redeemed. The code you scanned is not a badge code. @@ -3245,6 +3261,7 @@ Badge renewal failed Tap for details The badge service refused the renewal: %1$s + The badge service refused the purchase: %1$s The badge service did not respond. The badge service could not be reached. The badge issued by the service cannot be verified. diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md index 945256f814..538d75c7a0 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -48,7 +48,7 @@ simplex-badge-service --help - `--run-cli`: interactive CLI that also processes service requests (mirrors `simplex-directory-service --run-cli`). This mode is the chat/RPC side and the `//` commands below: it starts no web listener and no poller, and `[dev] chat_redeem` does not apply to it, - whatever `--service-config` says. + whatever `--service-config` says. `[dev] accept_unverified_store_receipts` does. - `--no-address`: skip address creation on start-up (for operators who provision the address themselves). The service cannot sign credentials without an issuer key and refuses to start without one: @@ -81,7 +81,8 @@ Other options: - `--service-config INI_FILE`: path to `badge_service.ini`. Omit it to run the chat/RPC side only; the process never starts a web listener without it, and never starts one under `--run-cli`, which parses and validates the whole file (`[listener] static_dir` included) but - uses only its `[issuer]` section. An issuer key is still required either way. + uses only its `[issuer]` section and `[dev] accept_unverified_store_receipts`. An issuer key is + still required either way. - `--service-name NAME`: the bot's display name, without `*`s or spaces (default `SimpleX Badges`). - `--client-service`: use the client service certificate. - also accepts the standard SimpleX Chat core options — database path, SMP/XFTP servers, @@ -216,6 +217,24 @@ there is no client key, so the service generates one and hands it over with the means it can link every badge it issues this way. `simplex-chat badge sign` has the same property and is the offline equivalent. +### Accepting store receipts unverified, for local testing + +```ini +[dev] +accept_unverified_store_receipts = on +``` + +Without a store verifier every store purchase is answered `provider_not_configured`. With this on, +the service accepts any well-formed receipt without asking or verifying anything: an Apple JWS for +the `transactionId` and `productId` its payload names, unsigned or not, and a Play product id and +token as given. It applies to both run modes, since `--run-cli` answers service requests too, and +the service logs a warning at every start while it is on. Off by default, and only `on`/`off` +parse. + +With it on, anyone who can reach the service address can mint badges by sending a made-up receipt. +That is harmless only while this deployment signs with an issuer key released apps do not carry, so +they refuse what it signs — never turn it on where the key is one those apps trust. + ## Issuing codes Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way diff --git a/apps/simplex-badge-service/badge_service.ini.example b/apps/simplex-badge-service/badge_service.ini.example index b1f5f4b1c1..3d02951fdc 100644 --- a/apps/simplex-badge-service/badge_service.ini.example +++ b/apps/simplex-badge-service/badge_service.ini.example @@ -54,3 +54,6 @@ idle_seconds = 60 ; with a master key this service generates and can therefore link [dev] chat_redeem = off +; local testing only, in both run modes: accepts any well-formed App Store or Play receipt without +; verifying it, so anyone who can reach this service can mint badges +accept_unverified_store_receipts = off diff --git a/apps/simplex-badge-service/src/BadgeService/Catalog.hs b/apps/simplex-badge-service/src/BadgeService/Catalog.hs index b15185da68..85b678d8d5 100644 --- a/apps/simplex-badge-service/src/BadgeService/Catalog.hs +++ b/apps/simplex-badge-service/src/BadgeService/Catalog.hs @@ -11,6 +11,8 @@ module BadgeService.Catalog priceOffer, catalogCurrency, defaultCatalog, + StoreProduct (..), + storeProduct, ) where @@ -21,7 +23,7 @@ import Data.Word (Word32, Word64, Word8) import Simplex.Chat.Badges (BadgeType (..)) import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..)) import Simplex.Chat.Badges.Types (BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..)) -import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), PaymentProvider (..)) data OfferInvalid = OIZeroMonths | OIFreeMonthsExceedTerm | OIDiscountTooLarge | OIAmountUnsellable deriving (Eq, Show) @@ -133,3 +135,20 @@ defaultCatalog seededAt = (prices, offers) status = BISActive, createdAt = seededAt } + +data StoreProduct = StoreProduct + { badgeType :: BadgeType, + months :: Int + } + deriving (Eq, Show) + +-- | A receipt proves only the product paid for, so the badge comes from here, never from the client. +-- Mirrors the one-time SKUs of BadgeStore.swift and .kt; subscriptions wait until renewals add months. +-- Not part of the seeded catalog above, which exists to pin prices this service sets: a store sets its own. +storeProduct :: PaymentProvider -> Text -> Maybe StoreProduct +storeProduct provider productId = case (provider, productId) of + (PPApple, "BADGE_SUPPORTER_01") -> Just $ StoreProduct BTSupporter 1 + (PPApple, "BADGE_LEGEND_01") -> Just $ StoreProduct BTLegend 1 + (PPGoogle, "badge_supporter_01") -> Just $ StoreProduct BTSupporter 1 + (PPGoogle, "badge_legend_01") -> Just $ StoreProduct BTLegend 1 + _ -> Nothing diff --git a/apps/simplex-badge-service/src/BadgeService/Config.hs b/apps/simplex-badge-service/src/BadgeService/Config.hs index 10d55d7da7..fd11647a36 100644 --- a/apps/simplex-badge-service/src/BadgeService/Config.hs +++ b/apps/simplex-badge-service/src/BadgeService/Config.hs @@ -104,7 +104,9 @@ data ServiceConfig = ServiceConfig poll :: PollConfig, issuer :: Maybe BadgeIssuerKey, -- Local testing only; signs credentials with a master key this service can link. - devChatRedeem :: Bool + devChatRedeem :: Bool, + -- Local testing only; anyone who can reach the service can mint badges with any well-formed receipt. + devAcceptUnverifiedStoreReceipts :: Bool } deriving (Eq, Show) @@ -155,7 +157,7 @@ knownSettings = ("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]), ("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]), ("poll", ["waiting_seconds", "idle_seconds"]), - ("dev", ["chat_redeem"]), + ("dev", ["chat_redeem", "accept_unverified_store_receipts"]), ("issuer", ["index", "private_key"]) ] @@ -189,6 +191,7 @@ parseConfig ini = do pWaitingSeconds <- cadence "waiting_seconds" 3 pIdleSeconds <- cadence "idle_seconds" 60 devRedeem <- bool "dev" "chat_redeem" False + devUnverifiedReceipts <- bool "dev" "accept_unverified_store_receipts" False pure ServiceConfig { listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor}, @@ -196,7 +199,8 @@ parseConfig ini = do stripe = str, poll = PollConfig {pWaitingSeconds, pIdleSeconds}, issuer = iss, - devChatRedeem = devRedeem + devChatRedeem = devRedeem, + devAcceptUnverifiedStoreReceipts = devUnverifiedReceipts } where hasSection s = s `elem` sections ini diff --git a/apps/simplex-badge-service/src/BadgeService/Poller.hs b/apps/simplex-badge-service/src/BadgeService/Poller.hs index c91ffcd22f..f066ed5d0c 100644 --- a/apps/simplex-badge-service/src/BadgeService/Poller.hs +++ b/apps/simplex-badge-service/src/BadgeService/Poller.hs @@ -30,7 +30,7 @@ where import BadgeService.Config (PollConfig (..)) import BadgeService.Orders (decide, settleOrder) import BadgeService.Providers (ListPass (..), PaymentSignal (..), Provider (..), ProviderError (..), Received (..), expiresItself, settleWindow) -import BadgeService.Store.Invoices (InvoiceRow (..), OverdueInvoice (..), expireOverdue, getInvoiceByProviderRef, overdueInvoices, providerText, unpaidRefs) +import BadgeService.Store.Invoices (InvoiceRow (..), OverdueInvoice (..), expireOverdue, getInvoiceByProviderRef, overdueInvoices, unpaidRefs) import BadgeService.Waiters (Waiters, publish, waitingCount, waitingCountSTM) import Control.Concurrent.STM import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try) @@ -47,6 +47,7 @@ import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCu import Numeric.Natural (Natural) import Simplex.Chat.PaymentService.Types (InvoiceStatus (..), PaymentProvider) import Simplex.Messaging.Agent.Store.Common (DBStore) +import Simplex.Messaging.Encoding.String (textEncode) import Simplex.Messaging.Util (tshow) -- | Allows for our clock running ahead of the provider's; an expired invoice can still be marked paid. @@ -145,7 +146,7 @@ readsPerPass = 25 coveringProvider :: PollerEnv -> UTCTime -> (Text, Text) -> IO (Maybe Provider) coveringProvider env@PollerEnv {peProviders} now (provider, ref) = - case find ((== provider) . providerText . pProvider) peProviders of + case find ((== provider) . textEncode . pProvider) peProviders of Just p -> pure (Just p) Nothing -> do due <- dueToWarn env now ("no provider for " <> provider) diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 9a3a3f4236..02519095de 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -15,13 +15,16 @@ module BadgeService.Service badgeServiceCLI, badgeServiceResponse, badgeErrorRetryAfter, + shownServiceRequest, + survive, IssueCodeOpts (..), issueBadgeCode, ) where -import BadgeService.Catalog (defaultCatalog) +import BadgeService.Catalog (StoreProduct (..), defaultCatalog, storeProduct) import BadgeService.Config (BadgeIssuerKey (..), ServiceConfig (..), readServiceConfig) +import BadgeService.Log (logError, logInfo, logWarn) import BadgeService.Options import BadgeService.Poller (newPollerEnv, newReadHints, runPoller) import BadgeService.Providers.BTCPay (btcpayProvider) @@ -29,11 +32,12 @@ import BadgeService.Providers.Stripe (stripeProvider) import BadgeService.Store import BadgeService.Store.Invoices (seedCatalog, truncateToSecond) import BadgeService.Store.Migrate (runBadgeServiceMigrations) +import BadgeService.StoreReceipts +import BadgeService.StoreReceipts.Mock (mockStoreVerifier) import BadgeService.Waiters (Waiters, newWaiters) import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener) import Control.Applicative (optional) import Control.Concurrent.STM -import BadgeService.Log (logError, logInfo, logWarn) import Control.Monad import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as J @@ -43,7 +47,8 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace) import Data.Either (fromRight) -import Data.Functor (($>)) +import Data.Functor (($>), (<&>)) +import Data.Int (Int64) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, maybeToList) import qualified Data.Text as T @@ -61,14 +66,16 @@ import Simplex.Chat.Core (sendChatCmd, simplexChatCore) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent (CIContent (..), SMsgDirection (..), ciContentToText) import Simplex.Chat.Options (printDbOpts) +import Simplex.Chat.PaymentService.Types (StoreTransactionRef (..)) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types (AgentInvId (..), Contact, User (..)) import Simplex.Messaging.Agent.Store.Common (DBStore) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsPublicKey) import Simplex.Messaging.Encoding.String (TextEncoding, strEncode, textDecode, textEncode) -import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (catchOwn', raceAny_, safeDecodeUtf8, tshow) import Simplex.Messaging.Version (isCompatible) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) @@ -76,15 +83,18 @@ import System.Exit (exitFailure) data ServiceState = ServiceState { serviceCC :: TMVar ChatController, serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object), - chatRedeemQ :: TQueue (Contact, T.Text) + chatRedeemQ :: TQueue (Contact, T.Text), + storeVerifier :: StoreVerifier } +-- | No store verifier exists yet, so every store receipt not already credited is answered +-- provider_not_configured, terminal for the request: retrying cannot deploy one. newServiceState :: IO ServiceState newServiceState = do serviceCC <- newEmptyTMVarIO serviceRequestQ <- newTQueueIO chatRedeemQ <- newTQueueIO - pure ServiceState {serviceCC, serviceRequestQ, chatRedeemQ} + pure ServiceState {serviceCC, serviceRequestQ, chatRedeemQ, storeVerifier = noStoreVerifier} welcomeGetOpts :: IO BadgeServiceOpts welcomeGetOpts = do @@ -123,6 +133,13 @@ readConfigOrExit path = Left e -> putStrLn (path <> ": " <> e) >> exitFailure Right sc -> pure sc +devStoreVerifier :: Maybe ServiceConfig -> ServiceState -> IO ServiceState +devStoreVerifier serviceCfg env + | maybe False devAcceptUnverifiedStoreReceipts serviceCfg = do + logWarn "[dev] accept_unverified_store_receipts is on: any store receipt is accepted without verification" + pure env {storeVerifier = mockStoreVerifier} + | otherwise = pure env + badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO () badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do serviceCfg <- traverse readConfigOrExit serviceConfigFile @@ -136,6 +153,7 @@ badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do preCmdHook = Just badgeCmdHook } when devRedeem $ logWarn "[dev] chat_redeem is on: /redeem over chat hands out credentials this service can link" + requestEnv <- devStoreVerifier serviceCfg env -- The reader must not block, since outputQ carries every chat event. simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> do lanes <- maybe (pure []) (serviceLanes waiters cc) serviceCfg @@ -147,7 +165,7 @@ badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do (_, Right CEvtNewChatItems {chatItems = AChatItem _ SMDRcv (DirectChat ct) ChatItem {content = mc@CIRcvMsgContent {}} : _}) | devRedeem -> atomically $ writeTQueue (chatRedeemQ env) (ct, ciContentToText mc) _ -> pure (), - processQueuedRequests key env + processQueuedRequests key requestEnv ] <> [processChatRedeems key env | devRedeem] <> lanes @@ -178,13 +196,12 @@ badgeServiceCLI :: BadgeServiceOpts -> IO () badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do serviceCfg <- traverse readConfigOrExit serviceConfigFile key <- requireIssuerKey opts serviceCfg terminalChatConfig - env <- newServiceState - let eventHook _cc ev = do - case ev of - Right (CEvtServiceRequest u reqId sigKey reqData) -> - atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) - _ -> pure () - pure ev + env <- newServiceState >>= devStoreVerifier serviceCfg + let eventHook _cc = \case + Right (CEvtServiceRequest u reqId sigKey reqData) -> do + atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) + pure $ Right $ CEvtServiceRequest u reqId sigKey (shownServiceRequest reqData) + ev -> pure ev chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, @@ -197,6 +214,12 @@ badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do processQueuedRequests key env ] +-- | The terminal prints each request, and requests carry codes and store receipts, which are bearer secrets. +shownServiceRequest :: J.Object -> J.Object +shownServiceRequest reqData = case KM.lookup "request" reqData of + Just (J.Object cmd) | Just t <- KM.lookup "type" cmd -> KM.singleton "request" $ J.object ["type" J..= t] + _ -> KM.empty + badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand) badgeCmdHook cc = \case CustomChatCommand cmd -> Left <$> runBadgeCmd cc cmd @@ -272,14 +295,19 @@ processQueuedRequests key env = do cc <- atomically $ readTMVar $ serviceCC env forever $ do (u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env - handleServiceRequest key cc u reqId sigKey reqData + survive ("request " <> safeDecodeUtf8 (strEncode reqId)) $ handleServiceRequest key (storeVerifier env) cc u reqId sigKey reqData processChatRedeems :: BadgeIssuerKey -> ServiceState -> IO () processChatRedeems key env = do cc <- atomically $ readTMVar $ serviceCC env forever $ do (ct, msg) <- atomically $ readTQueue $ chatRedeemQ env - chatRedeem key cc ct msg + survive "a chat redemption" $ chatRedeem key cc ct msg + +-- | Cancellation is rethrown, since that is how the race stops this thread. The +-- exception itself is not logged: it can quote the request, which carries codes and store receipts. +survive :: T.Text -> IO () -> IO () +survive what action = action `catchOwn'` \_ -> logError $ "badge service: " <> what <> " failed; the next one will be handled" -- | Here the service generates the master key and can link the badge, so [dev] chat_redeem gates this. chatRedeem :: BadgeIssuerKey -> ChatController -> Contact -> T.Text -> IO () @@ -308,11 +336,13 @@ badgePostStartHook BadgeServiceOpts {noAddress, testing} devRedeem env cc = do unless noAddress $ initializeBotAddress' (not testing) (Just True) devRedeem cc void $ atomically $ tryPutTMVar (serviceCC env) cc -handleServiceRequest :: BadgeIssuerKey -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () -handleServiceRequest key cc User {userId} reqId sigKey reqData = do +handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () +handleServiceRequest key verifier cc User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) logInfo $ "badge service request " <> reqIdT - resp <- badgeServiceResponse key cc sigKey reqData + resp <- + badgeServiceResponse key verifier cc sigKey reqData `catchOwn'` \_ -> + logError ("badge service request " <> reqIdT <> " failed") $> errorResponse BSEInternal sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e @@ -334,8 +364,8 @@ badgeErrorRetryAfter = \case -- | The agent verified the signature, so sigKey is a key the sender holds; a differing purchaseKey would let a client claim a purchase it cannot sign for. -badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse -badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) of +badgeServiceResponse :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse +badgeServiceResponse key verifier cc sigKey reqData = case J.fromJSON (J.Object reqData) of J.Error _ -> pure $ errorResponse BSEBadRequest J.Success BadgeServiceRequest {version, purchaseKey, request} | not (version `isCompatible` supportedBadgeServiceVRange) -> pure $ errorResponse BSEUnsupportedVersion @@ -344,10 +374,16 @@ badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of Just k -> redeemCode key cc k masterKey code Nothing -> pure $ errorResponse BSEBadRequest + BSCPurchaseBadge {masterKey, payment, upgrade} + | Just receipt_ <- toStoreReceipt verifier payment -> case (purchaseKey, upgrade) of + (Just k, Nothing) -> either storeRefusalResponse (purchaseWithReceipt key cc k masterKey) receipt_ + -- store upgrades are not built, and ignoring one would credit its months at the discounted price + (Just _, Just _) -> pure $ errorResponse BSEBadRequest + (Nothing, _) -> pure $ errorResponse BSEBadRequest BSCIssueBadge {balance} -> case purchaseKey of Just k -> issueBadgeCmd key cc k balance Nothing -> pure $ errorResponse BSEBadRequest - -- Every command but redeemBadgeCode needs a key the service already knows; that one creates the purchase, so its key is unknown on a first redemption. + -- Every command but redeemBadgeCode and purchaseBadge from a store needs a key the service already knows; those two create the purchase, so their key is unknown on a first purchase. _ -> case purchaseKey of Nothing -> pure $ errorResponse BSEUnsupportedVersion Just k -> @@ -384,29 +420,19 @@ redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText withDB "getBadgeCode" cc (readCode now code) >>= \case Left _ -> pure $ errorResponse BSEInternal Right (Left resp) -> pure resp - Right (Right IssuedCode {badgeCodeId, badgeType, months}) -> do - (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc - -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the - -- months it adds are counted from a start already in the past - let granted = grantEntry now grantUuid months SCCode $ emptyEntry now badgeType - -- A grant of at least one month starting now always has a month to issue. - case issueEntry now issueUuid granted of - Nothing -> pure $ errorResponse BSEInternal - Just issued -> credentialForEntry key masterKey issued >>= \case - Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal - Right signed -> do - -- If the code was revoked or redeemed while signing, the claim fails. Read the code again to tell the client why. - r <- withDB "writeCodeRedemption" cc $ \db -> - liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case - Nothing -> - readCode now code db >>= \case - Left resp -> pure resp - Right _ -> logError "badge service: redeeming a code failed, but the code is neither redeemed nor revoked" $> errorResponse BSEInternal - Just purchaseId -> liftIO $ do - appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed - entries_ <- getLedgerEntries db purchaseId 0 - pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ - pure $ fromRight (errorResponse BSEInternal) r + Right (Right IssuedCode {badgeCodeId, badgeType, months}) -> + signFirstMonth key cc masterKey badgeType months SCCode now >>= \case + Left resp -> pure resp + Right firstMonth -> do + -- If the code was revoked or redeemed while signing, the claim fails. Read the code again to tell the client why. + r <- withDB "writeCodeRedemption" cc $ \db -> + liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case + Nothing -> + readCode now code db >>= \case + Left resp -> pure resp + Right _ -> logError "badge service: redeeming a code failed, but the code is neither redeemed nor revoked" $> errorResponse BSEInternal + Just purchaseId -> liftIO $ firstMonthResponse db purchaseId Nothing firstMonth + pure $ fromRight (errorResponse BSEInternal) r where readCode now code db = liftIO $ getBadgeCode db (badgeCodeHash code) >>= \case @@ -417,19 +443,99 @@ redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText -- Redeeming an unpaid code would issue a free badge, so unpaid is refused. | CPSUnpaid <- paymentStatus -> pure $ Left $ errorResponse BSEPaymentPending | otherwise -> - checkUnspent db redemption >>= \case + creditedResponse db BSECodeUsed purchaseKey redemption >>= \case Left resp -> pure $ Left resp Right () | maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired | otherwise -> pure $ Right c - checkUnspent db = \case - CodeUnredeemed -> pure $ Right () - CodeRedeemedUnreadable -> pure $ Left $ errorResponse BSEInternal - CodeRedeemed RedeemedCode {purchaseKey = k, badgePurchaseId, credential} - | k /= purchaseKey -> pure $ Left $ errorResponse BSECodeUsed - | otherwise -> - maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) - <$> getLedgerEntries db badgePurchaseId 0 + +-- | Every refusal is answered before anything is written, so it leaves the receipt unclaimed; and +-- nothing is written until the credential is signed. +purchaseWithReceipt :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> StoreReceipt -> IO BadgeServiceResponse +purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = providerRef}, verifyReceipt} = + withDB' "getStorePayment" cc (\db -> getStorePaymentCredit db provider providerRef) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right credit + -- only the key it credited can ask, and it is told only what it was given, so the store is not asked + | creditedBy credit -> answerCredit credit + | otherwise -> + verifyReceipt >>= \case + Left refusal -> storeRefusalResponse refusal + -- the claim was read from the evidence before it was verified, so it must name the transaction the store vouched for + Right VerifiedStoreTransaction {transactionRef} + | transactionRef /= providerRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed" + Right VerifiedStoreTransaction {quantity} + | quantity /= 1 -> storeRefusalResponse $ SRVerifierFailed $ "verified a quantity of " <> tshow quantity + Right VerifiedStoreTransaction {testPurchase = True} -> storeRefusalResponse $ SRInvalid "test purchase" + -- another key's credit is told only once the store vouched for the receipt, or it would reveal which transactions were credited + Right tx -> case credit of + Uncredited -> newPurchase tx + _ -> answerCredit credit + where + creditedBy = \case + Credited CreditedPurchase {purchaseKey = k} -> k == purchaseKey + _ -> False + answerCredit credit = + withDB' "answerStoreCredit" cc (\db -> creditedResponse db BSEReceiptUsed purchaseKey credit) <&> \case + Right (Left resp) -> resp + _ -> errorResponse BSEInternal + -- the product is read only for a receipt not yet credited, so retiring it leaves its replays answered + newPurchase VerifiedStoreTransaction {productId, paid} = case storeProduct provider productId of + Nothing -> pure $ errorResponse BSEProductUnavailable + Just StoreProduct {badgeType, months} -> do + now <- badgeNow cc + signFirstMonth key cc masterKey badgeType months (SCPayment Nothing) now >>= \case + Left resp -> pure resp + Right firstMonth -> do + paymentId <- randomId cc + r <- withDB "writeStorePurchase" cc $ \db -> + liftIO (createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType} now) >>= \case + -- Credited to another key, or to this one by a request that ran alongside it, while signing. + Nothing -> + liftIO (getStorePaymentCredit db provider providerRef >>= creditedResponse db BSEReceiptUsed purchaseKey) >>= \case + Left resp -> pure resp + Right () -> logError ("badge service: a payment for " <> providerRef <> " exists but funds no purchase") $> errorResponse BSEInternal + Just purchaseId -> liftIO $ firstMonthResponse db purchaseId (Just paymentId) firstMonth + pure $ fromRight (errorResponse BSEInternal) r + +-- | The client learns only the code: a forged receipt, a refunded purchase and a test one are all receipt_invalid. +storeRefusalResponse :: StoreRefusal -> IO BadgeServiceResponse +storeRefusalResponse = \case + SRInvalid reason -> logInfo ("store receipt refused: " <> reason) $> errorResponse BSEReceiptInvalid + SRPending -> pure $ errorResponse BSEPaymentPending + SRUnreachable reason -> logWarn ("store unreachable: " <> reason) $> errorResponse BSEProviderUnavailable + SRVerifierFailed reason -> logError ("store receipt not verified: " <> reason) $> errorResponse BSEInternal + SRNotConfigured -> logWarn "store receipt refused: no verifier for this store is configured" $> errorResponse BSEProviderNotConfigured + +creditedResponse :: DB.Connection -> BadgeServiceErrorCode -> C.PublicKeyEd25519 -> FundingCredit -> IO (Either BadgeServiceResponse ()) +creditedResponse db usedCode purchaseKey = \case + Uncredited -> pure $ Right () + CreditedUnreadable -> pure $ Left $ errorResponse BSEInternal + Credited CreditedPurchase {purchaseKey = k, badgePurchaseId, credential} + | k /= purchaseKey -> pure $ Left $ errorResponse usedCode + | otherwise -> + maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) + <$> getLedgerEntries db badgePurchaseId 0 + +signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either BadgeServiceResponse (StatementEntry, (StatementEntry, BadgeCredential))) +signFirstMonth key cc masterKey badgeType months credit now = do + (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc + -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the + -- months it adds are counted from a start already in the past + let granted = grantEntry now grantUuid months credit $ emptyEntry now badgeType + -- A grant of at least one month starting now always has a month to issue. + case issueEntry now issueUuid granted of + Nothing -> pure $ Left $ errorResponse BSEInternal + Just issued -> + credentialForEntry key masterKey issued >>= \case + Left e -> logError ("badge service signing failed: " <> T.pack e) $> Left (errorResponse BSEInternal) + Right signed -> pure $ Right (granted, signed) + +firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> (StatementEntry, (StatementEntry, BadgeCredential)) -> IO BadgeServiceResponse +firstMonthResponse db purchaseId creditPaymentId_ (granted, signed) = do + appendLedgerPlan db purchaseId creditPaymentId_ [granted] $ Just $ issuanceAfter granted signed + entries_ <- getLedgerEntries db purchaseId 0 + pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ -- | The purchase is reached through the verified signer key and no other way. issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO BadgeServiceResponse @@ -458,7 +564,7 @@ issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do writeIssued purchaseId tip rows t issuance_ = do r <- withDB "issueBadge" cc $ \db -> liftIO $ do tip' <- getLedgerTip db purchaseId - when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId rows issuance_ + when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId Nothing rows issuance_ issueResponse db purchaseId t pure $ fromRight (errorResponse BSEInternal) r -- Only the asserted entry's identity is read, never the months it claims. diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index d8ab44c268..f81fbe4fe8 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -4,14 +4,17 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} module BadgeService.Store ( IssuedCode (..), - CodeRedemption (..), - RedeemedCode (..), + FundingCredit (..), + CreditedPurchase (..), NewCodePurchase (..), + NewStorePurchase (..), ServicePurchase (..), getBadgeCode, + getStorePaymentCredit, purchaseKeyExists, getPurchaseByKey, getLedgerTip, @@ -20,13 +23,14 @@ module BadgeService.Store getCurrentIssuance, appendLedgerPlan, createCodePurchase, + createStorePurchase, insertBadgeCode, RevokeResult (..), revokeCode, ) where -import BadgeService.Store.Invoices (executeChanging) +import BadgeService.Store.Invoices (executeChanging, paymentStatusText) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB @@ -35,12 +39,14 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType) import Simplex.Chat.Badges.Ledger -import Simplex.Chat.Badges.Service (StatementEntry (..)) +import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementEntry (..), StatementEntryType (..)) import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), InvoiceId (..), PaymentProvider, PaymentStatus (..)) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (textEncode) import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow') #if defined(dbPostgres) @@ -58,15 +64,15 @@ data IssuedCode = IssuedCode paymentStatus :: BadgeCodePaymentStatus, revokedAt :: Maybe UTCTime, expiresAt :: Maybe UTCTime, - redemption :: CodeRedemption + redemption :: FundingCredit } -data CodeRedemption - = CodeUnredeemed - | CodeRedeemed RedeemedCode - | CodeRedeemedUnreadable +data FundingCredit + = Uncredited + | Credited CreditedPurchase + | CreditedUnreadable -data RedeemedCode = RedeemedCode +data CreditedPurchase = CreditedPurchase { badgePurchaseId :: Int64, purchaseKey :: C.PublicKeyEd25519, credential :: BadgeCredential @@ -79,6 +85,16 @@ data NewCodePurchase = NewCodePurchase badgeType :: BadgeType } +data NewStorePurchase = NewStorePurchase + { paymentId :: Text, + provider :: PaymentProvider, + providerRef :: Text, + paid :: Maybe (CurrencyAmount, Text), + purchaseKey :: C.PublicKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType + } + data ServicePurchase = ServicePurchase { badgePurchaseId :: Int64, masterKey :: BadgeMasterKey, @@ -103,12 +119,31 @@ getBadgeCode db codeHash = (Only (Binary codeHash)) where toCode (badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, purchaseId_, purchaseKey_, credential_) = - IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redemption = codeRedemption purchaseId_ purchaseKey_ credential_} - codeRedemption purchaseId_ purchaseKey_ credential_ = case (purchaseId_, purchaseKey_) of - (Just badgePurchaseId, Just purchaseKey) -> case decodeCredential =<< credential_ of - Just credential -> CodeRedeemed RedeemedCode {badgePurchaseId, purchaseKey, credential} - Nothing -> CodeRedeemedUnreadable - _ -> CodeUnredeemed + IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redemption = fundingCredit purchaseId_ purchaseKey_ credential_} + +getStorePaymentCredit :: DB.Connection -> PaymentProvider -> Text -> IO FundingCredit +getStorePaymentCredit db provider providerRef = + maybeFirstRow' Uncredited (\(purchaseId, purchaseKey, credential_) -> fundingCredit (Just purchaseId) (Just purchaseKey) credential_) $ + DB.query + db + [sql| + SELECT p.badge_purchase_id, p.purchase_key, i.credential + FROM sx_badge_service_payments pay + JOIN sx_badge_service_badge_purchases p ON p.payment_id = pay.payment_id + LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id + WHERE pay.provider = ? AND pay.provider_ref = ? + ORDER BY i.period_end DESC + LIMIT 1 + |] + (textEncode provider, providerRef) + +fundingCredit :: Maybe Int64 -> Maybe C.PublicKeyEd25519 -> Maybe (Binary ByteString) -> FundingCredit +fundingCredit purchaseId_ purchaseKey_ credential_ = case (purchaseId_, purchaseKey_) of + (Just badgePurchaseId, Just purchaseKey) -> case decodeCredential =<< credential_ of + Just credential -> Credited CreditedPurchase {badgePurchaseId, purchaseKey, credential} + Nothing -> CreditedUnreadable + _ -> Uncredited + where decodeCredential (Binary bs) = J.decodeStrict' bs purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool @@ -137,11 +172,12 @@ getLedgerTip db purchaseId = DB.query db [sql| - SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, - entry_type, entry_credit_type, entry_debit_type, service_created_at - FROM sx_badge_service_badge_ledger - WHERE badge_purchase_id = ? - ORDER BY entry_id DESC + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id + FROM sx_badge_service_badge_ledger l + LEFT JOIN sx_badge_service_payments pay ON pay.payment_id = l.payment_id + WHERE l.badge_purchase_id = ? + ORDER BY l.entry_id DESC LIMIT 1 |] (Only purchaseId) @@ -161,18 +197,19 @@ getLedgerEntries db purchaseId afterEntryId = <$> DB.query db [sql| - SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, - entry_type, entry_credit_type, entry_debit_type, service_created_at - FROM sx_badge_service_badge_ledger - WHERE badge_purchase_id = ? AND entry_id > ? - ORDER BY entry_id + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id + FROM sx_badge_service_badge_ledger l + LEFT JOIN sx_badge_service_payments pay ON pay.payment_id = l.payment_id + WHERE l.badge_purchase_id = ? AND l.entry_id > ? + ORDER BY l.entry_id |] (purchaseId, afterEntryId) -toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) -> Maybe StatementEntry -toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) = +toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) :. (Maybe Text, Maybe Text) -> Maybe StatementEntry +toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) :. (paymentId_, invoiceId_)) = (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType}) - <$> entryTypeFromColumns entryType_ credit_ debit_ + <$> entryTypeFromColumns ((InvoiceId <$> invoiceId_) <$ paymentId_) entryType_ credit_ debit_ getCurrentIssuance :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe BadgeCredential) getCurrentIssuance db purchaseId now = do @@ -190,9 +227,10 @@ getCurrentIssuance db purchaseId now = do [Only (Binary bs)] -> J.decodeStrict' bs _ -> Nothing --- TODO write the reference columns (payment_id, charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today. -appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () -appendLedgerPlan db purchaseId rows issuance_ = do +-- | creditPaymentId_ names the payment behind a payment credit among the rows. +-- TODO write the other reference columns (charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today. +appendLedgerPlan :: DB.Connection -> Int64 -> Maybe Text -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () +appendLedgerPlan db purchaseId creditPaymentId_ rows issuance_ = do mapM_ appendRow rows case issuance_ of Nothing -> pure () @@ -211,15 +249,18 @@ appendLedgerPlan db purchaseId rows issuance_ = do where appendRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, createdAt, entryType} = do let (entryTypeT, creditType, debitType) = entryTypeColumns entryType + paymentId_ = case entryType of + SECredit SCPayment {} -> creditPaymentId_ + _ -> Nothing DB.execute db [sql| INSERT INTO sx_badge_service_badge_ledger (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, - balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type, payment_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType)) + ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType, paymentId_)) insertedRowId db -- redeemed_at is stamped here, so this must run in the same transaction as the credential rows. @@ -245,6 +286,32 @@ createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = Bad (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) Just <$> insertedRowId db +-- | In the transaction of the credential rows. The payment's unique (provider, provider_ref) is the +-- claim: a transaction presented twice at once, or under another key, inserts nothing the second time. +createStorePurchase :: DB.Connection -> NewStorePurchase -> UTCTime -> IO (Maybe Int64) +createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do + claimed <- + executeChanging + db + [sql| + INSERT INTO sx_badge_service_payments (payment_id, provider, provider_ref, amount, currency, status, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT (provider, provider_ref) DO NOTHING + |] + (paymentId, textEncode provider, providerRef, (\(CurrencyAmount a) -> a) . fst <$> paid, snd <$> paid, paymentStatusText PSSettled, now, now) + if claimed == 0 + then pure Nothing + else do + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_purchases + (purchase_key, master_key, initial_badge_type, current_badge_type, status, payment_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, paymentId, now, now) + Just <$> insertedRowId db + data RevokeResult = Revoked | AlreadyRevoked | AlreadyRedeemed | NoSuchCode deriving (Eq, Show) diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs b/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs index a3e177400e..57f9341308 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs @@ -17,7 +17,6 @@ module BadgeService.Store.Invoices getInvoice, getInvoiceByProviderRef, unpaidRefs, - providerText, codeHashExists, OverdueInvoice (..), overdueInvoices, @@ -59,7 +58,7 @@ import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), PaymentStatus (..), ServicePaymentDestination (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withConnection, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Encoding.String (textEncode) +import Simplex.Messaging.Encoding.String (textDecode, textEncode) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) #if defined(dbPostgres) @@ -290,25 +289,6 @@ qSeedBadgeOffer = "INSERT INTO @badge_offers (offer_id, price_id, months, free_months, discount, status, created_at) " <> "VALUES (?,?,?,?,?,?,?) ON CONFLICT (offer_id) DO NOTHING" -providerText :: PaymentProvider -> Text -providerText = \case - PPApple -> "apple" - PPGoogle -> "google" - PPStripe -> "stripe" - PPCrypto -> "crypto" - PPCode -> "code" - PPReceipt -> "receipt" - -textToProvider :: Text -> Maybe PaymentProvider -textToProvider = \case - "apple" -> Just PPApple - "google" -> Just PPGoogle - "stripe" -> Just PPStripe - "crypto" -> Just PPCrypto - "code" -> Just PPCode - "receipt" -> Just PPReceipt - _ -> Nothing - cryptoCurrencyText :: CryptoCurrency -> Text cryptoCurrencyText CCBtc = "btc" cryptoCurrencyText CCXmr = "xmr" @@ -380,7 +360,7 @@ mkInvoiceRow :. (cryptoAmt, expiresAt, statusTxt, createdAt) :. (pAmount, pCryptoPaid, pCryptoDue, pPaidInFull, pStatus, pUpdatedAt) ) = do - provider <- note "invoices.provider" (textToProvider providerTxt) + provider <- note "invoices.provider" (textDecode providerTxt) status <- note "invoices.status" (textToInvoiceStatus statusTxt) destination <- note "invoice payment destination" (mkDestination url addr cryptoCur cryptoAmt) pure @@ -462,7 +442,7 @@ insertInvoiceRows db NewInvoice {..} = do DB.execute db qInsertInvoice - ( (invId, providerText niProvider, price, discountAmount, Nothing :: Maybe Word32, amount, niCurrency) + ( (invId, textEncode niProvider, price, discountAmount, Nothing :: Maybe Word32, amount, niCurrency) :. (url, addr, cryptoCur, cryptoAmt, expiresAt, invoiceStatusText ISOpen, createdAt, createdAt) ) DB.execute @@ -526,7 +506,7 @@ overdueInvoices st cutoff = withConnection st $ \db -> do rows <- DB.query db qOverdueInvoices (Only (truncateToSecond cutoff)) either (E.throwIO . StoreDecodeError) pure (traverse toOverdue rows) where - toOverdue (i, providerTxt, oiProviderRef, oiCreatedAt) = case textToProvider providerTxt of + toOverdue (i, providerTxt, oiProviderRef, oiCreatedAt) = case textDecode providerTxt of Just oiProvider -> Right OverdueInvoice {oiInvoiceId = InvoiceId i, oiProvider, oiProviderRef, oiCreatedAt} Nothing -> Left ("invoices.provider: " <> providerTxt) @@ -591,7 +571,7 @@ upsertPayment db InvoiceRow {irInvoiceId, irProvider, irProviderRef, irCurrency} DB.execute db qUpsertPayment - ( (invId, invId, providerText irProvider, irProviderRef, amount) + ( (invId, invId, textEncode irProvider, irProviderRef, amount) :. (irCurrency, cryptoAmount, cryptoDue, if paidInFull then 1 :: Int else 0, paymentStatusText status, at, at) ) where diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs index c8179d5df2..a58df6d02b 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -17,7 +17,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = - [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema) + [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -109,6 +110,44 @@ DROP INDEX @idx_badge_purchases_code; DROP TABLE @badge_codes; |] +m20260925_badge_store_receipts :: Text +m20260925_badge_store_receipts = + withPrefix + servicePrefix + -- The unique provider_ref is what makes one store transaction fund one purchase: presented twice + -- at once, or under two keys, only one insert of its payment succeeds. + [r| +DROP INDEX @idx_payments_provider_ref; + +CREATE UNIQUE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); +|] + +down_m20260925_badge_store_receipts :: Text +down_m20260925_badge_store_receipts = + withPrefix + servicePrefix + [r| +DROP INDEX @idx_badge_ledger_payment; + +ALTER TABLE @badge_ledger DROP COLUMN payment_id; + +DROP INDEX @idx_badge_purchases_payment; + +ALTER TABLE @badge_purchases DROP COLUMN payment_id; + +DROP INDEX @idx_payments_provider_ref; + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index bf022f189c..fed5fd5ed0 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -18,7 +18,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = - [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema) + [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -110,6 +111,44 @@ DROP INDEX @idx_badge_purchases_code; DROP TABLE @badge_codes; |] +m20260925_badge_store_receipts :: Query +m20260925_badge_store_receipts = + withPrefix + servicePrefix + -- The unique provider_ref is what makes one store transaction fund one purchase: presented twice + -- at once, or under two keys, only one insert of its payment succeeds. + [sql| +DROP INDEX @idx_payments_provider_ref; + +CREATE UNIQUE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); +|] + +down_m20260925_badge_store_receipts :: Query +down_m20260925_badge_store_receipts = + withPrefix + servicePrefix + [sql| +DROP INDEX @idx_badge_ledger_payment; + +ALTER TABLE @badge_ledger DROP COLUMN payment_id; + +DROP INDEX @idx_badge_purchases_payment; + +ALTER TABLE @badge_purchases DROP COLUMN payment_id; + +DROP INDEX @idx_payments_provider_ref; + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs new file mode 100644 index 0000000000..b623944c46 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs @@ -0,0 +1,101 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.StoreReceipts + ( VerifiedStoreTransaction (..), + StoreRefusal (..), + StoreVerifier (..), + StoreReceipt (..), + noStoreVerifier, + toStoreReceipt, + ) +where + +import Control.Exception (evaluate) +import Data.Bifunctor (first) +import Data.Char (isAsciiLower, isAsciiUpper, isDigit) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionId, googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (CurrencyAmount, PaymentProvider (..), StoreTransactionRef (..)) +import Simplex.Messaging.Util (catchOwn') +import System.Timeout (timeout) + +-- | What a store vouches for about one completed transaction. +-- TODO [badges] PaymentFunding's PFApple and PFGoogle sketch the same thing and have no producer; +-- delete them, or build them out and reconcile. +data VerifiedStoreTransaction = VerifiedStoreTransaction + { transactionRef :: Text, -- from what was verified: Apple's transactionId, googlePurchaseRef of the token asked about + productId :: Text, + quantity :: Int, -- as the store reported it; the service refuses any but 1, as the apps never buy more + -- costs the buyer nothing: Apple's Sandbox, which a public TestFlight build buys in, and Google's license testers + testPurchase :: Bool, + paid :: Maybe (CurrencyAmount, Text) -- in minor units; Google's purchase record carries no price + } + deriving (Eq, Show) + +-- | The reasons are for the service's log alone and must never quote the receipt. +data StoreRefusal + = SRInvalid Text -- a verdict that cannot change, and the client consumes the purchase: forged, malformed, another app's, refunded + | SRPending -- a real purchase the store has not settled; it may yet + | SRUnreachable Text -- no verdict: the store was not asked, did not answer, or does not know the token (a Play 404 may be lag) + | SRVerifierFailed Text -- a bug, not the store's answer + | SRNotConfigured -- no verifier for this store is deployed; the purchase may be real + deriving (Eq, Show) + +-- | Apple signs its receipt and ships the certificate chain in it, so it is verified with no network +-- call; a Play token is opaque and must be asked about, which is why only that field is in IO. +data StoreVerifier = StoreVerifier + { verifyApple :: Maybe (Text -> Either Text VerifiedStoreTransaction), -- the JWS; Left is why Apple did not sign it + verifyGoogle :: Maybe (Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction)), -- the product id and the token + -- microseconds; requests are answered one at a time, so a verifier that does not finish holds up every other one + verifyTimeout :: Int + } + +-- | A store payment, named by the store's own reference before anything is verified. +data StoreReceipt = StoreReceipt + { txRef :: StoreTransactionRef, + verifyReceipt :: IO (Either StoreRefusal VerifiedStoreTransaction) + } + +noStoreVerifier :: StoreVerifier +noStoreVerifier = StoreVerifier {verifyApple = Nothing, verifyGoogle = Nothing, verifyTimeout = 10000000} + +-- | Nothing for a payment no store made. Exceptions are not logged, since they can quote the receipt +-- or, from Google, a URL holding the token. +toStoreReceipt :: StoreVerifier -> ServicePayment -> Maybe (Either StoreRefusal StoreReceipt) +toStoreReceipt StoreVerifier {verifyApple, verifyGoogle, verifyTimeout} = \case + SPApple {jws} -> Just $ case appleTransactionId jws of + Nothing -> Left $ SRInvalid "names no transaction" + Just ref -> Right $ StoreReceipt (StoreTransactionRef PPApple ref) $ maybe unconfigured (\verify -> offline $ first SRInvalid $ verify jws) verifyApple + SPGoogle {productId, token} + -- the claim is the token's hash, so neither string may name any purchase but the one it claims, + -- whatever path a verifier builds from them + | not (googleProductId productId) -> Just $ Left $ SRInvalid "not a Play product id" + -- Play documents no token grammar, so this is our guess, and refusing to ask Play is not its verdict + | not (googleToken token) -> Just $ Left $ SRUnreachable "a Play token this service will not send" + | otherwise -> Just $ Right $ StoreReceipt (StoreTransactionRef PPGoogle (googlePurchaseRef token)) $ maybe unconfigured (\verify -> online $ verify productId token) verifyGoogle + SPInvoice {} -> Nothing + SPReceipt {} -> Nothing + where + unconfigured = pure $ Left SRNotConfigured + -- nothing was fetched, so a throw or an overrun is a bug or a malformed receipt, never an outage + offline verdict = + (fromMaybe (Left $ SRVerifierFailed "apple verifier timed out") <$> timeout verifyTimeout (forced verdict)) + `catchOwn'` \_ -> pure $ Left $ SRVerifierFailed "apple verifier threw" + online verify = + (fromMaybe (Left $ SRUnreachable "google verifier timed out") <$> timeout verifyTimeout (verify >>= forced)) + `catchOwn'` \_ -> pure $ Left $ SRUnreachable "google verifier threw" + -- a verdict holding a thunk that throws would otherwise throw later, outside these handlers + forced = either (fmap Left . evaluate) (fmap Right . evaluate) + +googleProductId :: Text -> Bool +googleProductId pid = case T.uncons pid of + Just (c, _) -> T.length pid <= 150 && (isAsciiLower c || isDigit c) && T.all (\x -> isAsciiLower x || isDigit x || x == '_' || x == '.') pid + Nothing -> False + +googleToken :: Text -> Bool +googleToken t = not (T.null t) && T.length t <= 4096 && T.all (\x -> isAsciiLower x || isAsciiUpper x || isDigit x || x == '.' || x == '_' || x == '-') t diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs new file mode 100644 index 0000000000..c2fd1ab63d --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs @@ -0,0 +1,41 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.StoreReceipts.Mock (mockStoreVerifier) where + +import BadgeService.StoreReceipts +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as JM +import qualified Data.ByteString.Base64.URL as B64U +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat.PaymentService (appleTransactionId, googlePurchaseRef) +import Simplex.Messaging.Util (eitherToMaybe) + +-- | Vouches for any well-formed receipt without verifying anything. Each transaction is named by +-- the same function its claim is named by, so the two cannot differ. +mockStoreVerifier :: StoreVerifier +mockStoreVerifier = noStoreVerifier {verifyApple = Just mockApple, verifyGoogle = Just mockGoogle} + +mockApple :: Text -> Either Text VerifiedStoreTransaction +mockApple signed = case T.splitOn "." signed of + [_, payload, _] -> do + o <- case J.decodeStrict' =<< eitherToMaybe (B64U.decodeUnpadded $ encodeUtf8 payload) of + Just (J.Object obj) -> Right obj + _ -> Left "payload is not base64url JSON" + productId <- case JM.lookup "productId" o of + Just (J.String p) -> Right p + _ -> Left "no productId" + transactionRef <- maybe (Left "no transactionId") Right $ appleTransactionId signed + Right $ vouched transactionRef productId + _ -> Left "not three dot-separated parts" + +mockGoogle :: Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +mockGoogle productId token = pure $ Right $ vouched (googlePurchaseRef token) productId + +vouched :: Text -> Text -> VerifiedStoreTransaction +vouched transactionRef productId = + -- not a test purchase, though nothing was paid: a test purchase is refused as receipt_invalid, which is + -- terminal and drops the client's keys, so every dev purchase would fail for good + VerifiedStoreTransaction {transactionRef, productId, quantity = 1, testPurchase = False, paid = Nothing} diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json new file mode 100644 index 0000000000..72ee85416c --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345672", + "originalTransactionId": "2000000812345672", + "bundleId": "chat.simplex.app", + "productId": "BADGE_LEGEND_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "0b6a7e1d-5c3f-4a8e-b2d4-9e1f6c7a8b20", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Production", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 70000, + "currency": "USD" +} diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json new file mode 100644 index 0000000000..e61c63d2a7 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345673", + "originalTransactionId": "2000000812345673", + "bundleId": "chat.simplex.app", + "productId": "BADGE_LEGEND_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "4d2c9a61-8e3b-4f70-a1c5-6b0e9d2f3a47", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Sandbox", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 70000, + "currency": "USD" +} diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json new file mode 100644 index 0000000000..267184c036 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345671", + "originalTransactionId": "2000000812345671", + "bundleId": "chat.simplex.app", + "productId": "BADGE_SUPPORTER_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "7f7c0f43-4b7b-4c2e-9d57-1b7a2b1f3c11", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Production", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 7000, + "currency": "USD" +} diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f1122979a9..338fa6c206 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -441,6 +441,9 @@ Remote controller app version range (min and max as version strings). InvalidCode: - type: "invalidCode" +InvalidReceipt: +- type: "invalidReceipt" + ServiceNotConfigured: - type: "serviceNotConfigured" @@ -477,6 +480,7 @@ CredentialNotVerified: - "payment_not_entitled" - "payment_pending" - "provider_unavailable" +- "provider_not_configured" - "rate_limited" - "code_invalid" - "code_used" diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 4ffba7bfbe..ae1789f47e 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -361,6 +361,7 @@ undocumentedCommands = "APIConnectPreparedGroup", "APIContactInfo", "APIContactQueueInfo", + "APICreateBadgeInvoice", "APICreateChatItems", "APICreateChatTag", "APICreateMemberContact", @@ -407,6 +408,7 @@ undocumentedCommands = "APIPlanForwardChatItems", "APIPrepareContact", "APIPrepareGroup", + "APIPurchaseBadge", "APIRedeemBadgeCode", "APIRegisterToken", "APIRejectCall", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index dab06e9262..e35cd6ffe5 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -221,6 +221,7 @@ undocumentedEvents = "CEvtSndFileRedirectStartXFTP", "CEvtSndFileStart", -- legacy SMP files "CEvtSndStandaloneFileComplete", + "CEvtStorePurchaseResolved", "CEvtConnectionsDiff", "CEvtSubscriptionEnd", "CEvtTerminalEvent", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 18392ae1b5..674620487f 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -134,6 +134,7 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeInvoice", "CRBadgeLedger", "CRBadgeRedeemed", "CRBadgeState", diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 8ea70e1d19..b35e6b8001 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -20,21 +20,28 @@ A timeout hides the outcome, so the client repeats the identical signed request - `getBadgeInvoice` — returns the open invoice again; a new invoice is created only when none is open. - `redeemBadgeCode` — a code already redeemed by the signing key returns the same `badgeCredential` and writes nothing; redeemed by another key, `code_used`. The client must therefore keep the key it first signed with, or a retry cannot be recognised. -- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing. +- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing; a store payment credited to another key, `receipt_used`. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. -- `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. +- `purchaseBadge` with the recovery `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. ## Commands -`purchaseBadge` and `upgradeBadgeSubscription` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; `badgeExtra` is reserved and must be empty. `issueBadge` carries no `badgeRequest`: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state. +The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), shared by every credential of that week; `badgeExtra` is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries `badgeRequest`. `upgradeBadgeSubscription` still has the field in the schema; that is wrong and it must be dropped when the command is implemented. - `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. - `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. - `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when another key redeemed it, `code_expired` past a redemption deadline. -- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. + - A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. A receipt already credited to the signing key is answered from the record, without asking the store. + - Errors: `receipt_invalid`, `payment_pending`, `provider_unavailable` — see the classes below; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store, terminal for the request since retrying cannot deploy one. `receipt_invalid` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger. + - **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. + - **Terminal**, `receipt_invalid` — the store answered about this purchase and cannot change its mind: an Apple signature or certificate chain that does not verify, another app's bundle id, a test purchase (Apple Sandbox, Play `purchaseType` test), a refunded or revoked purchase, Play `purchaseState` canceled. + - **Pending**, `payment_pending` — Play `purchaseState` pending. + - **Unreachable**, `provider_unavailable` — the store was not asked or did not answer: network, timeout, 5xx, quota, this service's own auth or permission failures, and every Play error response, a 404 from `purchases.products.get` or `purchases.subscriptionsv2.get` and a 400 or 410 calling the token invalid included. A 404 decides nothing: Play answers it both for a token it has just issued and not yet recorded, and for one that was never a purchase. Only a purchase record is a verdict. A token whose characters this service will not send is never sent, so Play never answers about it — and Play documents no token grammar, so that check is our guess, not its verdict. A product id outside the characters Play does document is terminal, since no product of this app can have one. + - Apple is verified offline, so it has no "not yet" and a negative answer about the signed evidence is terminal. Two things are not the store's answer and leave the client holding the purchase: the verifier's own failure, such as a root certificate it could not load, which is `internal`; and a product this service does not price, which is `product_unavailable` — the verifier vouched for the transaction either way. - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. -- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. +- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. Its `badgeRequest` is to be dropped (see above). - `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). @@ -63,4 +70,4 @@ An assertion that names an entry the service holds is a prefix: the service proc ## Errors -`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — redeemed under another key; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. +`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — redeemed under another key; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed, another app's, refunded and test receipts alike, so a guesser learns nothing from the difference; `receipt_used` — credited to another key; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 01581a5bac..f9ca9d4a14 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -95,7 +95,7 @@ "properties": {"jws": {"type": "string"}} }, "google": { - "properties": {"token": {"type": "string"}} + "properties": {"productId": {"type": "string"}, "token": {"type": "string"}} }, "invoice": { "properties": {"invoiceId": {"type": "string"}} @@ -309,7 +309,7 @@ }, "purchaseBadge": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "masterKey": {"ref": "base64url"}, "payment": {"ref": "payment"} }, "optionalProperties": { @@ -321,7 +321,10 @@ }, "upgradeBadgeSubscription": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "badgeRequest": { + "ref": "badgeRequest", + "metadata": {"comment": "known wrong: to be dropped when implemented, see badges-rpc.md"} + }, "payment": { "ref": "payment", "metadata": {"comment": "store payments only"} diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index d7db7f259f..56fa0b389e 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -249,6 +249,7 @@ export interface BadgeProof { export type BadgeRedeemError = | BadgeRedeemError.InvalidCode + | BadgeRedeemError.InvalidReceipt | BadgeRedeemError.ServiceNotConfigured | BadgeRedeemError.BadgeActive | BadgeRedeemError.ServiceError @@ -259,6 +260,7 @@ export type BadgeRedeemError = export namespace BadgeRedeemError { export type Tag = | "invalidCode" + | "invalidReceipt" | "serviceNotConfigured" | "badgeActive" | "serviceError" @@ -274,6 +276,10 @@ export namespace BadgeRedeemError { type: "invalidCode" } + export interface InvalidReceipt extends Interface { + type: "invalidReceipt" + } + export interface ServiceNotConfigured extends Interface { type: "serviceNotConfigured" } @@ -312,6 +318,7 @@ export enum BadgeServiceErrorCode { Payment_not_entitled = "payment_not_entitled", Payment_pending = "payment_pending", Provider_unavailable = "provider_unavailable", + Provider_not_configured = "provider_not_configured", Rate_limited = "rate_limited", Code_invalid = "code_invalid", Code_used = "code_used", diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index ab06a24f53..eca43f04ed 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -188,6 +188,9 @@ class BadgeProof(TypedDict): class BadgeRedeemError_invalidCode(TypedDict): type: Literal["invalidCode"] +class BadgeRedeemError_invalidReceipt(TypedDict): + type: Literal["invalidReceipt"] + class BadgeRedeemError_serviceNotConfigured(TypedDict): type: Literal["serviceNotConfigured"] @@ -210,6 +213,7 @@ class BadgeRedeemError_credentialNotVerified(TypedDict): BadgeRedeemError = ( BadgeRedeemError_invalidCode + | BadgeRedeemError_invalidReceipt | BadgeRedeemError_serviceNotConfigured | BadgeRedeemError_badgeActive | BadgeRedeemError_serviceError @@ -218,9 +222,9 @@ BadgeRedeemError = ( | BadgeRedeemError_credentialNotVerified ) -BadgeRedeemError_Tag = Literal["invalidCode", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] +BadgeRedeemError_Tag = Literal["invalidCode", "invalidReceipt", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] -BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"] +BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "provider_not_configured", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"] BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] diff --git a/plans/2026-09-24-badge-buy-in-browser.md b/plans/2026-09-24-badge-buy-in-browser.md index d0a4b9c18f..826d8766a5 100644 --- a/plans/2026-09-24-badge-buy-in-browser.md +++ b/plans/2026-09-24-badge-buy-in-browser.md @@ -80,7 +80,7 @@ Getting the URL to an app that is already running is the part that half exists. The browser lane needs nothing. The store lane needs `purchaseBadge` to accept a store receipt and verify it — Apple's JWS offline, Google's token through the Publisher API, per `docs/protocol/badges-rpc.md`. No provider adapter for either exists today. -Two properties matter more than the plumbing. It must be idempotent per signing key, since the app will retry. And an unknown or invalid token must be a plain refusal, not a retryable error — Android has no local verification, so tampered clients will send junk tokens as a matter of course, and a retryable answer would leave the worker grinding on one that can never become valid. +Two properties matter more than the plumbing. It must be idempotent per signing key, since the app will retry. And only a store's verdict about a purchase may be a plain refusal: the client consumes a purchase answered `receipt_invalid`, so a store that has merely not answered — or that does not yet know a token it has just issued — must be answered retryably, or a paid badge is thrown away. Junk tokens from tampered clients are the cheap side of that trade: each costs one request per client trigger, nothing loops on the service, and Play refunds an unacknowledged purchase after three days. `docs/protocol/badges-rpc.md` has the classes. `badges-rpc.md` also specifies `getBadgeCatalog` and `getBadgeInvoice`. Neither is needed by anything here and neither should be built: the page owns the catalog and the invoice. diff --git a/scripts/badge-service/badge_service.ini.example b/scripts/badge-service/badge_service.ini.example index 09ca430fc7..1dfaaef95c 100644 --- a/scripts/badge-service/badge_service.ini.example +++ b/scripts/badge-service/badge_service.ini.example @@ -42,5 +42,10 @@ idle_seconds = 60 index = 1 private_key = replace-me +; local testing only: signs a credential for anyone who sends /redeem over chat, +; with a master key this service generates and can therefore link [dev] -chat_redeem = on +chat_redeem = off +; local testing only, in both run modes: accepts any well-formed App Store or Play receipt without +; verifying it, so anyone who can reach this service can mint badges +accept_unverified_store_receipts = off diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e759fbdd3e..4a5fa0e5a7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -168,6 +168,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json + Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_store_receipts else exposed-modules: Simplex.Chat.Archive @@ -345,6 +346,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json + Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_store_receipts other-modules: Paths_simplex_chat hs-source-dirs: @@ -441,6 +443,8 @@ executable simplex-badge-service BadgeService.Store BadgeService.Store.Invoices BadgeService.Store.Migrate + BadgeService.StoreReceipts + BadgeService.StoreReceipts.Mock BadgeService.Waiters BadgeService.Web.Server Paths_simplex_chat @@ -729,6 +733,8 @@ test-suite simplex-chat-test BadgeService.Store BadgeService.Store.Invoices BadgeService.Store.Migrate + BadgeService.StoreReceipts + BadgeService.StoreReceipts.Mock BadgeService.Waiters BadgeService.Web.Server Bots.BadgeService.BTCPayTests @@ -736,6 +742,7 @@ test-suite simplex-chat-test Bots.BadgeService.CatalogTests Bots.BadgeService.ConfigTests Bots.BadgeService.FakeBTCPay + Bots.BadgeService.FakeStore Bots.BadgeService.FakeStripe Bots.BadgeService.StripeTests Bots.BadgeService.WaitersTests diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3e6fb7e229..d80184d4a0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -72,6 +72,7 @@ defaultChatConfig = badgeServiceAddress = Just $ either error id $ strDecode "https://smp5.simplex.im/a#ooSNWlEZTO2RPE0Ff5ZoybAs5zEhWLMlQrXesnhaZHM", badgeCurrentTime = getCurrentTime, badgeRetryInterval = RetryInterval {initialInterval = 30_000000, increaseAfter = 0, maxInterval = 3600_000000}, + badgeConsecutiveRetries = 3, confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options @@ -195,6 +196,7 @@ newChatController deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO badgeWorkers <- TM.emptyIO + storeReceiptWorkers <- TM.emptyIO badgeSeq <- newTVarIO 0 relayGroupLinkChecksAsync <- newTVarIO Nothing webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState @@ -243,6 +245,7 @@ newChatController deliveryJobWorkers, relayRequestWorkers, badgeWorkers, + storeReceiptWorkers, badgeSeq, relayGroupLinkChecksAsync, webPreviewState, diff --git a/src/Simplex/Chat/Badges/Ledger.hs b/src/Simplex/Chat/Badges/Ledger.hs index 2f31773d2d..870820d7c8 100644 --- a/src/Simplex/Chat/Badges/Ledger.hs +++ b/src/Simplex/Chat/Badges/Ledger.hs @@ -26,6 +26,7 @@ import Data.Time.Calendar.WeekDate (toWeekDate) import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime) import Simplex.Chat.Badges (BadgeType) import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId) -- The calendar difference overshoots by at most one month, so one comparison settles it. monthsBetween :: UTCTime -> UTCTime -> Integer @@ -204,13 +205,15 @@ debitTypeTag = \case SDLapse -> "lapse" SDUnknown {tag} -> tag --- | Only the types a tag alone rebuilds, which is those whose constructor has no fields; the rest --- answer Nothing rather than a type with an invented payload. The client also stores each type's --- JSON and reads that first, so this is its fallback; the service has no such column. --- TODO [badges] take the reference columns and rebuild payment, charge, transferIn, upgrade and --- transferOut, without which the service cannot re-emit a statement carrying one. -entryTypeFromColumns :: Text -> Maybe Text -> Maybe Text -> Maybe StatementEntryType -entryTypeFromColumns entryType credit_ debit_ = case (entryType, credit_, debit_) of +-- | Rebuilds only what the columns determine: constructors with no fields, and a payment credit. +-- payment_ is Nothing when the row references no payment, and Just Nothing when that payment had no +-- invoice, as a store purchase has none. The rest answer Nothing rather than invent a payload. +-- The client stores each type's JSON and reads that first, so this is its fallback; the service has none. +-- TODO [badges] rebuild charge, transferIn, upgrade and transferOut from their reference columns, +-- without which the service cannot re-emit a statement carrying one. +entryTypeFromColumns :: Maybe (Maybe InvoiceId) -> Text -> Maybe Text -> Maybe Text -> Maybe StatementEntryType +entryTypeFromColumns payment_ entryType credit_ debit_ = case (entryType, credit_, debit_) of + ("credit", Just "payment", _) -> SECredit . SCPayment <$> payment_ ("credit", Just t, _) -> SECredit <$> creditType t ("debit", _, Just t) -> SEDebit <$> debitType t _ -> Nothing diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index 725009d839..80ee632c25 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -87,12 +87,12 @@ data BadgeServiceCommand code :: Text -- no badgeRequest: a code carries no tier for the client to state } | BSCPurchaseBadge - { badgeRequest :: BadgeRequest, + { masterKey :: BadgeMasterKey, -- no badgeRequest: the tier is the product's, the expiry the one every credential of that week shares payment :: ServicePayment, upgrade :: Maybe BadgeUpgrade -- upgrade store 1-time badge } | BSCUpgradeBadgeSubscription - { badgeRequest :: BadgeRequest, + { badgeRequest :: BadgeRequest, -- TODO [badges] drop when implemented: a client-stated expiry leaves the week's anonymity set payment :: ServicePayment, -- store payments only balance :: BadgeBalance } diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 1038b2b64f..376869f584 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -30,6 +30,7 @@ module Simplex.Chat.Badges.Types BadgeAlert (..), BadgeAlertPrice (..), BadgeState (..), + OpenStorePurchase (..), ) where import Control.Applicative ((<|>)) @@ -43,7 +44,7 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) -import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId, StoredPayment) +import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId) import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.DB (fromTextField_) @@ -236,6 +237,7 @@ data BadgeServiceErrorCode | BSEPaymentNotEntitled | BSEPaymentPending | BSEProviderUnavailable + | BSEProviderNotConfigured -- no verifier deployed: a dev service, not the store failing to answer | BSERateLimited | BSECodeInvalid | BSECodeUsed @@ -281,6 +283,17 @@ data BadgeState = BadgeState } deriving (Show) +-- | One of a profile's store purchases that no receipt has reached yet, or whose receipt is held until +-- the service credits it; transactionRef is set once a receipt arrived. Neither reference is a secret: +-- Apple's is the transaction id, Google's is a hash of the token. creditError is set only while the +-- held receipt's last attempt failed in a way that cannot clear on its own. +data OpenStorePurchase = OpenStorePurchase + { invoiceId :: Maybe Text, + transactionRef :: Maybe Text, + creditError :: Maybe BadgeIssueFailure + } + deriving (Show) + instance TextEncoding BadgePurchaseStatus where textEncode = \case PSAcquiring -> "acquiring" @@ -325,6 +338,7 @@ instance TextEncoding BadgeServiceErrorCode where BSEPaymentNotEntitled -> "payment_not_entitled" BSEPaymentPending -> "payment_pending" BSEProviderUnavailable -> "provider_unavailable" + BSEProviderNotConfigured -> "provider_not_configured" BSERateLimited -> "rate_limited" BSECodeInvalid -> "code_invalid" BSECodeUsed -> "code_used" @@ -344,6 +358,7 @@ instance TextEncoding BadgeServiceErrorCode where "payment_not_entitled" -> BSEPaymentNotEntitled "payment_pending" -> BSEPaymentPending "provider_unavailable" -> BSEProviderUnavailable + "provider_not_configured" -> BSEProviderNotConfigured "rate_limited" -> BSERateLimited "code_invalid" -> BSECodeInvalid "code_used" -> BSECodeUsed @@ -410,3 +425,5 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BIF") ''BadgeIssueFailure) $(JQ.deriveJSON defaultJSON ''BadgeIssueError) $(JQ.deriveJSON defaultJSON ''BadgeState) + +$(JQ.deriveJSON defaultJSON ''OpenStorePurchase) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9cc80fc54b..2719c588d8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -85,7 +85,8 @@ import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyM import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..), OpenStorePurchase (..)) +import Simplex.Chat.PaymentService (ServicePayment) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -152,6 +153,8 @@ data ChatConfig = ChatConfig badgeCurrentTime :: IO UTCTime, -- how long a badge worker waits before repeating a renewal that failed for a passing reason badgeRetryInterval :: RetryInterval, + -- attempts a store receipt gets in one turn before its next one waits for the stored schedule + badgeConsecutiveRetries :: Int, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -327,6 +330,7 @@ data ChatController = ChatController relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework -- one badge worker per user: badge state is per profile, and one profile must not stall another badgeWorkers :: TMap UserId (SessionVar BadgeWorker), + storeReceiptWorkers :: TMap UserId Worker, badgeSeq :: TVar Int, relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), webPreviewState :: Maybe WebPreviewState, @@ -659,6 +663,8 @@ data ChatCommand | UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service + | APIPurchaseBadge {userId :: UserId, echoedInvoiceId :: Maybe Text, payment :: ServicePayment} -- hand over an App Store or Google Play receipt, held until a worker credits it; answers from the record and sends nothing + | APICreateBadgeInvoice {userId :: UserId} -- the record of a store purchase, created before the store charges; answers the id the store echoes | APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert | APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first -- episode is last because it is free text: it is the value that makes one occurrence of an @@ -869,7 +875,8 @@ data ChatResponse | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} - | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} + | CRBadgeInvoice {user :: User, invoiceId :: Text} + | CRBadgeState {user :: User, badgeState :: Maybe BadgeState, storePurchases :: [OpenStorePurchase]} | CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} @@ -990,6 +997,7 @@ data ChatEvent | CEvtServiceReplySent {connectionId :: AgentConnId} | CEvtBadgeChanged {user :: User, badgeState :: Maybe BadgeState} -- badge state changed, including a renewal that arrived without a command | CEvtBadgeAlert {user :: User, badgeAlert :: BadgeAlert} + | CEvtStorePurchaseResolved {user :: User} -- a held store receipt was credited or refused, so its store transaction can be finished | CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason} | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response | CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} @@ -1479,8 +1487,10 @@ data SimplexDomainError | SDEUnknownDomain -- the resolved link's profile has no name, or a different name deriving (Eq, Show) +-- | Why acquiring a badge failed, for a code and for a store purchase alike. data BadgeRedeemError = BREInvalidCode -- format or check character + | BREInvalidReceipt -- names no transaction, or is not a store payment | BREServiceNotConfigured | BREBadgeActive | BREServiceError {serviceError :: BadgeServiceErrorCode} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d02a189b1d..043b06d829 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -61,7 +61,7 @@ import Crypto.Random (ChaChaDRG) import Simplex.Messaging.Session (SessionVar (..), withGetSessVar') import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), badgeServerCredential, mkBadgeStatus, maxSndXFTPFileSize, verifyCredential) import qualified Simplex.Chat.Badges.Ledger as L -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIssueError (..), BadgeIssueFailure (..), BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIssueError (..), BadgeIssueFailure (..), BadgeState (..), OpenStorePurchase (..)) import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) @@ -76,6 +76,8 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators import Simplex.Chat.Options +import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionId, googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (PaymentProvider (..), StoreTransactionRef (..)) import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Remote @@ -103,9 +105,10 @@ import qualified Simplex.Chat.Util as U import Simplex.Chat.Web (webPreviewWorker) import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHard) import Simplex.Messaging.Agent -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Client (getAgentWorker, waitForUserNetwork, waitForWork, waitWhileSuspended, withWork_) +import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), Worker (..), allRoles) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), withRetryInterval) +import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay, withRetryInterval, withRetryIntervalCount) import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) @@ -261,6 +264,7 @@ startChatController mainApp enableSndFiles serviceRequests = do startRelayRequestWorker_ startCleanupManager mapM_ startBadgeWork users + startStoreReceiptWorkers users void $ forkIO $ mapM_ startExpireCIs users startRelayChecks users startWebPreview users @@ -279,6 +283,10 @@ startChatController mainApp enableSndFiles serviceRequests = do runExceptT startRelayRequestWorker >>= \case Left e -> liftIO $ putStrLn $ "Error starting relay request worker: " <> show e Right _ -> pure () + startStoreReceiptWorkers users = + runExceptT (mapM_ startStoreReceiptWork users) >>= \case + Left e -> liftIO $ putStrLn $ "Error starting store receipt workers: " <> show e + Right _ -> pure () startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case @@ -357,7 +365,7 @@ restoreCalls = do atomically $ writeTVar calls callsMap stopChatController :: ChatController -> IO () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, cleanupManagerAsync, relayGroupLinkChecksAsync, webPreviewState, expireCIThreads, timedItemThreads, deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, badgeWorkers} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, cleanupManagerAsync, relayGroupLinkChecksAsync, webPreviewState, expireCIThreads, timedItemThreads, deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, badgeWorkers, storeReceiptWorkers} = do readTVarIO remoteHostSessions >>= mapM_ (cancelRemoteHost False . snd) atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (cancelRemoteCtrl False . snd) disconnectAgentClient smpAgent @@ -371,6 +379,7 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, clearMap deliveryJobWorkers >>= mapM_ cancelWorker clearMap relayRequestWorkers >>= mapM_ cancelWorker stopBadgeWorkers badgeWorkers + clearMap storeReceiptWorkers >>= mapM_ cancelWorker closeFiles sndFiles closeFiles rcvFiles atomically $ do @@ -603,6 +612,7 @@ processChatCommand cxt nm = \case void . forkIO $ startFilesToReceive users setAllExpireCIFlags True mapM_ startBadgeWork users + mapM_ startStoreReceiptWork users ok_ APISuspendChat t -> do chatWriteVar chatActivated False @@ -3562,10 +3572,19 @@ processChatCommand cxt nm = \case ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText + APIPurchaseBadge userId echoedInvoiceId payment -> withUserId userId $ \user -> handOverStoreReceipt user echoedInvoiceId payment + APICreateBadgeInvoice userId -> withUserId userId $ \user -> do + void requireBadgeService + refuseWhileBadgeHeld user + g <- asks random + now <- badgeNow + invoiceId <- UUID.toText <$> liftIO V4.nextRandom + withStore' $ \db -> createBadgeStoreReceipt db g user invoiceId now + pure $ CRBadgeInvoice user invoiceId APIGetBadgeState userId -> withUserId' userId $ \user -> do -- the read also signals the worker, whose results follow as CEvtBadgeChanged lift $ startBadgeWork user - CRBadgeState user <$> getUserBadgeState user + badgeStateResponse user APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user -> CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId) APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do @@ -3574,7 +3593,7 @@ processChatCommand cxt nm = \case withStore' $ \db -> setBadgeAlertAcked db user badgePurchaseId alertKind episode snoozeUntil -- after the write, so the pass it signals arms a wake for the snooze rather than raising again lift $ startBadgeWork user - CRBadgeState user <$> getUserBadgeState user + badgeStateResponse user SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -5219,30 +5238,20 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse redeemBadgeCode nm user@User {userId} codeText = do code <- maybe (throwRedeemError BREInvalidCode) pure $ parseBadgeCode codeText - sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure + sendTarget <- requireBadgeService g <- asks random now <- liftIO getCurrentTime let codeSent = badgeCodeText code -- the guard, the request and the write are one section: without it two codes redeemed at once -- both pass the guard and are both spent, for one badge (present_, redeemed) <- withEntityLock "badgeRedeem" (CLBadgeUser userId) $ do - redemption_ <- withStore' $ \db -> getBadgeCodeRedemption db user codeSent - -- a code already redeemed here is allowed through: re-sending it returns the badge it bought - -- and adds nothing. Refused before its keys are stashed and before the request, so it stays unspent - replaying <- maybe (pure False) (\r -> withStore' $ \db -> isJust <$> getCodeBadgePurchase db r) redemption_ - unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive - redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <- - maybe (withStore' $ \db -> createBadgeCodeRedemption db g user codeSent now) pure redemption_ - let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}} - respBytes <- sendServiceRequestBytes nm user sendTarget Nothing (Just purchasePrivKey) req - respData <- either (const $ throwRedeemError $ BREInvalidResponse "not JSON") pure $ J.eitherDecodeStrict' respBytes - case J.fromJSON (J.Object respData) of - J.Error _ -> throwRedeemError $ BREInvalidResponse "not a badge service response" - J.Success BSPError {code = errCode} -> do - when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption) + stash_ <- withStore' $ \db -> getBadgeCodeRedemption db user codeSent + stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeCodeRedemption db g user codeSent now + requestBadgeStash nm user sendTarget stash BSCRedeemBadgeCode {masterKey, code = codeSent} >>= \case + Left (errCode, _) -> do + when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db user codeSent throwRedeemError $ BREServiceError errCode - J.Success BSPBadgeCredential {credential = Just cred, statement} -> storeRedeemedBadge user redemption cred statement - J.Success _ -> throwRedeemError $ BREInvalidResponse "unexpected response type" + Right redeemed -> pure redeemed -- outside the badge lock: the chat lock must not be taken under it mapM_ presentUserBadgeToContacts present_ pure redeemed @@ -5254,6 +5263,69 @@ redeemBadgeCode nm user@User {userId} codeText = do BSECodeExpired -> True _ -> False +-- | The app hands a receipt over until it is answered credited or refused, under whichever profile is +-- active; the answer is the owner's, read from the record. Nothing is sent: the owner's receipt worker is. +handOverStoreReceipt :: User -> Maybe Text -> ServicePayment -> CM ChatResponse +handOverStoreReceipt presentingUser echoedInvoiceId payment = do + txRef <- maybe (throwRedeemError BREInvalidReceipt) pure $ storeTransactionRef payment + void requireBadgeService + g <- asks random + now <- badgeNow + BadgeReceiptRecord {ownerId, status} <- + withStore' (\db -> holdStoreReceipt db g presentingUser echoedInvoiceId txRef payment now) + >>= maybe (throwChatError $ CEInternalError "store receipt was not recorded") pure + owner <- withStore $ \db -> getUser db ownerId + case status of + RSHeld {} -> lift (resumeStoreReceiptWork ownerId) >> badgeStateResponse owner + RSCredited {badgePurchaseId} -> do + cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId) + cred@(BadgeCredential _ _ _ info) <- maybe (throwChatError $ CEInternalError "credited store purchase has no credential") pure cred_ + CRBadgeRedeemed owner (OwnBadge cred (mkBadgeStatus now (Just True) info)) False <$> getUserBadgeState owner + RSRefused {refusal = Just BIFServiceError {code}} -> throwRedeemError $ BREServiceError code + RSRefused {} -> throwChatError $ CEInternalError "store purchase refused for no recorded reason" + +-- | The receipt will never be credited to its key; any other refusal may pass on a retry. +storeReceiptRefused :: BadgeServiceErrorCode -> Bool +storeReceiptRefused = \case + BSEReceiptInvalid -> True + BSEReceiptUsed -> True + _ -> False + +-- | The same reference the service claims a transaction by, read without verifying anything. +storeTransactionRef :: ServicePayment -> Maybe StoreTransactionRef +storeTransactionRef = \case + SPApple {jws} -> StoreTransactionRef PPApple <$> appleTransactionId jws + SPGoogle {token} -> Just $ StoreTransactionRef PPGoogle $ googlePurchaseRef token + SPInvoice {} -> Nothing + SPReceipt {} -> Nothing + +-- | A stash that already bought a badge here passes, as re-sending it adds nothing; any other is +-- refused while a badge is held, before its keys are stashed or sent, so the funding stays unspent. +stashBadgeKeys :: User -> Maybe BadgeStash -> (DB.Connection -> IO BadgeStash) -> CM BadgeStash +stashBadgeKeys user stash_ createStash = do + replaying <- maybe (pure False) (\s -> withStore' $ \db -> isJust <$> getStashBadgePurchase db s) stash_ + unless replaying $ refuseWhileBadgeHeld user + maybe (withStore' createStash) pure stash_ + +requireBadgeService :: CM (ConnectTarget 'CMContact) +requireBadgeService = asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure + +refuseWhileBadgeHeld :: User -> CM () +refuseWhileBadgeHeld user = whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive + +-- | One attempt to turn a stash into a badge. A refusal is answered rather than thrown, with the +-- service's retryAfter. +requestBadgeStash :: NetworkRequestMode -> User -> ConnectTarget 'CMContact -> BadgeStash -> BadgeServiceCommand -> CM (Either (BadgeServiceErrorCode, Maybe Word32) (Maybe User, ChatResponse)) +requestBadgeStash nm user sendTarget stash@BadgeStash {purchaseKey, purchasePrivKey} request = do + let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} + respBytes <- sendServiceRequestBytes nm user sendTarget Nothing (Just purchasePrivKey) req + respData <- either (const $ throwRedeemError $ BREInvalidResponse "not JSON") pure $ J.eitherDecodeStrict' respBytes + case J.fromJSON (J.Object respData) of + J.Error _ -> throwRedeemError $ BREInvalidResponse "not a badge service response" + J.Success BSPError {code, retryAfter} -> pure $ Left (code, retryAfter) + J.Success BSPBadgeCredential {credential = Just cred, statement} -> Right <$> storeRedeemedBadge user stash cred statement + J.Success _ -> throwRedeemError $ BREInvalidResponse "unexpected response type" + throwRedeemError :: BadgeRedeemError -> CM a throwRedeemError = throwChatError . CEBadgeRedeemError @@ -5362,6 +5434,89 @@ waitBadgeWake badgeWork now = \case badgeMaxWake :: NominalDiffTime badgeMaxWake = 100 * 365 * nominalDay +startStoreReceiptWork :: User -> CM () +startStoreReceiptWork User {userId} = do + held <- withStore' $ \db -> getNextHeldStoreReceipt db userId + when (either (const False) isJust held) $ lift $ resumeStoreReceiptWork userId + +resumeStoreReceiptWork :: UserId -> CM' () +resumeStoreReceiptWork userId = do + ws <- asks storeReceiptWorkers + a <- asks smpAgent + void $ getAgentWorker "store_receipt" True a userId ws $ runStoreReceiptWorker a userId + +runStoreReceiptWorker :: AgentClient -> UserId -> Worker -> CM () +runStoreReceiptWorker a userId Worker {doWork} = do + scheduled <- newTVarIO S.empty + forever $ do + lift $ waitForWork doWork + withWork_ a doWork (getDueReceipt scheduled) $ \case + BadgeReceiptRecord {receiptId, status = RSHeld {stash, payment, retryDelay}} -> creditStoreReceipt a userId receiptId stash payment retryDelay + BadgeReceiptRecord {receiptId} -> notHeld receiptId + where + getDueReceipt scheduled = + withStore' (`getNextHeldStoreReceipt` userId) >>= \case + Right (Just r@BadgeReceiptRecord {receiptId, status = RSHeld {nextAttemptAt}}) -> do + now <- badgeNow + if nextAttemptAt <= now + then pure $ Right (Just r) + else Right Nothing <$ scheduleReceipt scheduled receiptId (diffUTCTime nextAttemptAt now) + Right (Just BadgeReceiptRecord {receiptId}) -> Right Nothing <$ notHeld receiptId + r -> pure r + -- getNextHeldStoreReceipt reads held rows only, so this says its query and its result type disagree + notHeld receiptId = eToView $ ChatError $ CEInternalError $ "store receipt " <> show receiptId <> " is not held" + scheduleReceipt scheduled receiptId delay = do + new <- atomically $ stateTVar scheduled $ \ids -> (S.notMember receiptId ids, S.insert receiptId ids) + when new . void . liftIO . forkIO $ do + threadDelay' $ diffToMicroseconds delay + atomically $ modifyTVar' scheduled $ S.delete receiptId + void $ atomically $ tryPutTMVar doWork () + +data StoreReceiptOutcome = SROCredited (Maybe User) | SRORefused | SRORetry | SRODeferred + +-- | Under the owner's badge lock, which keeps a profile's requests to the service serial. +creditStoreReceipt :: AgentClient -> UserId -> Int64 -> BadgeStash -> Text -> Maybe Int64 -> CM () +creditStoreReceipt a userId receiptId stash@BadgeStash {masterKey} payment retryDelay = do + ChatConfig {badgeRetryInterval = ri, badgeConsecutiveRetries} <- asks config + withRetryIntervalCount (maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) retryDelay) $ \n delay loop -> do + liftIO $ waitWhileSuspended a + liftIO $ waitForUserNetwork a + outcome <- withEntityLock "badgePurchase" (CLBadgeUser userId) $ + tryAllErrors attempt >>= \case + Right (Right (present_, _)) -> pure $ SROCredited present_ + Right (Left (code, retryAfter)) + | storeReceiptRefused code -> SRORefused <$ withStore' (\db -> refuseStoreReceipt db receiptId $ serviceFailure code retryAfter) + | otherwise -> failed ri delay (serviceFailure code retryAfter) retryAfter + Left e -> failed ri delay (badgeIssueFailure e) Nothing + -- only a settlement is announced, and the sweep it prompts signals this worker only for a receipt still held, so it cannot loop + case outcome of + SROCredited present_ -> do + user <- withStore $ \db -> getUser db userId + toView . CEvtBadgeChanged user =<< getUserBadgeState user + toView $ CEvtStorePurchaseResolved user + -- last, so a failed broadcast cannot lose the settlement; outside the badge lock, as it takes the chat lock + mapM_ presentUserBadgeToContacts present_ + SRORefused -> withStore (`getUser` userId) >>= toView . CEvtStorePurchaseResolved + SRORetry | n + 1 < badgeConsecutiveRetries -> loop + _ -> pure () + where + attempt = do + user <- withStore $ \db -> getUser db userId + refuseWhileBadgeHeld user + sendTarget <- requireBadgeService + payment' <- maybe (throwChatError $ CEInternalError "held store payment does not decode") pure $ decodeJSON payment + requestBadgeStash NRMBackground user sendTarget stash BSCPurchaseBadge {masterKey, payment = payment', upgrade = Nothing} + serviceFailure code retryAfter = BIFServiceError {code = boundedServiceErrorCode code, retryable = isJust retryAfter} + failed ri delay failure retryAfter = do + now <- badgeNow + let (retryIn, outcome) + | isJust retryAfter = (badgeRetryAfter ri retryAfter, SRODeferred) + -- a buyer is waiting, so unlike a renewal a failure that can clear on its own never waits the day + | badgeFailureTransient failure = (fromIntegral delay / 1000000, SRORetry) + | otherwise = (badgeStalledInterval, SRODeferred) + withStore' $ \db -> recordStoreReceiptFailure db receiptId (nextRetryDelay 0 delay ri) (retryIn `addUTCTime` now) failure + pure outcome + -- | Retire what has ended, renew what is due, then report the next wake. Waking early, late or not -- at all changes only timing: each run reads stored state and works out what to do. updateUserBadge :: UserId -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime) @@ -5439,6 +5594,15 @@ emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} shownCred now raised <- atomically $ stateTVar emitted (,occurrence) when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert +-- | Scoped to one profile, so another profile's store purchase, which may be hidden, never shows under it. +badgeStateResponse :: User -> CM ChatResponse +badgeStateResponse user = do + badgeState <- getUserBadgeState user + CRBadgeState user badgeState . map finalError <$> withStore' (`getOpenStorePurchases` user) + where + -- like a renewal's, only a failure that cannot clear on its own is the user's to know about + finalError p@OpenStorePurchase {creditError} = p {creditError = find (not . badgeFailureTransient) creditError} + -- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged. getUserBadgeState :: User -> CM (Maybe BadgeState) getUserBadgeState user = do @@ -5472,9 +5636,10 @@ badgeStalledInterval = nominalDay -- | The wait after a service refusal, floored at initialInterval so answering 0 cannot spin the -- worker, and uncapped above it. badgeRetryAfter :: RetryInterval -> Maybe Word32 -> NominalDiffTime -badgeRetryAfter RetryInterval {initialInterval} = maybe badgeStalledInterval (max floorWait . fromIntegral) - where - floorWait = fromIntegral initialInterval / 1000000 +badgeRetryAfter ri = maybe badgeStalledInterval (max (retryFloor ri) . fromIntegral) + +retryFloor :: RetryInterval -> NominalDiffTime +retryFloor RetryInterval {initialInterval} = fromIntegral initialInterval / 1000000 -- | How far ahead of the shown credential's expiry the renewal is requested - a day, so a failure -- has that long to retry. The wake and the due check both derive from it and have to agree. @@ -5531,6 +5696,8 @@ badgeIssueFailure e = case e of | otherwise -> BIFUnexpected {message = tshow agentError} ChatError (CECommandError m) -> BIFUnexpected {message = T.pack m} ChatError (CEInternalError m) -> BIFUnexpected {message = T.pack m} + ChatError (CEBadgeRedeemError BREUnknownKeyIndex) -> BIFInvalidCredential + ChatError (CEBadgeRedeemError BRECredentialNotVerified) -> BIFInvalidCredential _ -> BIFUnexpected {message = tshow e} -- | Whether a failure can clear on its own, which decides whether the alert waits for the shown @@ -5654,8 +5821,8 @@ stopBadgeWorkers workers = -- | Verify the credential before writing anything; the purchase, the statement's rows, the -- issuance and the profile's badge go in one transaction. Answers the user to tell contacts about, -- which the caller does once the badge lock is released. -storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse) -storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeType}) statement = +storeRedeemedBadge :: User -> BadgeStash -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse) +storeRedeemedBadge user@User {userId} stash@BadgeStash {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeType}) statement = verifyOwnBadge cred >>= \case Nothing -> throwRedeemError BREUnknownKeyIndex Just False -> throwRedeemError BRECredentialNotVerified @@ -5668,7 +5835,7 @@ storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} let badge = OwnBadge cred (mkBadgeStatus now (Just True) info) -- TODO [badges] retire a previously held badge (user', newBadge, applied) <- withStore $ \db -> do - (purchaseId, newBadge) <- liftIO $ createCodeBadgePurchase db user redemption cred now + (purchaseId, newBadge) <- liftIO $ createStashBadgePurchase db user stash cred now applied <- liftIO $ applyBadgeStatement db g purchaseId badgeType statement (Just cred) now -- a replay must not put a superseded badge back, or tell every contact again user' <- if newBadge then setUserBadge db user (Just badge) else getUser db userId @@ -5818,6 +5985,7 @@ cleanupManager = do cleanupDeliveryJobs `catchAllErrors` eToView -- TODO possibly, also cleanup async commands cleanupProbes `catchAllErrors` eToView + cleanupStoreReceipts `catchAllErrors` eToView liftIO $ threadDelay' $ diffToMicroseconds interval where runWithoutInitialDelay cleanupInterval = flip catchAllErrors eToView $ do @@ -5887,6 +6055,11 @@ cleanupManager = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(14 * nominalDay)) ts withStore' (`deleteOldProbes` cutoffTs) + -- the badge clock, which the records are written with + cleanupStoreReceipts = do + ts <- badgeNow + let cutoffTs = addUTCTime (-(30 * nominalDay)) ts + withStore' (`deleteUnfundedStoreReceipts` cutoffTs) deleteInProgressGroup :: User -> GroupInfo -> CM () deleteInProgressGroup user gInfo = do @@ -6109,6 +6282,8 @@ chatCommandP = "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP), + "/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <*> optional (" invoice=" *> (safeDecodeUtf8 <$> A.takeTill (== ' '))) <* A.space <*> jsonP), + "/_badge invoice " *> (APICreateBadgeInvoice <$> A.decimal), "/_badge state " *> (APIGetBadgeState <$> A.decimal), "/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal), "/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP), diff --git a/src/Simplex/Chat/PaymentService.hs b/src/Simplex/Chat/PaymentService.hs index 38f94b2118..91b7a9fde1 100644 --- a/src/Simplex/Chat/PaymentService.hs +++ b/src/Simplex/Chat/PaymentService.hs @@ -1,17 +1,28 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.PaymentService ( ServiceInvoice (..), ServicePayment (..), + appleTransactionId, + googlePurchaseRef, module Simplex.Chat.PaymentService.Types, ) where +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString.Base64.URL as B64U import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Simplex.Chat.PaymentService.Types +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) data ServiceInvoice = ServiceInvoice { invoiceId :: InvoiceId, @@ -27,11 +38,24 @@ data ServiceInvoice = ServiceInvoice data ServicePayment = SPApple {jws :: Text} - | SPGoogle {token :: Text} + | SPGoogle {productId :: Text, token :: Text} -- the Publisher API finds a purchase by product and token | SPInvoice {invoiceId :: InvoiceId} | SPReceipt {receipt :: Text} -- transfer of unissued months deriving (Show) +-- | Read without verifying the signature: it names the transaction, and proves nothing about it. +appleTransactionId :: Text -> Maybe Text +appleTransactionId signed = case T.splitOn "." signed of + [_, payload, _] -> do + J.Object o <- J.decodeStrict' =<< eitherToMaybe (B64U.decodeUnpadded $ encodeUtf8 payload) + J.String txId <- JM.lookup "transactionId" o + pure txId + _ -> Nothing + +-- | A purchase token is a bearer secret, so a purchase is named by its hash. +googlePurchaseRef :: Text -> Text +googlePurchaseRef = safeDecodeUtf8 . strEncode . C.sha256Hash . encodeUtf8 + $(JQ.deriveJSON defaultJSON ''ServiceInvoice) $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SP") ''ServicePayment) diff --git a/src/Simplex/Chat/PaymentService/Types.hs b/src/Simplex/Chat/PaymentService/Types.hs index 067e170e90..fe05ecbca4 100644 --- a/src/Simplex/Chat/PaymentService/Types.hs +++ b/src/Simplex/Chat/PaymentService/Types.hs @@ -1,6 +1,9 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.PaymentService.Types @@ -8,6 +11,7 @@ module Simplex.Chat.PaymentService.Types InvoiceId (..), PaymentId (..), PaymentProvider (..), + StoreTransactionRef (..), CardProvider (..), CryptoCurrency (..), ServicePaymentMethod (..), @@ -26,7 +30,16 @@ import Data.ByteString.Char8 (ByteString) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word32) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Encoding.String (TextEncoding (..)) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif -- USD etc. are in minor units, following Stripe etc. convention newtype CurrencyAmount = CurrencyAmount Word32 @@ -45,6 +58,31 @@ newtype PaymentId = PaymentId Text data PaymentProvider = PPApple | PPGoogle | PPStripe | PPCrypto | PPCode | PPReceipt deriving (Eq, Show) +instance TextEncoding PaymentProvider where + textEncode = \case + PPApple -> "apple" + PPGoogle -> "google" + PPStripe -> "stripe" + PPCrypto -> "crypto" + PPCode -> "code" + PPReceipt -> "receipt" + textDecode = \case + "apple" -> Just PPApple + "google" -> Just PPGoogle + "stripe" -> Just PPStripe + "crypto" -> Just PPCrypto + "code" -> Just PPCode + "receipt" -> Just PPReceipt + _ -> Nothing + +instance FromField PaymentProvider where fromField = fromTextField_ textDecode + +instance ToField PaymentProvider where toField = toField . textEncode + +-- | The stable name of one store transaction, by which a retry resolves to the same row. +data StoreTransactionRef = StoreTransactionRef {provider :: PaymentProvider, transactionRef :: Text} + deriving (Eq, Show) + data CardProvider = CPStripe deriving (Eq, Show) diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index 19d6d2efef..e749a7fc37 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -7,7 +7,8 @@ {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Badges - ( BadgeCodeRedemption (..), + ( BadgeStash (..), + BadgeStashRef (..), UserBadgePurchase (..), getUserBadgePurchase, getBadgePurchase, @@ -18,9 +19,18 @@ module Simplex.Chat.Store.Badges clearShownBadge, getBadgeCodeRedemption, createBadgeCodeRedemption, + BadgeReceiptRecord (..), + BadgeReceiptStatus (..), + holdStoreReceipt, + createBadgeStoreReceipt, + getOpenStorePurchases, + getNextHeldStoreReceipt, + recordStoreReceiptFailure, + refuseStoreReceipt, + deleteUnfundedStoreReceipts, deleteBadgeCodeRedemption, - createCodeBadgePurchase, - getCodeBadgePurchase, + createStashBadgePurchase, + getStashBadgePurchase, storeBadgeIssuance, getLatestIssuedCredential, storeBadgeStatement, @@ -31,8 +41,10 @@ module Simplex.Chat.Store.Badges where import Control.Concurrent.STM (TVar, atomically) +import Control.Monad (forM, forM_, join) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Maybe (isJust, mapMaybe) @@ -41,9 +53,12 @@ import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) -import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..)) -import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..), OpenStorePurchase (..)) +import Simplex.Chat.PaymentService (ServicePayment) +import Simplex.Chat.PaymentService.Types (StoreTransactionRef (..)) +import Simplex.Chat.Store.Shared (StoreError, insertedRowId) import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C @@ -58,18 +73,20 @@ import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif --- | The keys one redemption attempt is signed with, stashed before the request is sent so that a --- retry reaches the service as the same signer and is answered with the credential already issued. -data BadgeCodeRedemption = BadgeCodeRedemption - { redemptionId :: Int64, +-- | The keys one attempt to fund a badge is signed with, stashed before the request is sent so that +-- a retry reaches the service as the same signer and is answered with the credential already issued. +data BadgeStash = BadgeStash + { stashRef :: BadgeStashRef, purchaseKey :: C.PublicKeyEd25519, purchasePrivKey :: C.PrivateKeyEd25519, masterKey :: BadgeMasterKey } -getBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO (Maybe BadgeCodeRedemption) +data BadgeStashRef = BSRCodeRedemption Int64 | BSRStoreReceipt Int64 + +getBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO (Maybe BadgeStash) getBadgeCodeRedemption db User {userId} code = - maybeFirstRow toRedemption $ + maybeFirstRow (toBadgeStash BSRCodeRedemption) $ DB.query db [sql| @@ -78,11 +95,8 @@ getBadgeCodeRedemption db User {userId} code = WHERE user_id = ? AND code = ? |] (userId, code) - where - toRedemption (redemptionId, purchaseKey, purchasePrivKey, Binary mk) = - BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} -createBadgeCodeRedemption :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO BadgeCodeRedemption +createBadgeCodeRedemption :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO BadgeStash createBadgeCodeRedemption db g User {userId} code now = do (purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g masterKey@(BadgeMasterKey mk) <- generateMasterKey g @@ -94,42 +108,205 @@ createBadgeCodeRedemption db g User {userId} code now = do |] (userId, code, purchaseKey, purchasePrivKey, Binary mk, now) redemptionId <- insertedRowId db - pure BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey} + pure BadgeStash {stashRef = BSRCodeRedemption redemptionId, purchaseKey, purchasePrivKey, masterKey} --- | Drop a stashed attempt whose code the service refused for good, unless a purchase already --- came from it - badge_purchases references this row. -deleteBadgeCodeRedemption :: DB.Connection -> Int64 -> IO () -deleteBadgeCodeRedemption db redemptionId = +-- | Our record of a store receipt, as against the receipt itself: held until the service credits or refuses it. +data BadgeReceiptRecord = BadgeReceiptRecord + { receiptId :: Int64, + ownerId :: UserId, + status :: BadgeReceiptStatus + } + +data BadgeReceiptStatus + = RSHeld {stash :: BadgeStash, payment :: Text, nextAttemptAt :: UTCTime, retryDelay :: Maybe Int64} + | RSCredited {badgePurchaseId :: Int64} + | RSRefused {refusal :: Maybe BadgeIssueFailure} + +-- | Resolves a store transaction to the record that owns it, looking up by its reference again after each step +-- rather than trusting the write: finding none still leaves the echoed invoice's record to attach it to, and a +-- concurrent hand-over of the same transaction may win either write. It stays with the record that already has +-- it, whose keys the service may have credited; failing that it joins the record Buy created; and only when the +-- store echoed no invoice does the presenting profile get a new one, nothing else saying who paid. +holdStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Maybe Text -> StoreTransactionRef -> ServicePayment -> UTCTime -> IO (Maybe BadgeReceiptRecord) +holdStoreReceipt db g User {userId} invoiceId_ txRef@StoreTransactionRef {provider, transactionRef} payment now = + getReceiptRecord db txRef >>= \case + Just r@BadgeReceiptRecord {receiptId} -> do + -- a resolved record stays resolved, so a hand-over landing just after its credit cannot hold it again + DB.execute db "UPDATE badge_store_receipts SET payment = ? WHERE badge_store_receipt_id = ? AND payment IS NOT NULL" (paymentJSON, receiptId) + pure $ Just r + Nothing -> do + forM_ invoiceId_ $ \invoiceId -> + DB.execute + db + "UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, payment = ?, next_attempt_at = ? WHERE invoice_id = ? AND transaction_ref IS NULL" + (provider, transactionRef, paymentJSON, now, invoiceId) + getReceiptRecord db txRef >>= \case + Just r -> pure $ Just r + Nothing -> do + insertReceipt + getReceiptRecord db txRef + where + paymentJSON = safeDecodeUtf8 . LB.toStrict $ J.encode payment + insertReceipt = do + (purchaseKey, purchasePrivKey) <- atomically (C.generateKeyPair g) :: IO C.KeyPairEd25519 + BadgeMasterKey mk <- generateMasterKey g + -- an invoice id another record holds is not repeated: this record is then keyed by its transaction alone + invoiceId' <- fmap join . forM invoiceId_ $ \invoiceId -> do + taken <- DB.query db "SELECT badge_store_receipt_id FROM badge_store_receipts WHERE invoice_id = ?" (Only invoiceId) :: IO [Only Int64] + pure $ if null taken then Just invoiceId else Nothing + DB.execute + db + [sql| + INSERT INTO badge_store_receipts + (user_id, invoice_id, provider, transaction_ref, payment, next_attempt_at, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT DO NOTHING + |] + ((userId, invoiceId', provider, transactionRef, paymentJSON, now) :. (purchaseKey, purchasePrivKey, Binary mk, now)) + +getReceiptRecord :: DB.Connection -> StoreTransactionRef -> IO (Maybe BadgeReceiptRecord) +getReceiptRecord db StoreTransactionRef {provider, transactionRef} = + maybeFirstRow toReceiptRecord $ + DB.query + db + [sql| + SELECT r.badge_store_receipt_id, r.user_id, r.purchase_key, r.purchase_priv_key, r.master_key, + r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error + FROM badge_store_receipts r + LEFT JOIN badge_purchases p ON p.badge_store_receipt_id = r.badge_store_receipt_id + WHERE r.provider = ? AND r.transaction_ref = ? + |] + (provider, transactionRef) + where + toReceiptRecord ((receiptId, ownerId, purchaseKey, purchasePrivKey, mk) :. (payment_, nextAttemptAt_, retryDelay, purchaseId_, refusal)) = + BadgeReceiptRecord {receiptId, ownerId, status} + where + status = case (payment_, nextAttemptAt_) of + (Just payment, Just nextAttemptAt) -> RSHeld {stash = toBadgeStash BSRStoreReceipt (receiptId, purchaseKey, purchasePrivKey, mk), payment, nextAttemptAt, retryDelay} + _ -> maybe (RSRefused refusal) RSCredited purchaseId_ + +-- | The record made when Buy is tapped, before any receipt: the store echoes its invoice id. +createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO () +createBadgeStoreReceipt db g User {userId} invoiceId now = do + (purchaseKey, purchasePrivKey) <- atomically (C.generateKeyPair g) :: IO C.KeyPairEd25519 + BadgeMasterKey mk <- generateMasterKey g + DB.execute + db + [sql| + INSERT INTO badge_store_receipts (user_id, invoice_id, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?) + |] + (userId, invoiceId, purchaseKey, purchasePrivKey, Binary mk, now) + +-- | No receipt yet, or one held: a credited or refused record is resolved and not listed. +getOpenStorePurchases :: DB.Connection -> User -> IO [OpenStorePurchase] +getOpenStorePurchases db User {userId} = + map toOpenStorePurchase + <$> DB.query + db + [sql| + SELECT invoice_id, transaction_ref, credit_error + FROM badge_store_receipts + WHERE user_id = ? AND (transaction_ref IS NULL OR payment IS NOT NULL) + ORDER BY badge_store_receipt_id + |] + (Only userId) + where + toOpenStorePurchase (invoiceId, transactionRef, creditError) = OpenStorePurchase {invoiceId, transactionRef, creditError} + +getNextHeldStoreReceipt :: DB.Connection -> UserId -> IO (Either StoreError (Maybe BadgeReceiptRecord)) +getNextHeldStoreReceipt db userId = + fmap Right . maybeFirstRow toHeld $ + DB.query + db + [sql| + SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.payment, r.next_attempt_at, r.retry_delay + FROM badge_store_receipts r + JOIN users u ON u.user_id = r.user_id + WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL + ORDER BY r.next_attempt_at, r.retry_count, r.badge_store_receipt_id + LIMIT 1 + |] + (Only userId) + where + toHeld (stashRow@(receiptId, _, _, _) :. (payment, nextAttemptAt, retryDelay)) = + BadgeReceiptRecord {receiptId, ownerId = userId, status = RSHeld {stash = toBadgeStash BSRStoreReceipt stashRow, payment, nextAttemptAt, retryDelay}} + +recordStoreReceiptFailure :: DB.Connection -> Int64 -> Int64 -> UTCTime -> BadgeIssueFailure -> IO () +recordStoreReceiptFailure db receiptId retryDelay nextAttemptAt failure = + DB.execute + db + [sql| + UPDATE badge_store_receipts + SET retry_delay = ?, next_attempt_at = ?, retry_count = retry_count + 1, credit_error = ? + WHERE badge_store_receipt_id = ? AND payment IS NOT NULL + |] + (retryDelay, nextAttemptAt, failure, receiptId) + +-- | Kept rather than deleted, so that a later hand-over of the same transaction is answered from the record. +refuseStoreReceipt :: DB.Connection -> Int64 -> BadgeIssueFailure -> IO () +refuseStoreReceipt db receiptId refusal = + DB.execute + db + "UPDATE badge_store_receipts SET payment = NULL, next_attempt_at = NULL, retry_delay = NULL, retry_count = 0, credit_error = ? WHERE badge_store_receipt_id = ?" + (refusal, receiptId) + +-- | A held record is paid for and a credited one is referenced by its purchase, so neither is deleted. +deleteUnfundedStoreReceipts :: DB.Connection -> UTCTime -> IO () +deleteUnfundedStoreReceipts db createdBefore = + DB.execute + db + [sql| + DELETE FROM badge_store_receipts + WHERE payment IS NULL AND created_at < ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_store_receipt_id = badge_store_receipts.badge_store_receipt_id) + |] + (Only createdBefore) + +toBadgeStash :: (Int64 -> BadgeStashRef) -> (Int64, C.PublicKeyEd25519, C.PrivateKeyEd25519, Binary ByteString) -> BadgeStash +toBadgeStash ref (stashId, purchaseKey, purchasePrivKey, Binary mk) = + BadgeStash {stashRef = ref stashId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} + +-- | Drop the keys stashed for a code the service refused for good, unless a purchase already came +-- from them - badge_purchases references their row. +deleteBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO () +deleteBadgeCodeRedemption db User {userId} code = DB.execute db [sql| DELETE FROM badge_code_redemptions - WHERE badge_code_redemption_id = ? - AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?) + WHERE user_id = ? AND code = ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_code_redemption_id = badge_code_redemptions.badge_code_redemption_id) |] - (redemptionId, redemptionId) + (userId, code) --- | 'False' when the code was already redeemed here: the service replays the credential it +-- | 'False' when the stash already funded a purchase here: the service replays the credential it -- issued, and that must add no purchase and leave the shown badge alone. -createCodeBadgePurchase :: DB.Connection -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> IO (Int64, Bool) -createCodeBadgePurchase db User {userId} redemption credential now = - getCodeBadgePurchase db redemption >>= \case +createStashBadgePurchase :: DB.Connection -> User -> BadgeStash -> BadgeCredential -> UTCTime -> IO (Int64, Bool) +createStashBadgePurchase db User {userId} stash credential now = do + -- the credit settles a held receipt in the transaction that stores its purchase, a replay included + forM_ storeReceiptId_ $ \storeReceiptId -> DB.execute db "UPDATE badge_store_receipts SET payment = NULL, next_attempt_at = NULL, retry_delay = NULL, retry_count = 0 WHERE badge_store_receipt_id = ?" (Only storeReceiptId) + getStashBadgePurchase db stash >>= \case Just purchaseId -> pure (purchaseId, False) Nothing -> do DB.execute db [sql| INSERT INTO badge_purchases - (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?) + (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, + badge_code_redemption_id, badge_store_receipt_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, PSIssued, redemptionId, now, now) + ((userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType) :. (PSIssued, redemptionId_, storeReceiptId_, now, now)) purchaseId <- insertedRowId db DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId) pure (purchaseId, True) where - BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption + BadgeStash {stashRef, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = stash BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential + (redemptionId_, storeReceiptId_) = case stashRef of + BSRCodeRedemption redemptionId -> (Just redemptionId, Nothing) + BSRStoreReceipt storeReceiptId -> (Nothing, Just storeReceiptId) -- | The period comes from the ledger, the expiry from the credential, which runs a week longer. -- 'False' means no issuance row was written, which the caller reports rather than drop in silence. @@ -194,10 +371,13 @@ getIssuedPeriod db badgePurchaseId entryId = do [(Just periodStart, periodEnd)] -> Just (periodStart, periodEnd) _ -> Nothing -getCodeBadgePurchase :: DB.Connection -> BadgeCodeRedemption -> IO (Maybe Int64) -getCodeBadgePurchase db BadgeCodeRedemption {redemptionId} = - maybeFirstRow fromOnly $ - DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId) +getStashBadgePurchase :: DB.Connection -> BadgeStash -> IO (Maybe Int64) +getStashBadgePurchase db BadgeStash {stashRef} = + maybeFirstRow fromOnly $ case stashRef of + BSRCodeRedemption redemptionId -> + DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId) + BSRStoreReceipt storeReceiptId -> + DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_store_receipt_id = ?" (Only storeReceiptId) data UserBadgePurchase = UserBadgePurchase { badgePurchaseId :: Int64, @@ -370,7 +550,7 @@ getBadgeLedger db User {userId} badgePurchaseId = toStatementEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType) :. (Maybe UTCTime, UTCTime, Text, Maybe Text, Maybe Text, Maybe Text) -> Maybe StatementEntry toStatementEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) = (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}) - <$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ + <$> maybe (entryTypeFromColumns Nothing entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ -- | Decodes the stored JSON rather than rebuilding from the tag, so a version that has since -- learnt the type reads it with its fields, and one that has not still gets it back verbatim. diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 24ed45c9a9..dd5e812595 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -53,6 +53,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_store_receipts import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -105,7 +106,8 @@ schemaMigrations = ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), - ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs index 98bdd53db7..20fc4fa99d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs @@ -152,6 +152,7 @@ DROP TABLE @badge_prices; |] {- TODO [badges] deferred draft schema for paid purchases, subscriptions, upgrades and transfers. +The service added @badge_purchases.payment_id and @badge_ledger.payment_id in its 20260925_badge_store_receipts, so they left this draft; the client's stay deferred with the rest, as nothing writes its @payments yet. CREATE TABLE @subscription_charges( charge_id TEXT NOT NULL PRIMARY KEY, @@ -165,10 +166,6 @@ CREATE TABLE @subscription_charges( UNIQUE(payment_id, provider_charge_ref) ); -ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; - -CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); - CREATE TABLE @badge_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, @@ -200,16 +197,12 @@ CREATE TABLE @badge_subscription_changes( CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); -ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; - ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; ALTER TABLE @badge_ledger ADD COLUMN from_purchase_id BIGINT REFERENCES @badge_purchases; ALTER TABLE @badge_ledger ADD COLUMN to_purchase_id BIGINT REFERENCES @badge_purchases; -CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); - CREATE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id); CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); @@ -217,11 +210,9 @@ CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); -- down -DROP INDEX @idx_badge_ledger_payment; DROP INDEX @idx_badge_ledger_charge; DROP INDEX @idx_badge_ledger_from_purchase; DROP INDEX @idx_badge_ledger_to_purchase; -ALTER TABLE @badge_ledger DROP COLUMN payment_id; ALTER TABLE @badge_ledger DROP COLUMN charge_id; ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; @@ -231,8 +222,6 @@ DROP INDEX @idx_badge_invoices_purchase; DROP INDEX @idx_badge_invoices_offer; DROP INDEX @idx_badge_invoices_price; DROP TABLE @badge_invoices; -DROP INDEX @idx_badge_purchases_payment; -ALTER TABLE @badge_purchases DROP COLUMN payment_id; DROP TABLE @subscription_charges; -} diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs new file mode 100644 index 0000000000..093031e21d --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs @@ -0,0 +1,51 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_store_receipts where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +-- | invoice_id is null for a receipt naming one another row holds: the transaction still has to be +-- creditable, and its reference identifies it. provider and transaction_ref are null until a receipt +-- arrives, and distinct NULLs let several rows await one at once. payment is held from the receipt's +-- arrival until the service credits or refuses it; a refused row keeps its refusal in credit_error. +m20260925_badge_store_receipts :: Text +m20260925_badge_store_receipts = + [r| +CREATE TABLE badge_store_receipts( + badge_store_receipt_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + invoice_id TEXT UNIQUE, + provider TEXT, + transaction_ref TEXT, + purchase_key BYTEA NOT NULL, + purchase_priv_key BYTEA NOT NULL, + master_key BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + payment TEXT, + next_attempt_at TIMESTAMPTZ, + retry_delay BIGINT, + retry_count BIGINT NOT NULL DEFAULT 0, + credit_error TEXT, + UNIQUE(provider, transaction_ref) +); + +CREATE INDEX idx_badge_store_receipts_user ON badge_store_receipts(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_store_receipt_id BIGINT REFERENCES badge_store_receipts; + +CREATE UNIQUE INDEX idx_badge_purchases_store_receipt ON badge_purchases(badge_store_receipt_id); +|] + +down_m20260925_badge_store_receipts :: Text +down_m20260925_badge_store_receipts = + [r| +DROP INDEX idx_badge_purchases_store_receipt; + +ALTER TABLE badge_purchases DROP COLUMN badge_store_receipt_id; + +DROP INDEX idx_badge_store_receipts_user; + +DROP TABLE badge_store_receipts; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index ad975874db..faf872db07 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -279,7 +279,8 @@ CREATE TABLE test_chat_schema.badge_purchases ( issue_failed_since timestamp with time zone, issue_error_at timestamp with time zone, issue_error text, - next_wake_at timestamp with time zone + next_wake_at timestamp with time zone, + badge_store_receipt_id bigint ); @@ -295,6 +296,36 @@ ALTER TABLE test_chat_schema.badge_purchases ALTER COLUMN badge_purchase_id ADD +CREATE TABLE test_chat_schema.badge_store_receipts ( + badge_store_receipt_id bigint NOT NULL, + user_id bigint NOT NULL, + invoice_id text, + provider text, + transaction_ref text, + purchase_key bytea NOT NULL, + purchase_priv_key bytea NOT NULL, + master_key bytea NOT NULL, + created_at timestamp with time zone NOT NULL, + payment text, + next_attempt_at timestamp with time zone, + retry_delay bigint, + retry_count bigint DEFAULT 0 NOT NULL, + credit_error text +); + + + +ALTER TABLE test_chat_schema.badge_store_receipts ALTER COLUMN badge_store_receipt_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.badge_store_receipts_badge_store_receipt_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.calls ( call_id bigint NOT NULL, contact_id bigint NOT NULL, @@ -1793,6 +1824,21 @@ ALTER TABLE ONLY test_chat_schema.badge_purchases +ALTER TABLE ONLY test_chat_schema.badge_store_receipts + ADD CONSTRAINT badge_store_receipts_invoice_id_key UNIQUE (invoice_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_store_receipts + ADD CONSTRAINT badge_store_receipts_pkey PRIMARY KEY (badge_store_receipt_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_store_receipts + ADD CONSTRAINT badge_store_receipts_provider_transaction_ref_key UNIQUE (provider, transaction_ref); + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_pkey PRIMARY KEY (call_id); @@ -2192,10 +2238,18 @@ CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON test_chat_schema.badg +CREATE UNIQUE INDEX idx_badge_purchases_store_receipt ON test_chat_schema.badge_purchases USING btree (badge_store_receipt_id); + + + CREATE INDEX idx_badge_purchases_user ON test_chat_schema.badge_purchases USING btree (user_id); +CREATE INDEX idx_badge_store_receipts_user ON test_chat_schema.badge_store_receipts USING btree (user_id); + + + CREATE INDEX idx_calls_chat_item_id ON test_chat_schema.calls USING btree (chat_item_id); @@ -2994,11 +3048,21 @@ ALTER TABLE ONLY test_chat_schema.badge_purchases +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_badge_store_receipt_id_fkey FOREIGN KEY (badge_store_receipt_id) REFERENCES test_chat_schema.badge_store_receipts(badge_store_receipt_id); + + + ALTER TABLE ONLY test_chat_schema.badge_purchases ADD CONSTRAINT badge_purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; +ALTER TABLE ONLY test_chat_schema.badge_store_receipts + ADD CONSTRAINT badge_store_receipts_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 4dcefa1598..a5cb6b012d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -176,6 +176,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_store_receipts import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -351,7 +352,8 @@ schemaMigrations = ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), - ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs index 3462db9bc1..8646d8b29e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs @@ -153,6 +153,7 @@ DROP TABLE @badge_prices; |] {- TODO [badges] deferred draft schema for paid purchases, subscriptions, upgrades and transfers. +The service added @badge_purchases.payment_id and @badge_ledger.payment_id in its 20260925_badge_store_receipts, so they left this draft; the client's stay deferred with the rest, as nothing writes its @payments yet. CREATE TABLE @subscription_charges( charge_id TEXT NOT NULL PRIMARY KEY, @@ -166,10 +167,6 @@ CREATE TABLE @subscription_charges( UNIQUE(payment_id, provider_charge_ref) ) STRICT; -ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; - -CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); - CREATE TABLE @badge_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, @@ -201,16 +198,12 @@ CREATE TABLE @badge_subscription_changes( CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); -ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; - ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; ALTER TABLE @badge_ledger ADD COLUMN from_purchase_id INTEGER REFERENCES @badge_purchases; ALTER TABLE @badge_ledger ADD COLUMN to_purchase_id INTEGER REFERENCES @badge_purchases; -CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); - CREATE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id); CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); @@ -218,11 +211,9 @@ CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); -- down -DROP INDEX @idx_badge_ledger_payment; DROP INDEX @idx_badge_ledger_charge; DROP INDEX @idx_badge_ledger_from_purchase; DROP INDEX @idx_badge_ledger_to_purchase; -ALTER TABLE @badge_ledger DROP COLUMN payment_id; ALTER TABLE @badge_ledger DROP COLUMN charge_id; ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; @@ -232,8 +223,6 @@ DROP INDEX @idx_badge_invoices_purchase; DROP INDEX @idx_badge_invoices_offer; DROP INDEX @idx_badge_invoices_price; DROP TABLE @badge_invoices; -DROP INDEX @idx_badge_purchases_payment; -ALTER TABLE @badge_purchases DROP COLUMN payment_id; DROP TABLE @subscription_charges; -} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs new file mode 100644 index 0000000000..bf02837cb9 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs @@ -0,0 +1,50 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_store_receipts where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- | invoice_id is null for a receipt naming one another row holds: the transaction still has to be +-- creditable, and its reference identifies it. provider and transaction_ref are null until a receipt +-- arrives, and distinct NULLs let several rows await one at once. payment is held from the receipt's +-- arrival until the service credits or refuses it; a refused row keeps its refusal in credit_error. +m20260925_badge_store_receipts :: Query +m20260925_badge_store_receipts = + [sql| +CREATE TABLE badge_store_receipts( + badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + invoice_id TEXT UNIQUE, + provider TEXT, + transaction_ref TEXT, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + created_at TEXT NOT NULL, + payment TEXT, + next_attempt_at TEXT, + retry_delay INTEGER, + retry_count INTEGER NOT NULL DEFAULT 0, + credit_error TEXT, + UNIQUE(provider, transaction_ref) +) STRICT; + +CREATE INDEX idx_badge_store_receipts_user ON badge_store_receipts(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_store_receipt_id INTEGER REFERENCES badge_store_receipts; + +CREATE UNIQUE INDEX idx_badge_purchases_store_receipt ON badge_purchases(badge_store_receipt_id); +|] + +down_m20260925_badge_store_receipts :: Query +down_m20260925_badge_store_receipts = + [sql| +DROP INDEX idx_badge_purchases_store_receipt; + +ALTER TABLE badge_purchases DROP COLUMN badge_store_receipt_id; + +DROP INDEX idx_badge_store_receipts_user; + +DROP TABLE badge_store_receipts; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 152d6c0c8d..6886687a23 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1223,8 +1223,17 @@ Plan: Query: INSERT INTO badge_purchases - (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at) + (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, + badge_code_redemption_id, badge_store_receipt_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO badge_store_receipts + (user_id, invoice_id, provider, transaction_ref, payment, next_attempt_at, purchase_key, purchase_priv_key, master_key, created_at) VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT DO NOTHING Plan: @@ -4034,6 +4043,15 @@ Query: Plan: SCAN group_members +Query: + SELECT invoice_id, transaction_ref, credit_error + FROM badge_store_receipts + WHERE user_id = ? AND (transaction_ref IS NULL OR payment IS NOT NULL) + ORDER BY badge_store_receipt_id + +Plan: +SEARCH badge_store_receipts USING INDEX idx_badge_store_receipts_user (user_id=?) + Query: SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, l.was_paused_since, l.service_created_at, l.entry_type, l.entry_credit_type, l.entry_debit_type, l.entry_type_value @@ -4092,6 +4110,30 @@ Query: Plan: SEARCH file_badge_proofs USING INDEX idx_file_badge_proofs_file_id_kind (file_id=?) +Query: + SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.payment, r.next_attempt_at, r.retry_delay + FROM badge_store_receipts r + JOIN users u ON u.user_id = r.user_id + WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL + ORDER BY r.next_attempt_at, r.retry_count, r.badge_store_receipt_id + LIMIT 1 + +Plan: +SEARCH u USING INTEGER PRIMARY KEY (rowid=?) +SEARCH r USING INDEX idx_badge_store_receipts_user (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT r.badge_store_receipt_id, r.user_id, r.purchase_key, r.purchase_priv_key, r.master_key, + r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error + FROM badge_store_receipts r + LEFT JOIN badge_purchases p ON p.badge_store_receipt_id = r.badge_store_receipt_id + WHERE r.provider = ? AND r.transaction_ref = ? + +Plan: +SEARCH r USING INDEX sqlite_autoindex_badge_store_receipts_2 (provider=? AND transaction_ref=?) +SEARCH p USING COVERING INDEX idx_badge_purchases_store_receipt (badge_store_receipt_id=?) LEFT-JOIN + Query: SELECT r.contact_id, g.group_id, r.group_member_id FROM received_probes r @@ -4427,15 +4469,26 @@ SEARCH ug USING AUTOMATIC COVERING INDEX (group_id=?) Query: DELETE FROM badge_code_redemptions - WHERE badge_code_redemption_id = ? - AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?) + WHERE user_id = ? AND code = ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_code_redemption_id = badge_code_redemptions.badge_code_redemption_id) Plan: -SEARCH badge_code_redemptions USING INTEGER PRIMARY KEY (rowid=?) -SCALAR SUBQUERY 1 -SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) +SEARCH badge_code_redemptions USING INDEX sqlite_autoindex_badge_code_redemptions_1 (user_id=? AND code=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH p USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) +Query: + DELETE FROM badge_store_receipts + WHERE payment IS NULL AND created_at < ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_store_receipt_id = badge_store_receipts.badge_store_receipt_id) + +Plan: +SCAN badge_store_receipts +CORRELATED SCALAR SUBQUERY 1 +SEARCH p USING COVERING INDEX idx_badge_purchases_store_receipt (badge_store_receipt_id=?) +SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_store_receipt (badge_store_receipt_id=?) + Query: DELETE FROM chat_items WHERE group_scope_group_member_id = ? @@ -4928,6 +4981,12 @@ Query: Plan: +Query: + INSERT INTO badge_store_receipts (user_id, invoice_id, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?) + +Plan: + Query: INSERT INTO calls (contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at) @@ -5162,6 +5221,14 @@ Query: Plan: SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE badge_store_receipts + SET retry_delay = ?, next_attempt_at = ?, retry_count = retry_count + 1, credit_error = ? + WHERE badge_store_receipt_id = ? AND payment IS NOT NULL + +Plan: +SEARCH badge_store_receipts USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? @@ -7109,6 +7176,7 @@ SEARCH connections USING COVERING INDEX idx_connections_user_contact_link_id (us Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH badge_store_receipts USING COVERING INDEX idx_badge_store_receipts_user (user_id=?) SEARCH badge_code_redemptions USING COVERING INDEX idx_badge_code_redemptions_user (user_id=?) SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_user (user_id=?) SEARCH chat_relays USING COVERING INDEX idx_chat_relays_user_id (user_id=?) @@ -7266,6 +7334,14 @@ Query: SELECT 1 FROM settings WHERE user_id = ? LIMIT 1 Plan: SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) +Query: SELECT COUNT(*) FROM badge_store_receipts +Plan: +SCAN badge_store_receipts USING COVERING INDEX idx_badge_store_receipts_user + +Query: SELECT COUNT(*) FROM badge_store_receipts WHERE payment IS NOT NULL +Plan: +SCAN badge_store_receipts + Query: SELECT COUNT(*) FROM groups WHERE relay_own_status IS NOT NULL Plan: SCAN groups @@ -7365,6 +7441,14 @@ Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption Plan: SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) +Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_store_receipt_id = ? +Plan: +SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_store_receipt (badge_store_receipt_id=?) + +Query: SELECT badge_store_receipt_id FROM badge_store_receipts WHERE invoice_id = ? +Plan: +SEARCH badge_store_receipts USING COVERING INDEX sqlite_autoindex_badge_store_receipts_1 (invoice_id=?) + Query: SELECT balance_checked FROM badge_ledger ORDER BY entry_id Plan: SCAN badge_ledger @@ -7502,6 +7586,10 @@ Query: SELECT count(1) FROM pending_group_messages Plan: SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id +Query: SELECT credit_error FROM badge_store_receipts WHERE payment IS NOT NULL AND credit_error IS NOT NULL +Plan: +SCAN badge_store_receipts + Query: SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ? Plan: SEARCH badge_ledger USING INDEX idx_badge_ledger_uuid (entry_uuid=?) @@ -7768,6 +7856,10 @@ Query: SELECT user_id FROM users WHERE local_display_name = ? Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) +Query: SELECT user_id, invoice_id, transaction_ref IS NOT NULL FROM badge_store_receipts ORDER BY badge_store_receipt_id +Plan: +SCAN badge_store_receipts + Query: SELECT via_contact_uri FROM connections WHERE connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -7792,6 +7884,26 @@ Query: UPDATE badge_purchases SET next_wake_at = ? WHERE badge_purchase_id = ? Plan: SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE badge_store_receipts SET payment = ? +Plan: +SCAN badge_store_receipts + +Query: UPDATE badge_store_receipts SET payment = ? WHERE badge_store_receipt_id = ? AND payment IS NOT NULL +Plan: +SEARCH badge_store_receipts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE badge_store_receipts SET payment = NULL, next_attempt_at = NULL, retry_delay = NULL, retry_count = 0 WHERE badge_store_receipt_id = ? +Plan: +SEARCH badge_store_receipts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE badge_store_receipts SET payment = NULL, next_attempt_at = NULL, retry_delay = NULL, retry_count = 0, credit_error = ? WHERE badge_store_receipt_id = ? +Plan: +SEARCH badge_store_receipts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, payment = ?, next_attempt_at = ? WHERE invoice_id = ? AND transaction_ref IS NULL +Plan: +SEARCH badge_store_receipts USING INDEX sqlite_autoindex_badge_store_receipts_1 (invoice_id=?) + Query: UPDATE chat_items SET item_msg_body = ?, item_chat_binding = ?, item_signatures = ?, item_signed_by_group_member_id = ? WHERE chat_item_id = ? AND include_in_history = 1 Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 995497d5ee..9ef56d8aa9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -938,6 +938,7 @@ CREATE TABLE badge_purchases( issue_error_at TEXT, issue_error TEXT, next_wake_at TEXT, + badge_store_receipt_id INTEGER REFERENCES badge_store_receipts, UNIQUE(purchase_key) ) STRICT; CREATE TABLE badge_ledger( @@ -981,6 +982,23 @@ CREATE TABLE badge_code_redemptions( created_at TEXT NOT NULL, UNIQUE(user_id, code) ) STRICT; +CREATE TABLE badge_store_receipts( + badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + invoice_id TEXT UNIQUE, + provider TEXT, + transaction_ref TEXT, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + created_at TEXT NOT NULL, + payment TEXT, + next_attempt_at TEXT, + retry_delay INTEGER, + retry_count INTEGER NOT NULL DEFAULT 0, + credit_error TEXT, + UNIQUE(provider, transaction_ref) +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1544,6 +1562,10 @@ CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions( CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( badge_code_redemption_id ); +CREATE INDEX idx_badge_store_receipts_user ON badge_store_receipts(user_id); +CREATE UNIQUE INDEX idx_badge_purchases_store_receipt ON badge_purchases( + badge_store_receipt_id +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1c62bc267b..30211ea181 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -46,7 +46,7 @@ import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag) import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..)) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..), OpenStorePurchase (..)) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -193,7 +193,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] - CRBadgeState u st -> ttyUser u $ viewUserBadgeState st + CRBadgeInvoice u invoiceId -> ttyUser u ["badge invoice: " <> plain invoiceId] + CRBadgeState u st storePurchases -> ttyUser u $ viewUserBadgeState st <> map viewOpenStorePurchase storePurchases CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView @@ -483,6 +484,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)] CEvtBadgeChanged u st -> ttyUser u $ viewUserBadgeState st CEvtBadgeAlert u alert -> ttyUser u $ viewBadgeAlert alert + CEvtStorePurchaseResolved u -> ttyUser u ["store purchase resolved"] CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"] CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci @@ -1863,6 +1865,14 @@ viewBadgeIssueError BadgeIssueError {failedSince, lastAttemptAt, reason} = viewBadgeAlert :: BadgeAlert -> [StyledString] viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date] +viewOpenStorePurchase :: OpenStorePurchase -> StyledString +viewOpenStorePurchase OpenStorePurchase {invoiceId, transactionRef, creditError} = + plain $ + "store purchase open: invoice " + <> fromMaybe "none" invoiceId + <> maybe "" (", transaction " <>) transactionRef + <> maybe "" ((", not credited: " <>) . safeDecodeUtf8 . strEncode) creditError + viewBadgeLedger :: [StatementEntry] -> [StyledString] viewBadgeLedger [] = ["no ledger entries"] viewBadgeLedger entries = map viewEntry entries @@ -2867,13 +2877,14 @@ viewChatError isCmd logLevel testView = \case CEBadgeRedeemError e -> let reason = case e of BREInvalidCode -> "invalid code" + BREInvalidReceipt -> "invalid store receipt" BREServiceNotConfigured -> "badge service not configured" BREBadgeActive -> "badge already active" BREServiceError code -> "badge service error: " <> T.unpack (badgeServiceErrorText code) BREInvalidResponse m -> "invalid service response: " <> m BREUnknownKeyIndex -> "credential names an unknown badge key index" BRECredentialNotVerified -> "credential does not verify against configured key" - in ["cannot redeem badge code: " <> plain reason] + in ["cannot get badge: " <> plain reason] CEAgentCommandError e -> ["agent command error: " <> plain e] CEInvalidFileDescription e -> ["invalid file description: " <> plain e] CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index bd8f62d3fd..6bb4b14aa7 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -9,19 +9,26 @@ module BadgeTests (badgeTests) where -import BadgeService.Service (badgeErrorRetryAfter) +import BadgeService.Service (badgeErrorRetryAfter, shownServiceRequest, survive) +import BadgeService.StoreReceipts (StoreReceipt (..), StoreRefusal (..), StoreVerifier (..), VerifiedStoreTransaction (..), toStoreReceipt) +import BadgeService.StoreReceipts.Mock (mockStoreVerifier) +import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) import Control.Concurrent.STM (atomically) +import Control.Exception (SomeAsyncException, SomeException, catch, fromException, throwIO) import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Base64.URL as B64U import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Data.Time.Calendar (fromGregorian) import Data.Time.Calendar.WeekDate (toWeekDate) import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, nominalDay) import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import qualified Data.Aeson as J import qualified Data.Aeson.KeyMap as KM -import Data.Maybe (fromMaybe, isNothing, maybeToList) +import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges import Simplex.Chat.Badges.Code @@ -29,14 +36,16 @@ import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service import Simplex.Chat.Badges.Types (BadgeIssueFailure (..)) import Simplex.Chat (defaultChatConfig) -import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent) -import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeFailureTransient, badgeIssueFailure, badgeRetryAfter, badgeServiceErrorText, badgeStalledInterval) +import Simplex.Chat.Controller (BadgeRedeemError (..), ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent) +import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeFailureTransient, badgeIssueFailure, badgeRetryAfter, badgeServiceErrorText, badgeStalledInterval, storeTransactionRef) +import Simplex.Chat.PaymentService (ServicePayment (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId (..), PaymentProvider (..), StoreTransactionRef (..)) import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay) import Simplex.Messaging.Crypto.BBS import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BrokerErrorType (..), ErrorType (AUTH), NetworkError (..)) -import Simplex.Messaging.Util (tshow) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import Simplex.Messaging.Version.Internal (Version (..)) import Test.Hspec @@ -92,9 +101,18 @@ badgeTests = do it "bounds and strips a code this version does not know" testServiceErrorCodeBounded describe "service protocol JSON" $ do it "redeemBadgeCode request matches the schema" testRedeemRequestJSON + it "purchaseBadge request matches the schema" testPurchaseRequestJSON it "badgeCredential response matches the schema" testCredentialResponseJSON it "error response matches the schema" testErrorResponseJSON it "statement entries round-trip, unknown entry types verbatim" testStatementJSON + describe "store purchases" $ do + it "keys a purchase by the store's transaction id, not by the evidence signed over it" testStoreTransactionRef + it "shows a service request in the terminal as its type alone" testShownServiceRequest + it "refuses for good a Play product id that could name another purchase, before any verifier" testGoogleProductIdPath + it "does not send a Play token that could name another purchase, and leaves it retryable" testGoogleTokenPath + it "has the dev mock vouch for the transaction its claim names, as a production purchase" testMockVouchesForClaim + describe "badge service request loop" $ do + it "survives a request that throws, and still stops when cancelled" testSurviveRequestFailure proofOf :: BadgeProof -> BBSProof proofOf (BadgeProof _ _ p _) = p @@ -682,7 +700,7 @@ testServiceRetryAfter = do badgeErrorRetryAfter BSEInternal `shouldBe` Nothing mapM_ (\code -> badgeErrorRetryAfter code `shouldBe` Nothing) - [BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEUnknown "future_code"] + [BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEProviderNotConfigured, BSEUnknown "future_code"] -- The app shows the recorded failure in a sentence, so an agent error is stored as the agent -- error and not as the chat error wrapping it, whether or not it can clear on its own. @@ -695,6 +713,8 @@ testIssueFailureClassification = do failureFor timeout `shouldBe` BIFNetwork {agentError = tshow timeout} failureFor auth `shouldBe` BIFUnexpected {message = tshow auth} badgeIssueFailure (ChatError (CECommandError "unexpected badge service response")) `shouldBe` BIFUnexpected {message = "unexpected badge service response"} + badgeIssueFailure (ChatError (CEBadgeRedeemError BREUnknownKeyIndex)) `shouldBe` BIFInvalidCredential + badgeIssueFailure (ChatError (CEBadgeRedeemError BRECredentialNotVerified)) `shouldBe` BIFInvalidCredential -- A refusal the service marks transient is not worth a word before the credential lapses. Internal -- comes without retryAfter so that a failing service is not pressed, not because the fault is @@ -753,12 +773,18 @@ testEntryTypeColumns = do debits = [SDRefund, SDUpgrade k, SDTransferOut k, SDSupport, SDBadge, SDLapse] mapM_ (\c -> wireTag (J.toJSON (SECredit c)) "credit" `shouldBe` Just (creditTypeTag c)) credits mapM_ (\d -> wireTag (J.toJSON (SEDebit d)) "debit" `shouldBe` Just (debitTypeTag d)) debits - -- the three types this version writes survive a round trip through the columns + -- the types this version writes survive a round trip through the columns, a payment credit + -- through the payment its row references mapM_ - (\t -> uncurry3 entryTypeFromColumns (entryTypeColumns t) `shouldSatisfy` sameEntryType t) + (\t -> uncurry3 (entryTypeFromColumns Nothing) (entryTypeColumns t) `shouldSatisfy` sameEntryType t) [SECredit SCCode, SEDebit SDBadge, SEDebit SDLapse] + let storeCredit = SECredit (SCPayment Nothing) + invoiceCredit = SECredit (SCPayment (Just (InvoiceId "inv1"))) + uncurry3 (entryTypeFromColumns (Just Nothing)) (entryTypeColumns storeCredit) `shouldSatisfy` sameEntryType storeCredit + uncurry3 (entryTypeFromColumns (Just (Just (InvoiceId "inv1")))) (entryTypeColumns invoiceCredit) `shouldSatisfy` sameEntryType invoiceCredit -- a type that needs a reference column is not silently read back as something else - uncurry3 entryTypeFromColumns (entryTypeColumns (SECredit (SCCharge "ch1"))) `shouldSatisfy` isNothing + uncurry3 (entryTypeFromColumns Nothing) (entryTypeColumns storeCredit) `shouldSatisfy` isNothing + uncurry3 (entryTypeFromColumns Nothing) (entryTypeColumns (SECredit (SCCharge "ch1"))) `shouldSatisfy` isNothing -- which is why every type is stored as its own JSON as well, and read from that first: the -- columns alone would answer a row naming an invoice or a purchase as no row at all mapM_ roundTrips credits @@ -790,6 +816,106 @@ testRedeemRequestJSON = do `shouldBe` J.object ["version" J..= (1 :: Int), "request" J..= J.object ["type" J..= ("getBadgeCatalog" :: T.Text)]] roundTrips req +testPurchaseRequestJSON :: IO () +testPurchaseRequestJSON = do + drg <- C.newRandom + mk <- generateMasterKey drg + (k, _) <- atomically $ C.generateKeyPair drg :: IO (C.KeyPair 'C.Ed25519) + let payment = SPGoogle {productId = "badge_supporter_01", token = "token"} + req = BadgeServiceRequest {version = Version 1, purchaseKey = Just k, request = BSCPurchaseBadge {masterKey = mk, payment, upgrade = Nothing}} + -- the client states its master key and the store's evidence, and nothing a receipt already decides + J.toJSON req + `shouldBe` J.object + [ "version" J..= (1 :: Int), + "purchaseKey" J..= k, + "request" + J..= J.object + [ "type" J..= ("purchaseBadge" :: T.Text), + "masterKey" J..= mk, + "payment" J..= J.object ["type" J..= ("google" :: T.Text), "productId" J..= ("badge_supporter_01" :: T.Text), "token" J..= ("token" :: T.Text)] + ] + ] + J.toJSON SPApple {jws = "a.b.c"} `shouldBe` J.object ["type" J..= ("apple" :: T.Text), "jws" J..= ("a.b.c" :: T.Text)] + roundTrips req + +testStoreTransactionRef :: IO () +testStoreTransactionRef = do + let jws transactionId signature = T.intercalate "." [part "{\"alg\":\"ES256\"}", part ("{\"transactionId\":\"" <> transactionId <> "\",\"productId\":\"BADGE_SUPPORTER_01\"}"), signature] + part = safeDecodeUtf8 . B64U.encodeUnpadded . encodeUtf8 + apple = storeTransactionRef . SPApple + -- the store may sign the same transaction again, and a retry must find the keys it stashed + apple (jws "2000000812345671" "c2lnbmVkIG9uY2U") `shouldBe` Just (StoreTransactionRef PPApple "2000000812345671") + apple (jws "2000000812345671" "c2lnbmVkIGFnYWlu") `shouldBe` apple (jws "2000000812345671" "c2lnbmVkIG9uY2U") + apple (jws "2000000812345672" "c2lnbmVkIG9uY2U") `shouldNotBe` apple (jws "2000000812345671" "c2lnbmVkIG9uY2U") + apple "not.a-jws" `shouldBe` Nothing + apple (T.intercalate "." [part "{}", part "{\"productId\":\"BADGE_SUPPORTER_01\"}", "sig"]) `shouldBe` Nothing + -- a Play token is a bearer secret, so it is kept only as its hash + let google = storeTransactionRef SPGoogle {productId = "badge_supporter_01", token = "play-token"} + google `shouldSatisfy` maybe False (\(StoreTransactionRef provider ref) -> provider == PPGoogle && ref /= "play-token") + google `shouldBe` storeTransactionRef SPGoogle {productId = "badge_supporter_01", token = "play-token"} + storeTransactionRef SPInvoice {invoiceId = InvoiceId "inv"} `shouldBe` Nothing + +testShownServiceRequest :: IO () +testShownServiceRequest = do + drg <- C.newRandom + mk <- generateMasterKey drg + (k, _) <- atomically $ C.generateKeyPair drg :: IO (C.KeyPair 'C.Ed25519) + let shown request = case J.toJSON BadgeServiceRequest {version = Version 1, purchaseKey = Just k, request} of + J.Object o -> J.Object (shownServiceRequest o) + _ -> J.Null + typeOnly t = J.object ["request" J..= J.object ["type" J..= (t :: T.Text)]] + shown BSCPurchaseBadge {masterKey = mk, payment = SPApple {jws = "a.b.c"}, upgrade = Nothing} `shouldBe` typeOnly "purchaseBadge" + shown BSCRedeemBadgeCode {masterKey = mk, code = "SB-00000-00000-00000-00001"} `shouldBe` typeOnly "redeemBadgeCode" + +testGoogleProductIdPath :: IO () +testGoogleProductIdPath = do + let refused productId = case toStoreReceipt uncalledVerifier SPGoogle {productId, token = validPlayToken} of + Just (Left SRInvalid {}) -> True + _ -> False + mapM_ (\p -> refused p `shouldBe` True) ["badge_legend_01/tokens/other?", "badge_legend_01?x", "badge_legend_01#x", "..", "../badge_legend_01", "Badge_legend_01", ""] + case toStoreReceipt uncalledVerifier SPGoogle {productId = "badge_supporter_01", token = validPlayToken} of + Just (Right StoreReceipt {txRef = StoreTransactionRef {provider}}) -> provider `shouldBe` PPGoogle + _ -> expectationFailure "a valid product id and token were refused" + +testGoogleTokenPath :: IO () +testGoogleTokenPath = do + let unsent token = case toStoreReceipt uncalledVerifier SPGoogle {productId = "badge_supporter_01", token} of + Just (Left SRUnreachable {}) -> True + _ -> False + mapM_ (\t -> unsent t `shouldBe` True) ["a/b", "../x", "t?x", "t#x", "t x", ""] + +uncalledVerifier :: StoreVerifier +uncalledVerifier = StoreVerifier {verifyApple = Nothing, verifyGoogle = Just $ \_ _ -> error "verifier called", verifyTimeout = 500000} + +validPlayToken :: T.Text +validPlayToken = "fake-play-token.AO-J1Oz9x2kqE7wYt3" + +testMockVouchesForClaim :: IO () +testMockVouchesForClaim = do + let part = safeDecodeUtf8 . B64U.encodeUnpadded . encodeUtf8 + signed = T.intercalate "." [part "{\"alg\":\"ES256\"}", part "{\"transactionId\":\"2000000812345671\",\"productId\":\"BADGE_SUPPORTER_01\"}", "c2lnbmVk"] + vouchesForClaim payment = case toStoreReceipt mockStoreVerifier payment of + Just (Right StoreReceipt {txRef = StoreTransactionRef {transactionRef = providerRef}, verifyReceipt}) -> + verifyReceipt >>= \case + Right VerifiedStoreTransaction {transactionRef, testPurchase} -> (transactionRef, testPurchase) `shouldBe` (providerRef, False) + Left refusal -> expectationFailure ("the mock refused: " <> show refusal) + _ -> expectationFailure "refused before the mock was asked" + vouchesForClaim SPApple {jws = signed} + vouchesForClaim SPGoogle {productId = "badge_supporter_01", token = "fake-play-token.AO-J1Oz9x2kqE7wYt3"} + +testSurviveRequestFailure :: IO () +testSurviveRequestFailure = do + survive "a request" (throwIO $ userError "failed") `shouldReturn` () + started <- newEmptyMVar + stopped <- newEmptyMVar + t <- forkIO $ survive "a request" (putMVar started () >> threadDelay 10000000) `catch` (putMVar stopped . asyncException) + takeMVar started + killThread t + takeMVar stopped `shouldReturn` True + where + asyncException :: SomeException -> Bool + asyncException e = isJust (fromException e :: Maybe SomeAsyncException) + testCredentialResponseJSON :: IO () testCredentialResponseJSON = do Right (_, sk) <- bbsKeyGen diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index c11af7f4d1..93b24fd427 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PostfixOperators #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -12,9 +13,12 @@ module Bots.BadgeService.BotTests where import BadgeService.Config (BadgeIssuerKey (..), readServiceConfig) import Bots.BadgeService.ConfigTests (withIssuer) +import Bots.BadgeService.FakeStore import BadgeService.Options import BadgeService.Service +import BadgeService.Store (NewStorePurchase (..), createStorePurchase) import BadgeService.Store.Invoices (markCodePaid) +import BadgeService.StoreReceipts (StoreRefusal (..), StoreVerifier (..), noStoreVerifier) import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import ChatClient @@ -27,10 +31,12 @@ import Control.Exception (finally) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (toLower) import Data.Either (isLeft, isRight) import Data.Int (Int64) -import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef) +import Data.List (isPrefixOf, stripPrefix) import qualified Data.Map.Strict as M import Data.Maybe (isJust, isNothing) import Data.String (fromString) @@ -39,15 +45,19 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay) import Data.Time.Format (defaultTimeLocale, formatTime) +import Data.Word (Word32) import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType (..), generateMasterKey) import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeHash, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode) -import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, endOfMondayAfter) +import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, emptyEntry, endOfMondayAfter) import Simplex.Chat.Badges.Service import Simplex.Chat.Bot.Store (withDB') import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (CRCustomChatResponse)) import Simplex.Chat.Core (sendChatCmdStr) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Chat.PaymentService (ServicePayment (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId (..), PaymentProvider (..)) +import Simplex.Chat.Store.Badges (getNextHeldStoreReceipt) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) import Simplex.Messaging.Agent.Store.Common (withTransaction) @@ -106,6 +116,56 @@ badgeServiceTests = do it "should renew a badge on a profile that is not active, without switching to it" testRenewalKeepsActiveProfile it "should broadcast the current profile when a renewal presents a badge" testRenewalKeepsProfileEdits it "should present the month already issued when a previous pass did not" testPresentationCatchesUp + describe "store purchases" $ do + describe "what the store answers" $ do + it "should credit the months a store receipt paid for and issue the first" testStorePurchase + it "should return the same credential for a receipt its key presents again, writing nothing" testStorePurchaseReplay + it "should refuse a receipt another key was credited with" testStoreReceiptUsed + it "should refuse a receipt the store does not vouch for, with no retry" testStoreReceiptInvalid + it "should answer a store it cannot reach as retryable, writing nothing" testStoreUnreachable + it "should write nothing for a pending purchase, and credit it once when it settles" testStorePending + it "should refuse a product that grants no badge" testStoreUnknownProduct + it "should refuse a store purchase that carries an upgrade" testStoreUpgradeRefused + it "should grant the badge of the product the receipt proves" testStoreBadgeTypeFromProduct + it "should keep refusing invoice and receipt funding" testNonStoreFundingRefused + it "should replay a receipt to its own key while the store is down, and to no other" testStoreReplayWhileStoreDown + it "should answer a throwing Apple verifier as internal, and a failing or hanging Google one as retryable" testStoreVerifierFailures + describe "claim races" $ do + it "should credit a transaction claimed twice at once only once" testStoreClaimRace + it "should answer a request that lost the claim race with the credential the winner was given" testStorePurchaseRace + it "should refuse as receipt_used a key that lost the claim race to another key, crediting nothing" testStorePurchaseRaceOtherKey + describe "the service's own checks" $ do + it "should refuse a store with no verifier with no retry, and the client should hold the receipt, showing why it is not credited" testPurchaseWithNoVerifier + it "should credit nothing when the verified transaction is not the one the evidence names" testStoreVerifiedOtherTransaction + it "should refuse a verified quantity other than one as internal, and the client should hold the receipt" testStoreQuantityRefused + it "should refuse a store purchase whose purchaseKey is not the verified signer" testStorePurchaseKeyMismatch + describe "the hand-over" $ do + it "should hold a Play receipt until the worker credits it, and answer it as credited after" testPurchaseBadge + it "should credit an App Store purchase by its JWS" testPurchaseBadgeAppStore + it "should keep a refusal on the record, and hold a pending receipt until it settles" testPurchaseStash + it "should keep a receipt credited to another key as refused" testPurchaseStashReceiptUsed + it "should hold a Play token the service will not send to Play" testPurchaseUnsentPlayToken + it "should hold a store receipt while a badge is held, without attempting it" testPurchaseWhileBadgeHeld + it "should answer a receipt handed over under a second profile as the profile that bought it" testPurchaseSameReceiptOtherProfile + it "should credit a purchase first handed over under another profile to that profile" testPurchaseStrandedUnderOtherProfile + it "should credit a purchase to a hidden profile without naming it" testPurchaseDeliveredToHiddenProfile + describe "invoice first" $ do + it "should credit a receipt to the profile that created its invoice, and answer as that profile" testInvoiceOtherProfile + it "should resolve the same receipt to the same record, and answer it as credited" testInvoiceSameReceiptTwice + it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown + it "should attach a late receipt to its record, and credit the profile that created it" testInvoiceLateReceipt + it "should list a record however old until a receipt reaching it is settled" testInvoiceAgedOut + it "should refuse an invoice with no service configured or while a badge is held, creating no record" testInvoiceRefusedBeforeCharge + it "should hold a receipt for an invoice while a badge is held, without attempting it" testInvoiceWhileBadgeHeld + it "should list only the asking profile's open store purchases" testInvoiceStateOtherProfile + describe "the receipt worker" $ do + it "should retry a receipt the service could not verify, and credit it with no trigger at all" testStoreReceiptRetried + it "should credit a second receipt while an earlier one waits for the store" testStoreReceiptNotBlocked + it "should answer a refused receipt from the record, sending nothing, and not list it" testStoreRefusalKept + it "should announce a settlement to an owner that is not active, and print nothing for a hidden one" testStoreSettledOtherProfile + it "should resolve one new transaction handed over twice at once to one record" testStoreReceiptTwoAtOnce + it "should delete a month-old record that will fund no badge, and keep held and credited ones" testStoreReceiptCleanup + it "should keep a receipt whose attempt throws" testStoreReceiptAttemptThrows badgeProfile :: Profile badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} @@ -157,7 +217,9 @@ data BadgeServiceEnv = BadgeServiceEnv bsClock :: TestClock, bsClientCfg :: ChatConfig, bsAddress :: String, - bsController :: ChatController + bsController :: ChatController, + bsStore :: FakeStore, + bsVerifier :: StoreVerifier } -- | Stop the service for good: requests sent after it go unanswered until they time out. Stopping @@ -170,13 +232,18 @@ withBadgeService ps test = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsAddress, bsController} -> test bsClientCfg bsAddress bsController withBadgeServiceEnv :: HasCallStack => TestParams -> (BadgeServiceEnv -> IO ()) -> IO () -withBadgeServiceEnv ps test = do +withBadgeServiceEnv ps = withBadgeServiceVerifier ps fakeVerifier + +withBadgeServiceVerifier :: HasCallStack => TestParams -> (FakeStore -> StoreVerifier) -> (BadgeServiceEnv -> IO ()) -> IO () +withBadgeServiceVerifier ps verifierOf test = do Right (pk, sk) <- bbsKeyGen clock <- newTestClock + store <- newFakeStore let opts = mkBadgeServiceOpts ps sk svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk, badgeCurrentTime = testClockTime clock} + verifier = verifierOf store withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () - runBadgeService svcCfg opts $ \_ -> pure () + runBadgeService verifier svcCfg opts $ \_ -> pure () bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do bs <## "subscribed 1 connections on server localhost" bs ##> "/sa" @@ -185,9 +252,9 @@ withBadgeServiceEnv ps test = do pure sLink let clientCfg = svcCfg {badgeServiceAddress = Just $ either (error . ("bad badge service address: " <>)) id $ strDecode (B.pack bsLink)} - runBadgeService svcCfg opts $ \env -> do + runBadgeService verifier svcCfg opts $ \env -> do cc <- atomically $ readTMVar $ serviceCC env - test BadgeServiceEnv {bsIssuerKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}, bsClock = clock, bsClientCfg = clientCfg, bsAddress = bsLink, bsController = cc} + test BadgeServiceEnv {bsIssuerKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}, bsClock = clock, bsClientCfg = clientCfg, bsAddress = bsLink, bsController = cc, bsStore = store, bsVerifier = verifier} issueCode :: HasCallStack => ChatController -> BadgeType -> Int -> IO BadgeCode issueCode cc badgeType months = issueCodeAs cc badgeType months "free" @@ -208,9 +275,9 @@ issueCodeAs cc badgeType months status = r -> error $ "issue failed: " <> show (() <$ r) -- | The post-start hook fills serviceCC once the address exists, so the test waits on it rather than on a fixed delay that would race with startup and let one start's address output arrive during the next test. -runBadgeService :: ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () -runBadgeService cfg opts action = do - env <- newServiceState +runBadgeService :: StoreVerifier -> ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () +runBadgeService verifier cfg opts action = do + env <- (\s -> s {storeVerifier = verifier}) <$> newServiceState t <- forkIO $ badgeService opts cfg env ready <- timeout 30000000 $ atomically $ readTMVar $ serviceCC env when (isNothing ready) $ killThread t >> error "badge service did not start" @@ -276,10 +343,10 @@ testRedeemUnknownCode ps = g <- C.newRandom unknown <- randomBadgeCode g alice ##> ("/_redeem_badge_code 1 " <> codeArg unknown) - alice <## "cannot redeem badge code: badge service error: code_invalid" + alice <## "cannot get badge: badge service error: code_invalid" -- a failed check character is refused before anything leaves the device alice ##> "/_redeem_badge_code 1 SB-00000-00000-00000-00001" - alice <## "cannot redeem badge code: invalid code" + alice <## "cannot get badge: invalid code" -- sent straight to the service, past the client's own check, the two are one answer (_, redeemPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) redeemDirect alice bsLink redeemPriv (T.unpack $ badgeCodeText unknown) @@ -330,7 +397,7 @@ testRedeemSecondCode ps = alice <## "supporter badge - active" alice <##. "expires " alice ##> ("/_redeem_badge_code 1 " <> codeArg legend) - alice <## "cannot redeem badge code: badge already active" + alice <## "cannot get badge: badge already active" alice ##> "/p" showActiveUser alice "alice (Alice, * supporter)" alice ##> "/create user alisa" @@ -352,15 +419,15 @@ testRedeemSameCodeOtherProfile ps = alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> ("/_redeem_badge_code 2 " <> codeArg code) - alice <## "cannot redeem badge code: badge service error: code_used" + alice <## "cannot get badge: badge service error: code_used" alice ##> "/p" showActiveUser alice "alisa" alice ##> "/user alice" showActiveUser alice "alice (Alice, * supporter)" serviceCmd :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse -serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} purchaseKey request = - badgeServiceResponse bsIssuerKey bsController (Just purchaseKey) reqObject +serviceCmd BadgeServiceEnv {bsIssuerKey, bsController, bsVerifier} purchaseKey request = + badgeServiceResponse bsIssuerKey bsVerifier bsController (Just purchaseKey) reqObject where reqObject = case J.toJSON BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} of J.Object o -> o @@ -1267,7 +1334,7 @@ testRedeemUnpaidCode ps = withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do unpaid <- issueCodeAs cc BTSupporter 1 "unpaid" alice ##> ("/_redeem_badge_code 1 " <> codeArg unpaid) - alice <## "cannot redeem badge code: badge service error: payment_pending" + alice <## "cannot get badge: badge service error: payment_pending" paid <- issueCodeAs cc BTSupporter 1 "paid" alice ##> ("/_redeem_badge_code 1 " <> codeArg paid) alice <## "badge redeemed" @@ -1284,7 +1351,7 @@ testExpiredCode ps = withDB' "markCodePaid" cc (\db -> markCodePaid db (badgeCodeHash code) (addUTCTime (-60) now)) `shouldReturn` Right () alice ##> ("/_redeem_badge_code 1 " <> codeArg code) - alice <## "cannot redeem badge code: badge service error: code_expired" + alice <## "cannot get badge: badge service error: code_expired" testRedeemedBeforeTheDeadline :: HasCallStack => TestParams -> IO () testRedeemedBeforeTheDeadline ps = @@ -1338,6 +1405,784 @@ testRevokedCode ps = paid <- issueCodeAs cc BTSupporter 1 "paid" revokeCodeAs cc paid `shouldReturn` "revoked" alice ##> ("/_redeem_badge_code 1 " <> codeArg paid) - alice <## "cannot redeem badge code: badge service error: code_invalid" + alice <## "cannot get badge: badge service error: code_invalid" second <- revokeCodeAs cc paid second `shouldSatisfy` T.isInfixOf "revoked already" + +purchaseCmd :: BadgeMasterKey -> ServicePayment -> BadgeServiceCommand +purchaseCmd masterKey payment = BSCPurchaseBadge {masterKey, payment, upgrade = Nothing} + +supporterPlay :: ServicePayment +supporterPlay = googlePayment "badge_supporter_01" googleSupporterToken + +paymentArg :: ServicePayment -> String +paymentArg = LB.unpack . J.encode + +refusalOf :: HasCallStack => BadgeServiceResponse -> (BadgeServiceErrorCode, Maybe Word32) +refusalOf = \case + BSPError {code, retryAfter} -> (code, retryAfter) + r -> error $ "expected an error, got " <> show (J.toJSON r) + +badgeTypeOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeType +badgeTypeOf r = (\(BadgeCredential _ _ _ BadgeInfo {badgeType}) -> badgeType) <$> credentialOf r + +rowCount :: ChatController -> String -> IO Int +rowCount ChatController {chatStore} table = + withTransaction chatStore $ \db -> do + [Only n] <- DB.query_ db $ fromString $ "SELECT COUNT(*) FROM " <> table + pure n + +-- | Each store payment's provider, amount and currency, and the ledger rows that name it. +storePayments :: ChatController -> IO [(Text, Maybe Int, Maybe Text, Int)] +storePayments ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.query_ db . fromString $ + "SELECT pay.provider, pay.amount, pay.currency, COUNT(l.entry_id) FROM sx_badge_service_payments pay " + <> "LEFT JOIN sx_badge_service_badge_ledger l ON l.payment_id = pay.payment_id " + <> "GROUP BY pay.payment_id, pay.provider, pay.amount, pay.currency ORDER BY pay.provider" + +nothingPurchased :: HasCallStack => ChatController -> IO () +nothingPurchased cc = do + rowCount cc "sx_badge_service_payments" `shouldReturn` 0 + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 0 + +testStorePurchase :: HasCallStack => TestParams -> IO () +testStorePurchase ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + purchased <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + let (entries, previousEntryId) = statementOf purchased + previousEntryId `shouldBe` Nothing + map entryTag entries `shouldBe` ["payment", "badge"] + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries `shouldBe` [(1, 1), (-1, 0)] + badgeTypeOf purchased `shouldBe` Just BTSupporter + masterKeyOf purchased `shouldBe` Just masterKey + map (\(_, c, m, _, _, t) -> (c, m, t)) <$> ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` [(1, 1, Just "payment"), (-1, 0, Just "badge")] + -- Play reports no price, and only the credit names the payment + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + +testStorePurchaseReplay :: HasCallStack => TestParams -> IO () +testStorePurchaseReplay ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + purchased <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + ledger <- ledgerRows cc "sx_badge_service_badge_ledger" + replayed <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + credentialOf replayed `shouldBe` credentialOf purchased + map entryTag (fst $ statementOf replayed) `shouldBe` map entryTag (fst $ statementOf purchased) + ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStoreReceiptUsed :: HasCallStack => TestParams -> IO () +testStoreReceiptUsed ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (firstKey, firstMasterKey) <- newPurchaseKeys + _ <- serviceCmd env firstKey $ purchaseCmd firstMasterKey supporterPlay + ledger <- ledgerRows cc "sx_badge_service_badge_ledger" + (otherKey, otherMasterKey) <- newPurchaseKeys + used <- serviceCmd env otherKey $ purchaseCmd otherMasterKey supporterPlay + refusalOf used `shouldBe` (BSEReceiptUsed, Nothing) + ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStoreReceiptInvalid :: HasCallStack => TestParams -> IO () +testStoreReceiptInvalid ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleSandboxJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + let refused payment = refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey payment) + refused (googlePayment "badge_supporter_01" "not-a-purchase") `shouldReturn` (BSEReceiptInvalid, Nothing) + refused SPApple {jws = "not.a.jws"} `shouldReturn` (BSEReceiptInvalid, Nothing) + -- refused by the service, which the store would have vouched for + refused SPApple {jws = appleSandboxJWS} `shouldReturn` (BSEReceiptInvalid, Nothing) + nothingPurchased cc + +testStoreUnreachable :: HasCallStack => TestParams -> IO () +testStoreUnreachable ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + unreachable <- serviceCmd env purchaseKey $ purchaseCmd masterKey $ googlePayment "badge_supporter_01" googleUnreachableToken + refusalOf unreachable `shouldSatisfy` \(code, retryAfter) -> code == BSEProviderUnavailable && isJust retryAfter + nothingPurchased cc + +testStorePending :: HasCallStack => TestParams -> IO () +testStorePending ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = store} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + let unsettled = purchaseCmd masterKey $ googlePayment "badge_supporter_01" googlePendingToken + waiting <- serviceCmd env purchaseKey unsettled + refusalOf waiting `shouldSatisfy` \(code, retryAfter) -> code == BSEPaymentPending && isJust retryAfter + nothingPurchased cc + settlePending store + settled <- serviceCmd env purchaseKey unsettled + badgeTypeOf settled `shouldBe` Just BTSupporter + again <- serviceCmd env purchaseKey unsettled + credentialOf again `shouldBe` credentialOf settled + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + +testStoreUnknownProduct :: HasCallStack => TestParams -> IO () +testStoreUnknownProduct ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + -- a real purchase of a subscription, whose renewals nothing adds to a badge yet + subscription <- serviceCmd env purchaseKey $ purchaseCmd masterKey $ googlePayment "subscr_badge_supporter_01" googleSubscriptionToken + refusalOf subscription `shouldBe` (BSEProductUnavailable, Nothing) + nothingPurchased cc + +testStoreUpgradeRefused :: HasCallStack => TestParams -> IO () +testStoreUpgradeRefused ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + g <- C.newRandom + (fromPurchaseKey, fromPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + now <- getCurrentTime + let upgrade = BadgeUpgrade {fromPurchaseKey, receipt = "receipt", receiptSignature = C.sign' fromPriv "receipt", balance = BadgeBalance {lastEntry = emptyEntry now BTSupporter}} + upgraded <- serviceCmd env purchaseKey BSCPurchaseBadge {masterKey, payment = supporterPlay, upgrade = Just upgrade} + refusalOf upgraded `shouldBe` (BSEBadRequest, Nothing) + nothingPurchased cc + +testStoreBadgeTypeFromProduct :: HasCallStack => TestParams -> IO () +testStoreBadgeTypeFromProduct ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleLegendJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + legend <- serviceCmd env purchaseKey $ purchaseCmd masterKey SPApple {jws = appleLegendJWS} + badgeTypeOf legend `shouldBe` Just BTLegend + map (\StatementEntry {balanceBadgeType} -> balanceBadgeType) (fst $ statementOf legend) `shouldBe` [BTLegend, BTLegend] + storePayments cc `shouldReturn` [("apple", Just 7000, Just "USD", 1)] + +testNonStoreFundingRefused :: HasCallStack => TestParams -> IO () +testNonStoreFundingRefused ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + invoice <- serviceCmd env purchaseKey $ purchaseCmd masterKey SPInvoice {invoiceId = InvoiceId "inv"} + refusalOf invoice `shouldBe` (BSEUnknownPurchaseKey, Nothing) + transfer <- serviceCmd env purchaseKey $ purchaseCmd masterKey SPReceipt {receipt = "receipt"} + refusalOf transfer `shouldBe` (BSEUnknownPurchaseKey, Nothing) + nothingPurchased cc + +testStoreReplayWhileStoreDown :: HasCallStack => TestParams -> IO () +testStoreReplayWhileStoreDown ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = store} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + purchased <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + ledger <- ledgerRows cc "sx_badge_service_badge_ledger" + setGoogleDown store True + -- the key it was credited to is answered from the record + replayed <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + credentialOf replayed `shouldBe` credentialOf purchased + -- any other key waits for the store, or the answer would tell it which purchases were credited + (otherKey, otherMasterKey) <- newPurchaseKeys + other <- serviceCmd env otherKey $ purchaseCmd otherMasterKey supporterPlay + fst (refusalOf other) `shouldBe` BSEProviderUnavailable + ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger + +testStoreVerifierFailures :: HasCallStack => TestParams -> IO () +testStoreVerifierFailures ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleThrowingJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + let answer payment = refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey payment) + -- Apple is checked offline, so a verifier that throws was answered by nothing but its own bug + answer SPApple {jws = appleThrowingJWS} `shouldReturn` (BSEInternal, Nothing) + answer (googlePayment "badge_supporter_01" googleThrowingToken) >>= (`shouldSatisfy` \(code, retryAfter) -> code == BSEProviderUnavailable && isJust retryAfter) + answer (googlePayment "badge_supporter_01" googleHangingToken) >>= (`shouldSatisfy` \(code, retryAfter) -> code == BSEProviderUnavailable && isJust retryAfter) + nothingPurchased cc + +testStoreVerifiedOtherTransaction :: HasCallStack => TestParams -> IO () +testStoreVerifiedOtherTransaction ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleMisnamedJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey SPApple {jws = appleMisnamedJWS}) `shouldReturn` (BSEInternal, Nothing) + nothingPurchased cc + +testStoreQuantityRefused :: HasCallStack => TestParams -> IO () +testStoreQuantityRefused ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClientCfg, bsController = cc, bsStore = FakeStore {appleQuantityJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey SPApple {jws = appleQuantityJWS}) `shouldReturn` (BSEInternal, Nothing) + nothingPurchased cc + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleQuantityJWS}) + storePurchaseOpen alice "" + waitStoreReceiptError (chatController alice) "service_error final internal" + heldStoreReceipts (chatController alice) `shouldReturn` 1 + nothingPurchased cc + +testPurchaseWithNoVerifier :: HasCallStack => TestParams -> IO () +testPurchaseWithNoVerifier ps = + withBadgeServiceVerifier ps (const noStoreVerifier) $ \env@BadgeServiceEnv {bsClientCfg, bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey supporterPlay) `shouldReturn` (BSEProviderNotConfigured, Nothing) + nothingPurchased cc + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + storePurchaseOpen alice "" + -- not yet rather than never, so the receipt stays held for the retry once a verifier is deployed + waitStoreReceiptError (chatController alice) "service_error final provider_not_configured" + heldStoreReceipts (chatController alice) `shouldReturn` 1 + alice ##> "/_badge state 1" + alice .<## ", not credited: service_error final provider_not_configured" + +testStoreClaimRace :: HasCallStack => TestParams -> IO () +testStoreClaimRace ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsController = cc} -> do + (firstKey, firstMasterKey) <- newPurchaseKeys + (otherKey, otherMasterKey) <- newPurchaseKeys + now <- getCurrentTime + let claim paymentId purchaseKey masterKey = + withDB' "claim" cc $ \db -> + createStorePurchase db NewStorePurchase {paymentId, provider = PPGoogle, providerRef = "ref", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter} now + -- the second insert meets the first's claim, as a request that read before the first wrote would + claim "p1" firstKey firstMasterKey >>= (`shouldSatisfy` either (const False) isJust) + claim "p2" otherKey otherMasterKey `shouldReturn` Right Nothing + rowCount cc "sx_badge_service_payments" `shouldReturn` 1 + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStorePurchaseRace :: HasCallStack => TestParams -> IO () +testStorePurchaseRace ps = do + hook <- newIORef (pure ()) + withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + winner <- newIORef Nothing + -- the same purchase is presented again while the first is with the store, and credited first + writeIORef hook $ serviceCmd env purchaseKey (purchaseCmd masterKey supporterPlay) >>= writeIORef winner . Just + raced <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + Just credited <- readIORef winner + credentialOf credited `shouldSatisfy` isJust + credentialOf raced `shouldBe` credentialOf credited + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStorePurchaseRaceOtherKey :: HasCallStack => TestParams -> IO () +testStorePurchaseRaceOtherKey ps = do + hook <- newIORef (pure ()) + withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + (otherKey, otherMasterKey) <- newPurchaseKeys + winner <- newIORef Nothing + -- another key presents the same purchase while the first is with the store, and is credited first + writeIORef hook $ serviceCmd env otherKey (purchaseCmd otherMasterKey supporterPlay) >>= writeIORef winner . Just + raced <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay + refusalOf raced `shouldBe` (BSEReceiptUsed, Nothing) + Just credited <- readIORef winner + masterKeyOf credited `shouldBe` Just otherMasterKey + ledger <- ledgerRows cc "sx_badge_service_badge_ledger" + replayed <- serviceCmd env otherKey $ purchaseCmd otherMasterKey supporterPlay + credentialOf replayed `shouldBe` credentialOf credited + ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStorePurchaseKeyMismatch :: HasCallStack => TestParams -> IO () +testStorePurchaseKeyMismatch ps = + withBadgeService ps $ \clientCfg bsLink cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + g <- C.newRandom + (_, signPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + (claimedPub, _) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + let signKey = B.unpack $ strEncode (C.StoredPrivateKey signPriv) + claimed = B.unpack $ strEncode claimedPub + req = + "{\"version\":1,\"purchaseKey\":\"" <> claimed + <> "\",\"request\":{\"type\":\"purchaseBadge\",\"masterKey\":\"" <> testMasterKeyB64 + <> "\",\"payment\":" <> paymentArg supporterPlay <> "}}" + alice ##> ("/_service_request 1 " <> bsLink <> " sign_key=" <> signKey <> " " <> req) + alice <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}" + nothingPurchased cc + +testPurchaseBadge :: HasCallStack => TestParams -> IO () +testPurchaseBadge ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + let purchase = "/_badge purchase 1 " <> paymentArg supporterPlay + alice ##> purchase + inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + -- the app hands a purchase over until it is answered credited, and one already credited adds nothing + alice ##> purchase + alice <## "badge already redeemed" + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + map (\(_, c, m, _, _, t) -> (c, m, t)) <$> ledgerRows (chatController alice) "badge_ledger" `shouldReturn` [(1, 1, Just "payment"), (-1, 0, Just "badge")] + +testPurchaseBadgeAppStore :: HasCallStack => TestParams -> IO () +testPurchaseBadgeAppStore ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc, bsStore = FakeStore {appleLegendJWS}} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleLegendJWS}) + inAnyOrder alice [[openPurchase ""], creditedLines "" "1: legend"] + storePayments cc `shouldReturn` [("apple", Just 7000, Just "USD", 1)] + +testPurchaseStash :: HasCallStack => TestParams -> IO () +testPurchaseStash ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + let records = rowCount (chatController alice) "badge_store_receipts" + refused = "/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase") + -- the store does not vouch for it, so it can never be credited, and the record says so + alice ##> refused + inAnyOrder alice [[openPurchase ""], ["store purchase resolved"]] + alice ##> refused + alice <## "cannot get badge: badge service error: receipt_invalid" + records `shouldReturn` 1 + heldStoreReceipts (chatController alice) `shouldReturn` 0 + -- no store transaction to key a record by, so nothing is held or sent + alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = "not.a-jws"}) + alice <## "cannot get badge: invalid store receipt" + records `shouldReturn` 1 + nothingPurchased cc + -- pending keeps the receipt held + alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googlePendingToken)) + storePurchaseOpen alice "" + waitStoreReceiptError (chatController alice) "service_error retry payment_pending" + records `shouldReturn` 2 + heldStoreReceipts (chatController alice) `shouldReturn` 1 + nothingPurchased cc + +testPurchaseStashReceiptUsed :: HasCallStack => TestParams -> IO () +testPurchaseStashReceiptUsed ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + let purchase = "/_badge purchase 1 " <> paymentArg supporterPlay + alice ##> purchase + inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + bob ##> purchase + inAnyOrder bob [[openPurchase ""], ["store purchase resolved"]] + bob ##> purchase + bob <## "cannot get badge: badge service error: receipt_used" + storeReceiptRows (chatController bob) `shouldReturn` [(1, Nothing, True)] + heldStoreReceipts (chatController bob) `shouldReturn` 0 + rowCount (chatController alice) "badge_store_receipts" `shouldReturn` 1 + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + bob ##> "/p" + showActiveUser bob "bob (Bob)" + +testPurchaseUnsentPlayToken :: HasCallStack => TestParams -> IO () +testPurchaseUnsentPlayToken ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "token/other")) + storePurchaseOpen alice "" + waitStoreReceiptError (chatController alice) "service_error retry provider_unavailable" + heldStoreReceipts (chatController alice) `shouldReturn` 1 + nothingPurchased cc + +testPurchaseWhileBadgeHeld :: HasCallStack => TestParams -> IO () +testPurchaseWhileBadgeHeld ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + alice <##. "1: supporter" + storePurchaseOpen alice "" + storeReceiptDue (chatController alice) 1 `shouldReturn` False + heldStoreReceipts (chatController alice) `shouldReturn` 1 + rowCount cc "sx_badge_service_payments" `shouldReturn` 0 + +testPurchaseSameReceiptOtherProfile :: HasCallStack => TestParams -> IO () +testPurchaseSameReceiptOtherProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- the store transaction is the device's, so it stays with the profile it was bought under + alice ##> ("/_badge purchase 2 " <> paymentArg supporterPlay) + alice <## "[user: alice] badge already redeemed" + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + alice ##> "/p" + showActiveUser alice "alisa" + +testPurchaseStrandedUnderOtherProfile :: HasCallStack => TestParams -> IO () +testPurchaseStrandedUnderOtherProfile ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do + let purchase userId = "/_badge purchase " <> show (userId :: Int) <> " " <> paymentArg supporterPlay + alice ##> purchase 1 + storePurchaseOpen alice "" + waitStoreReceiptError (chatController alice) "service_error final internal" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- handed over again under whichever profile is active, the purchase stays with the keys alice holds + alice ##> purchase 2 + storePurchaseOpen alice "[user: alice] " + writeIORef broken False + mapM_ (alice <##.) $ creditedLines "[user: alice] " "1: supporter" + (alice "/user alice" + showActiveUser alice "alice (Alice, * supporter)" + +testPurchaseDeliveredToHiddenProfile :: HasCallStack => TestParams -> IO () +testPurchaseDeliveredToHiddenProfile ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do + let purchase userId = "/_badge purchase " <> show (userId :: Int) <> " " <> paymentArg supporterPlay + alice ##> purchase 1 + storePurchaseOpen alice "" + waitStoreReceiptError (chatController alice) "service_error final internal" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_hide user 1 \"password\"" + alice <## "user alice:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + -- the answer and the credit are the hidden owner's, so the view prints none of them + alice ##> purchase 2 + (alice "/user alice password" + showActiveUser alice "alice (Alice, * supporter)" + +testInvoiceOtherProfile :: HasCallStack => TestParams -> IO () +testInvoiceOtherProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay + inAnyOrder alice [["[user: alice] store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "[user: alice] " "1: supporter"] + (alice "/user alice" + showActiveUser alice "alice (Alice, * supporter)" + +testInvoiceSameReceiptTwice :: HasCallStack => TestParams -> IO () +testInvoiceSameReceiptTwice ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + inAnyOrder alice [["store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "" "1: supporter"] + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <## "badge already redeemed" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testInvoiceUnknown :: HasCallStack => TestParams -> IO () +testInvoiceUnknown ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- a reinstall or a second device: the store echoes an invoice this database never created + let unknown = "0b6a5e6c-4f3e-4d51-9f55-3a0f7c2f9e11" + alice ##> purchaseWithInvoice 2 unknown supporterPlay + inAnyOrder alice [["store purchase open: invoice " <> unknown <> ", transaction "], creditedLines "" "1: supporter"] + storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True)] + +testInvoiceLateReceipt :: HasCallStack => TestParams -> IO () +testInvoiceLateReceipt ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock = clock} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay + inAnyOrder alice [["[user: alice] store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "[user: alice] " "1: supporter"] + (alice TestParams -> IO () +testInvoiceAgedOut ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock = clock} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + unpaid <- createInvoice alice 1 + presented <- createInvoice alice 1 + refused <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 refused (googlePayment "badge_supporter_01" "not-a-purchase") + inAnyOrder + alice + [ [ "store purchase open: invoice " <> unpaid, + "store purchase open: invoice " <> presented, + "store purchase open: invoice " <> refused <> ", transaction " + ], + ["store purchase resolved"] + ] + alice ##> purchaseWithInvoice 1 presented (googlePayment "badge_supporter_01" googlePendingToken) + alice <## ("store purchase open: invoice " <> unpaid) + alice <##. ("store purchase open: invoice " <> presented <> ", transaction ") + waitStoreReceiptError (chatController alice) "service_error retry payment_pending" + -- no age hides a record: one with no receipt and one held stay listed, and only a settled one goes + getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) + alice ##> "/_badge state 1" + alice <## ("store purchase open: invoice " <> unpaid) + alice <##. ("store purchase open: invoice " <> presented <> ", transaction ") + (alice TestParams -> IO () +testInvoiceRefusedBeforeCharge ps = do + withNewTestChatCfg ps testCfg {badgeServiceAddress = Nothing} "bob" bobProfile $ \bob -> do + bob ##> "/_badge invoice 1" + bob <## "cannot get badge: badge service not configured" + storeReceiptRows (chatController bob) `shouldReturn` [] + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice ##> "/_badge invoice 1" + alice <## "cannot get badge: badge already active" + storeReceiptRows (chatController alice) `shouldReturn` [] + +testInvoiceWhileBadgeHeld :: HasCallStack => TestParams -> IO () +testInvoiceWhileBadgeHeld ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <##. "1: supporter" + alice <##. ("store purchase open: invoice " <> invoiceId <> ", transaction ") + storeReceiptDue (chatController alice) 1 `shouldReturn` False + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] + heldStoreReceipts (chatController alice) `shouldReturn` 1 + rowCount cc "sx_badge_service_payments" `shouldReturn` 0 + +testInvoiceStateOtherProfile :: HasCallStack => TestParams -> IO () +testInvoiceStateOtherProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + _ <- createInvoice alice 1 + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_hide user 1 \"password\"" + alice <## "user alice:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + invoiceId <- createInvoice alice 2 + alice ##> "/_badge state 2" + alice <## ("store purchase open: invoice " <> invoiceId) + (alice TestParams -> IO () +testStoreReceiptRetried ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + storePurchaseOpen alice "" + waitStoreReceiptError (chatController alice) "service_error final internal" + writeIORef broken False + mapM_ (alice <##.) $ creditedLines "" "1: supporter" + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testStoreReceiptNotBlocked :: HasCallStack => TestParams -> IO () +testStoreReceiptNotBlocked ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsStore = store@FakeStore {appleSupporterJWS}} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + setGoogleDown store True + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + storePurchaseOpen alice "" + waitStoreReceiptError (chatController alice) "service_error retry provider_unavailable" + alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) + inAnyOrder alice [[openPurchase "", openPurchase ""], creditedLines "" "1: supporter"] + heldStoreReceipts (chatController alice) `shouldReturn` 1 + +testStoreRefusalKept :: HasCallStack => TestParams -> IO () +testStoreRefusalKept ps = do + hook <- newIORef (pure ()) + verifications <- countGoogleVerifications hook + withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + let refused = "/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase") + alice ##> refused + inAnyOrder alice [[openPurchase ""], ["store purchase resolved"]] + alice ##> refused + alice <## "cannot get badge: badge service error: receipt_invalid" + alice ##> "/_badge state 1" + (alice TestParams -> IO () +testStoreSettledOtherProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + shown <- createInvoice alice 1 + hidden <- createInvoice alice 1 + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- the invoice makes alice the owner, so the hand-over's answer and the settlement are hers + alice ##> purchaseWithInvoice 2 shown (googlePayment "badge_supporter_01" "not-a-purchase") + inAnyOrder + alice + [ ["[user: alice] store purchase open: invoice " <> shown <> ", transaction ", "store purchase open: invoice " <> hidden], + ["[user: alice] store purchase resolved"] + ] + alice ##> "/_hide user 1 \"password\"" + alice <## "user alice:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + alice ##> purchaseWithInvoice 2 hidden (googlePayment "badge_supporter_01" "not-a-purchase-either") + (alice TestParams -> IO () +testStoreReceiptTwoAtOnce ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + let handOver = void $ sendChatCmdStr (chatController alice) ("/_badge purchase 1 " <> paymentArg supporterPlay) + concurrentlyN_ [handOver, handOver] + mapM_ (alice <##.) $ creditedLines "" "1: supporter" + (alice TestParams -> IO () +testStoreReceiptCleanup ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock, bsStore = FakeStore {appleSupporterJWS}} -> do + let cfg = bsClientCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + unpaid <- createInvoice alice 1 + alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase")) + inAnyOrder alice [["store purchase open: invoice " <> unpaid, openPurchase ""], ["store purchase resolved"]] + alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) + inAnyOrder alice [["store purchase open: invoice " <> unpaid, openPurchase ""], creditedLines "" "1: supporter"] + alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googleUnreachableToken)) + alice <##. "1: supporter" + alice <## ("store purchase open: invoice " <> unpaid) + storePurchaseOpen alice "" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack unpaid), False), (1, Nothing, True), (1, Nothing, True), (1, Nothing, True)] + -- a month on, the record no receipt reached and the refusal go; the credited and the held one stay + testClockTime bsClock >>= setClockAt bsClock . addUTCTime (31 * nominalDay) + -- the move also ends the badge credited above; whether its worker wakes alone or on this signal, its lines are read here + alice `send` "/_app activate" + inAnyOrder alice [["/_app activate"], ["ok"], ["badge alert: support_ended "], ["1: supporter", "badge alert: support_ended "]] + waitStoreReceiptRows (chatController alice) 2 `shouldReturn` [(1, Nothing, True), (1, Nothing, True)] + heldStoreReceipts (chatController alice) `shouldReturn` 1 + -- a receipt for the deleted invoice now arrives with no record to name its profile, so the presenting one gets it + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> purchaseWithInvoice 2 unpaid (googlePayment "badge_supporter_01" googlePendingToken) + alice <##. ("store purchase open: invoice " <> unpaid <> ", transaction ") + waitStoreReceiptRows (chatController alice) 3 >>= (`shouldContain` [(2, Just (T.pack unpaid), True)]) + +testStoreReceiptAttemptThrows :: HasCallStack => TestParams -> IO () +testStoreReceiptAttemptThrows ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do + alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) + storePurchaseOpen alice "" + waitStoreReceiptError (chatController alice) "service_error final internal" + -- a stored payment this version cannot read makes the attempt throw before anything is sent + setStoreReceiptPayment (chatController alice) "not a payment" + waitStoreReceiptError (chatController alice) "unexpected held store payment does not decode" + (alice TestCC -> Int -> IO String +createInvoice cc userId = do + cc ##> ("/_badge invoice " <> show userId) + line <- getTermLine cc + maybe (error $ "expected an invoice, got " <> line) pure $ stripPrefix "badge invoice: " line + +purchaseWithInvoice :: Int -> String -> ServicePayment -> String +purchaseWithInvoice userId invoiceId payment = "/_badge purchase " <> show userId <> " invoice=" <> invoiceId <> " " <> paymentArg payment + +-- | Each store purchase record's profile, invoice id, and whether a receipt reached it. +storeReceiptRows :: ChatController -> IO [(Int64, Maybe Text, Bool)] +storeReceiptRows ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.query_ db $ + "SELECT user_id, invoice_id, transaction_ref IS NOT NULL " + <> "FROM badge_store_receipts ORDER BY badge_store_receipt_id" + +waitStoreReceiptRows :: HasCallStack => ChatController -> Int -> IO [(Int64, Maybe Text, Bool)] +waitStoreReceiptRows cc n = loop (100 :: Int) + where + loop 0 = storeReceiptRows cc >>= \rows -> error $ "expected " <> show n <> " store purchase records, got " <> show rows + loop i = do + rows <- storeReceiptRows cc + if length rows == n then pure rows else threadDelay 50000 >> loop (i - 1) + +storeReceiptDue :: ChatController -> Int64 -> IO Bool +storeReceiptDue ChatController {chatStore} userId = + withTransaction chatStore $ \db -> + getNextHeldStoreReceipt db userId >>= either (error . show) (pure . isJust) + +heldStoreReceipts :: ChatController -> IO Int +heldStoreReceipts ChatController {chatStore} = + withTransaction chatStore $ \db -> do + [Only n] <- DB.query_ db "SELECT COUNT(*) FROM badge_store_receipts WHERE payment IS NOT NULL" + pure n + +waitHeldStoreReceipts :: HasCallStack => ChatController -> Int -> IO () +waitHeldStoreReceipts cc n = loop (100 :: Int) + where + loop 0 = heldStoreReceipts cc >>= (`shouldBe` n) + loop i = heldStoreReceipts cc >>= \held -> if held == n then pure () else threadDelay 50000 >> loop (i - 1) + +waitStoreReceiptError :: HasCallStack => ChatController -> Text -> IO () +waitStoreReceiptError ChatController {chatStore} failure = loop (100 :: Int) + where + loop 0 = heldFailures >>= (`shouldContain` [failure]) + loop i = heldFailures >>= \failures -> if failure `elem` failures then pure () else threadDelay 50000 >> loop (i - 1) + heldFailures = + withTransaction chatStore $ \db -> + map fromOnly <$> DB.query_ db "SELECT credit_error FROM badge_store_receipts WHERE payment IS NOT NULL AND credit_error IS NOT NULL" + +setStoreReceiptPayment :: ChatController -> Text -> IO () +setStoreReceiptPayment ChatController {chatStore} payment = + withTransaction chatStore $ \db -> DB.execute db "UPDATE badge_store_receipts SET payment = ?" (Only payment) + +fastRetryCfg :: ChatConfig -> ChatConfig +fastRetryCfg cfg = cfg {badgeRetryInterval = RetryInterval {initialInterval = 100000, increaseAfter = 0, maxInterval = 500000}} + +-- | The fake store's Google verdicts, failing as a verifier bug while broken is set: internal, with no retryAfter. +brokenGoogle :: IORef Bool -> FakeStore -> StoreVerifier +brokenGoogle broken FakeStore {fakeVerifier = v@StoreVerifier {verifyGoogle}} = + v {verifyGoogle = unlessBroken <$> verifyGoogle} + where + unlessBroken verify productId token = + readIORef broken >>= \case + True -> pure $ Left $ SRVerifierFailed "fake verifier bug" + False -> verify productId token + +-- | Each block's lines in order, the blocks in any order: the worker is signalled before the command that +-- signalled it has answered, so its events can print first. Every line is matched as a prefix. +inAnyOrder :: HasCallStack => TestCC -> [[String]] -> IO () +inAnyOrder _ [] = pure () +inAnyOrder cc blocks = do + line <- getTermLine cc + case break (startsWith line) blocks of + (earlier, (_ : rest) : others) -> do + mapM_ (cc <##.) rest + inAnyOrder cc (earlier <> others) + _ -> error $ "expected a line starting one of " <> show blocks <> ", got " <> show line + where + startsWith l = \case + start : _ -> start `isPrefixOf` l + [] -> False + +-- | Counts the Google verifications the service makes, re-arming the one-shot hook each time. +countGoogleVerifications :: IORef (IO ()) -> IO (IO Int) +countGoogleVerifications hook = do + n <- newIORef 0 + let count = modifyIORef' n (+ 1) >> writeIORef hook count + writeIORef hook count + pure $ readIORef n + +storePurchaseOpen :: HasCallStack => TestCC -> String -> IO () +storePurchaseOpen cc userPrefix = cc <##. openPurchase userPrefix + +openPurchase :: String -> String +openPurchase userPrefix = userPrefix <> "store purchase open: invoice none, transaction " + +-- | The worker's credit as the owner's terminal shows it: the badge, then the settlement. +creditedLines :: String -> String -> [String] +creditedLines userPrefix badge = [userPrefix <> badge, userPrefix <> "store purchase resolved"] diff --git a/tests/Bots/BadgeService/ConfigTests.hs b/tests/Bots/BadgeService/ConfigTests.hs index d27698622d..4e797e6335 100644 --- a/tests/Bots/BadgeService/ConfigTests.hs +++ b/tests/Bots/BadgeService/ConfigTests.hs @@ -50,6 +50,10 @@ badgeConfigTests = describe "badge service config" $ do it "reads chat_redeem = on" testDevRedeemOn it "reads chat_redeem = off" testDevRedeemOff it "refuses a chat_redeem that is not on or off" testDevRedeemNotBoolean + it "verifies store receipts when the dev section is absent" testDevUnverifiedReceiptsAbsent + it "reads accept_unverified_store_receipts = on" testDevUnverifiedReceiptsOn + it "reads accept_unverified_store_receipts = off" testDevUnverifiedReceiptsOff + it "refuses an accept_unverified_store_receipts that is not on or off" testDevUnverifiedReceiptsNotBoolean fullIni :: T.Text fullIni = @@ -365,5 +369,25 @@ testDevRedeemNotBoolean = withDev "chat_redeem = true\n" $ \r -> case r of Left e -> e `shouldContain` "chat_redeem" Right _ -> expectationFailure "only on and off are accepted, so a typo cannot silently disarm the gate" +testDevUnverifiedReceiptsAbsent :: IO () +testDevUnverifiedReceiptsAbsent = withIni fullIni $ \p -> do + Right cfg <- readServiceConfig p + devAcceptUnverifiedStoreReceipts cfg `shouldBe` False + +testDevUnverifiedReceiptsOn :: IO () +testDevUnverifiedReceiptsOn = withDev "accept_unverified_store_receipts = on\n" $ \r -> case r of + Right cfg -> devAcceptUnverifiedStoreReceipts cfg `shouldBe` True + Left e -> expectationFailure ("[dev] accept_unverified_store_receipts = on is legal: " <> e) + +testDevUnverifiedReceiptsOff :: IO () +testDevUnverifiedReceiptsOff = withDev "accept_unverified_store_receipts = off\n" $ \r -> case r of + Right cfg -> devAcceptUnverifiedStoreReceipts cfg `shouldBe` False + Left e -> expectationFailure ("[dev] accept_unverified_store_receipts = off is legal: " <> e) + +testDevUnverifiedReceiptsNotBoolean :: IO () +testDevUnverifiedReceiptsNotBoolean = withDev "accept_unverified_store_receipts = yes\n" $ \r -> case r of + Left e -> e `shouldContain` "accept_unverified_store_receipts" + Right _ -> expectationFailure "only on and off are accepted, so a typo cannot silently arm the mock" + withDev :: T.Text -> (Either String ServiceConfig -> IO a) -> IO a withDev keys act = withIni (fullIni <> "[dev]\n" <> keys) $ \p -> readServiceConfig p >>= act diff --git a/tests/Bots/BadgeService/FakeStore.hs b/tests/Bots/BadgeService/FakeStore.hs new file mode 100644 index 0000000000..986351c47f --- /dev/null +++ b/tests/Bots/BadgeService/FakeStore.hs @@ -0,0 +1,141 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} + +module Bots.BadgeService.FakeStore + ( FakeStore (..), + newFakeStore, + googleVerifierWithHook, + settlePending, + setGoogleDown, + googleSupporterToken, + googlePendingToken, + googleUnreachableToken, + googleThrowingToken, + googleHangingToken, + googleSubscriptionToken, + googlePayment, + unsignedJWS, + ) +where + +import BadgeService.StoreReceipts +import Control.Concurrent (threadDelay) +import Control.Monad (forever) +import qualified Data.Aeson as J +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef) +import Data.Text (Text) +import Simplex.Chat.PaymentService (ServicePayment (..), googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) +import Simplex.Messaging.Util (safeDecodeUtf8) +import System.FilePath (()) + +fixtureDir :: FilePath +fixtureDir = "apps" "simplex-badge-service" "test-fixtures" "apple" + +-- | The Apple JWS are the fixtures under no real signature, so they are accepted as these exact +-- strings only, never because a payload decodes - which a real verifier must refuse to do. +data FakeStore = FakeStore + { appleSupporterJWS :: Text, + appleLegendJWS :: Text, + appleSandboxJWS :: Text, + appleThrowingJWS :: Text, + appleMisnamedJWS :: Text, + appleQuantityJWS :: Text, + pendingSettled :: IORef Bool, + googleDown :: IORef Bool, + fakeVerifier :: StoreVerifier + } + +newFakeStore :: IO FakeStore +newFakeStore = do + appleSupporterJWS <- fixtureJWS "transaction-supporter.json" + appleLegendJWS <- fixtureJWS "transaction-legend.json" + appleSandboxJWS <- fixtureJWS "transaction-sandbox.json" + let appleThrowingJWS = unsignedJWS "{\"transactionId\":\"2000000812345679\",\"productId\":\"BADGE_SUPPORTER_01\"}" + appleMisnamedJWS = unsignedJWS "{\"transactionId\":\"2000000812345698\",\"productId\":\"BADGE_SUPPORTER_01\"}" + appleQuantityJWS = unsignedJWS "{\"transactionId\":\"2000000812345697\",\"productId\":\"BADGE_SUPPORTER_01\"}" + pendingSettled <- newIORef False + googleDown <- newIORef False + let appleReceipts = + [ (appleSupporterJWS, appleTransaction "2000000812345671" "BADGE_SUPPORTER_01" False 700), + (appleLegendJWS, appleTransaction "2000000812345672" "BADGE_LEGEND_01" False 7000), + (appleSandboxJWS, appleTransaction "2000000812345673" "BADGE_LEGEND_01" True 7000), + -- a verifier vouching for another transaction than the one the evidence names + (appleMisnamedJWS, appleTransaction "2000000812345671" "BADGE_SUPPORTER_01" False 700), + (appleQuantityJWS, (appleTransaction "2000000812345697" "BADGE_SUPPORTER_01" False 1400) {quantity = 2}) + ] + verifyApple jws + | jws == appleThrowingJWS = error "fake verifier bug" + | otherwise = maybe (Left "not a fake receipt") Right $ lookup jws appleReceipts + verifyGoogle = googleVerdict pendingSettled googleDown + pure + FakeStore + { appleSupporterJWS, + appleLegendJWS, + appleSandboxJWS, + appleThrowingJWS, + appleMisnamedJWS, + appleQuantityJWS, + pendingSettled, + googleDown, + fakeVerifier = StoreVerifier {verifyApple = Just verifyApple, verifyGoogle = Just verifyGoogle, verifyTimeout = 500000} + } + where + fixtureJWS name = unsignedJWS <$> B.readFile (fixtureDir name) + appleTransaction transactionRef productId testPurchase cents = + VerifiedStoreTransaction {transactionRef, productId, quantity = 1, testPurchase, paid = Just (CurrencyAmount cents, "USD")} + +googleVerdict :: IORef Bool -> IORef Bool -> Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +googleVerdict pendingSettled googleDown productId token = + readIORef googleDown >>= \case + True -> pure $ Left $ SRUnreachable "fake store is down" + False + | (productId, token) == ("badge_supporter_01", googleSupporterToken) -> pure $ Right purchased + | (productId, token) == ("subscr_badge_supporter_01", googleSubscriptionToken) -> pure $ Right purchased + | (productId, token) == ("badge_supporter_01", googlePendingToken) -> do + settled <- readIORef pendingSettled + pure $ if settled then Right purchased else Left SRPending + | token == googleUnreachableToken -> pure $ Left $ SRUnreachable "fake store is down" + | token == googleThrowingToken -> ioError $ userError "fake connection reset" + | token == googleHangingToken -> forever $ threadDelay 1000000 + | otherwise -> pure $ Left $ SRInvalid "not a fake purchase" + where + purchased = VerifiedStoreTransaction {transactionRef = googlePurchaseRef token, productId, quantity = 1, testPurchase = False, paid = Nothing} + +-- | The fake store, running the hook's action once, inside the first Google verification, before it answers. +googleVerifierWithHook :: IORef (IO ()) -> FakeStore -> StoreVerifier +googleVerifierWithHook hook FakeStore {fakeVerifier = v@StoreVerifier {verifyGoogle}} = + v {verifyGoogle = hooked <$> verifyGoogle, verifyTimeout = 10000000} + where + hooked verify productId token = do + action <- atomicModifyIORef' hook (pure (),) + action + verify productId token + +settlePending :: FakeStore -> IO () +settlePending FakeStore {pendingSettled} = writeIORef pendingSettled True + +setGoogleDown :: FakeStore -> Bool -> IO () +setGoogleDown FakeStore {googleDown} = writeIORef googleDown + +googleSupporterToken, googlePendingToken, googleUnreachableToken, googleThrowingToken, googleHangingToken, googleSubscriptionToken :: Text +googleSupporterToken = "fake-play-token-supporter.AO-J1Oz9x2kqE7wYt3" +googlePendingToken = "fake-play-token-pending.AO-J1Oy8w1jpD6vXs2" +googleUnreachableToken = "fake-play-token-unreachable.AO-J1Ox7v0ioC5uWr1" +googleThrowingToken = "fake-play-token-throwing.AO-J1Ov5t8gmA3sUp9" +googleHangingToken = "fake-play-token-hanging.AO-J1Ou4s7flZ2rTo8" +googleSubscriptionToken = "fake-play-token-subscription.AO-J1Ow6u9hnB4tVq0" + +googlePayment :: Text -> Text -> ServicePayment +googlePayment productId token = SPGoogle {productId, token} + +unsignedJWS :: B.ByteString -> Text +unsignedJWS payload = safeDecodeUtf8 $ B.intercalate "." $ map B64U.encodeUnpadded [header, payload, "not a signature"] + where + header = LB.toStrict $ J.encode $ J.object ["alg" J..= ("ES256" :: Text), "x5c" J..= (["leaf", "intermediate", "root"] :: [Text])] diff --git a/tests/Bots/BadgeService/WebTests.hs b/tests/Bots/BadgeService/WebTests.hs index dd21c820dd..bd8b166094 100644 --- a/tests/Bots/BadgeService/WebTests.hs +++ b/tests/Bots/BadgeService/WebTests.hs @@ -14,7 +14,7 @@ import BadgeService.Poller import BadgeService.Providers import BadgeService.Providers.BTCPay (btcpayProvider, listPageSize, maxListPages) import BadgeService.Providers.Stripe (stripeProvider) -import BadgeService.Store (CodeRedemption (..), IssuedCode (..), NewCodePurchase (..), RevokeResult (..), createCodePurchase, getBadgeCode, insertBadgeCode, revokeCode) +import BadgeService.Store (FundingCredit (..), IssuedCode (..), NewCodePurchase (..), RevokeResult (..), createCodePurchase, getBadgeCode, insertBadgeCode, revokeCode) import BadgeService.Store.Invoices import BadgeService.Waiters (awaitStatus, newWaiters, publish, waitingCount) import BadgeService.Web.Server @@ -471,7 +471,7 @@ testRevokeAndRedeemExcludeEachOther = withServiceStore $ \st -> do revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now unredeemed codeHash = withTransaction st (`getBadgeCode` codeHash) >>= \case - Just IssuedCode {redemption = CodeUnredeemed} -> pure True + Just IssuedCode {redemption = Uncredited} -> pure True _ -> pure False revokedFirst <- newCode "revoked-first" revoke "revoked-first" `shouldReturn` Revoked @@ -801,7 +801,8 @@ testServiceConfig staticDir trustForwarded = stripe = Nothing, poll = PollConfig {pWaitingSeconds = 3, pIdleSeconds = 60}, issuer = Nothing, - devChatRedeem = False + devChatRedeem = False, + devAcceptUnverifiedStoreReceipts = False } testServeWebappOff :: IO () From b4f12494367f4e95576704a62df2f889bfc1d604 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:46:27 +0000 Subject: [PATCH 06/84] core, ios, android: name the resolved store purchase so the app finishes it without a sweep (#7659) --- apps/ios/Shared/Model/AppAPITypes.swift | 9 +- apps/ios/Shared/Model/SimpleXAPI.swift | 7 +- apps/ios/Shared/Views/Badges/BadgeStore.swift | 39 ++++++-- apps/ios/Shared/Views/Badges/BadgesView.swift | 6 -- .../Views/Badges/BadgesYourBadgeView.swift | 10 -- .../chat/simplex/common/model/SimpleXAPI.kt | 16 ++-- .../simplex/common/views/badges/BadgeStore.kt | 47 ++++++--- .../views/badges/BadgesCheckOrderView.kt | 2 +- .../views/badges/BadgesPurchaseStateView.kt | 2 +- .../simplex/common/views/badges/BadgesView.kt | 12 +-- .../views/badges/BadgesYourBadgeView.kt | 8 -- .../moko-resources/base/strings.xml | 2 - bots/src/API/Docs/Events.hs | 3 +- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Library/Commands.hs | 23 +++-- src/Simplex/Chat/Store/Badges.hs | 14 +-- src/Simplex/Chat/View.hs | 3 +- tests/Bots/BadgeService/BotTests.hs | 95 +++++++++++-------- 18 files changed, 174 insertions(+), 129 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index bf7ee67a7e..7099fc4d7f 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1283,7 +1283,8 @@ enum ChatEvent: Decodable, ChatAPIResult { // badges case badgeChanged(user: User, badgeState: BadgeState?) case badgeAlert(user: UserRef, badgeAlert: BadgeAlert) - case storePurchaseResolved(user: UserRef) + case storePurchaseCredited(user: UserRef, invoiceId: String) + case storePurchaseRefused(user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) var responseType: String { switch self { @@ -1358,7 +1359,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .contactPQEnabled: "contactPQEnabled" case .badgeChanged: "badgeChanged" case .badgeAlert: "badgeAlert" - case .storePurchaseResolved: "storePurchaseResolved" + case .storePurchaseCredited: "storePurchaseCredited" + case .storePurchaseRefused: "storePurchaseRefused" } } @@ -1443,7 +1445,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState)) case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert)) - case .storePurchaseResolved: return noDetails + case let .storePurchaseCredited(u, invoiceId): return withUser(u, "invoiceId: \(invoiceId)") + case let .storePurchaseRefused(u, invoiceId, refusal): return withUser(u, "invoiceId: \(invoiceId)\nrefusal: \(refusal.tag)") } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index bcb592ee1a..2a9c82a966 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -3094,9 +3094,10 @@ func processReceivedMsg(_ res: ChatEvent) async { BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert) } } - case .storePurchaseResolved: - // whichever profile owns it: only the app can finish the store transaction - Task { await BadgeStore.shared.presentUnfinished() } + case let .storePurchaseCredited(user, invoiceId): + Task { await BadgeStore.shared.storePurchaseResolved(user, invoiceId) } + case let .storePurchaseRefused(user, invoiceId, refusal): + Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 0c338655cd..f5da8d62e1 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -111,7 +111,6 @@ final class BadgeStore: ObservableObject { // set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was // closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again @Published private var reconciledOnce = false - let refusals = PassthroughSubject() private var transactionUpdates: Task? = nil private init() {} @@ -233,7 +232,7 @@ final class BadgeStore: ObservableObject { // or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way. private func handOver(_ receipt: BadgeStoreReceipt) async throws { guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return } - // a refusal is announced where it reads as its own, so only an unexpected answer reaches the buyer + // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer do { switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) { case let .held(user, badgeState, storePurchases): @@ -252,22 +251,41 @@ final class BadgeStore: ObservableObject { if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) } } } - await resolve(receipt, refusal: nil) + await receipt.transaction.finish() } } catch let error where badgeReceiptRefused(error) { logger.error("BadgeStore.handOver: \(responseError(error))") - await resolve(receipt, refusal: error) + await receipt.transaction.finish() } } - private func resolve(_ receipt: BadgeStoreReceipt, refusal: Error?) async { - await receipt.transaction.finish() - if let refusal { - await MainActor.run { refusals.send(refusal) } + func storePurchaseRefused(_ user: UserRef, _ invoiceId: String, _ refusal: BadgeIssueFailure) async { + await MainActor.run { + if active(user) && openStorePurchases(user.userId).contains(where: { $0.invoiceId == invoiceId }) { + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: refusal.purchaseText) + } + } + await storePurchaseResolved(user, invoiceId) + } + + 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 { + if active(user), let open = storePurchases, open.userId == user.userId { + storePurchases = (open.userId, open.purchases.filter { $0.invoiceId != invoiceId }) + } + } + for await verification in Transaction.unfinished { + let receipt = storeReceipt(verification) + if receipt.echoedInvoiceId == invoiceId { + await receipt.transaction.finish() + return + } } } - // at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer + // at launch, on return to the foreground and on a profile switch, never on a timer func presentUnfinished() async { await listenForTransactions() for await verification in Transaction.unfinished { @@ -339,7 +357,8 @@ private func storeReceipt(_ verification: VerificationResult) -> Ba ) } -// the codes core refuses a receipt with for good, after which the store may stop re-delivering it +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it; +// a sweep that hands over a receipt refused while the app was not running learns it only from this answer private func badgeReceiptRefused(_ error: Error) -> Bool { if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { return code == .receiptInvalid || code == .receiptUsed diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index 28fa1a02e8..2d37860c45 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -30,12 +30,6 @@ struct BadgesView: View { // holds the purchase screens' slot, so a consumable cannot be bought twice BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet) .transition(.opacity) - .onReceive(store.refusals) { refusal in - // only the issuing screen belongs to the active profile's held purchase, so a refusal shown there reads as its own - if purchaseState == .issuing { - showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(refusal, purchase: true)) - } - } } else if store.checkingPurchases { BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet) .transition(.opacity) diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 6a67dad8c2..60c6a0ab77 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -14,7 +14,6 @@ struct BadgesYourBadgeView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) private var dismiss @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false - @ObservedObject private var store = BadgeStore.shared let badgeState: BadgeState var showsAsSheet: Bool = false @@ -79,15 +78,6 @@ struct BadgesYourBadgeView: View { } } } - if store.purchaseState(chatModel.currentUser?.userId) != nil { - Section { - Text("A store purchase is being processed.") - .foregroundColor(theme.colors.secondary) - } header: { - Text("Store purchase") - .foregroundColor(theme.colors.secondary) - } - } if developerTools { Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { if let badge = chatModel.currentUser?.profile.localBadge { 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 91424ec8f2..3c0443090b 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 @@ -3612,9 +3612,10 @@ object ChatController { BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert) } } - is CR.StorePurchaseResolved -> - // whichever profile owns it: only the app can finish the store purchase - withLongRunningApi { BadgeStore.presentUnfinished() } + is CR.StorePurchaseCredited -> + withLongRunningApi { BadgeStore.storePurchaseResolved(rhId, r.user, r.invoiceId) } + is CR.StorePurchaseRefused -> + withLongRunningApi { BadgeStore.storePurchaseRefused(rhId, r.user, r.invoiceId, r.refusal) } else -> Log.d(TAG , "unsupported event: ${msg.responseType}") } @@ -6963,7 +6964,8 @@ sealed class CR { @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() - @Serializable @SerialName("storePurchaseResolved") class StorePurchaseResolved(val user: UserRef): CR() + @Serializable @SerialName("storePurchaseCredited") class StorePurchaseCredited(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("storePurchaseRefused") class StorePurchaseRefused(val user: UserRef, val invoiceId: String, val refusal: BadgeIssueFailure): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -7156,7 +7158,8 @@ sealed class CR { is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" is BadgeAlertR -> "badgeAlert" - is StorePurchaseResolved -> "storePurchaseResolved" + is StorePurchaseCredited -> "storePurchaseCredited" + is StorePurchaseRefused -> "storePurchaseRefused" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7366,7 +7369,8 @@ sealed class CR { is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) - is StorePurchaseResolved -> withUser(user, noDetails()) + is StorePurchaseCredited -> withUser(user, "invoiceId: $invoiceId") + is StorePurchaseRefused -> withUser(user, "invoiceId: $invoiceId\nrefusal: ${refusal.tag}") is Response -> json is Invalid -> str } 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 ae6ecd3876..6e846678c2 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 @@ -6,10 +6,12 @@ import androidx.compose.runtime.mutableStateOf 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.generalGetString +import chat.simplex.res.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.withContext import java.text.NumberFormat import java.util.Currency @@ -157,7 +159,6 @@ object BadgeStore { // set once presentUnfinished has read the store, or failed to: until then, a slow payment completed while // the app was closed, or a purchase it died before handing over, are both unknown, so canBuy refuses private val reconciledOnce = mutableStateOf(false) - val refusals = MutableSharedFlow() fun purchaseState(userId: Long?): BadgePurchaseState? { if (!badgeStoreAvailable) return null @@ -292,27 +293,50 @@ object BadgeStore { if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true) } } - resolve(receipt, refusal = null) + finish(receipt) } - // a refusal is announced where it reads as its own, so only an unexpected answer reaches the buyer + // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer is BadgePurchaseResult.Failed -> if (badgeReceiptRefused(r.err)) { Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}") - resolve(receipt, refusal = r.err) + finish(receipt) } else { throw r.err?.let(BadgeStoreError::ApiError) ?: Exception("apiPurchaseBadge: unexpected response") } } } - private suspend fun resolve(receipt: BadgeStoreReceipt, refusal: ChatError?) { - finish(receipt) - if (refusal != null) { - withContext(Dispatchers.Main) { refusals.emit(refusal) } + 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 }) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = refusal.purchaseText) + } + } + storePurchaseResolved(rhId, user, invoiceId) + } + + 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) { + val (readOn, readFor, purchases) = storePurchases.value ?: return@withContext + if (chatModel.controller.activeUser(rhId, user) && readOn == rhId && readFor == user.userId) { + storePurchases.value = Triple(readOn, readFor, purchases.filter { it.invoiceId != invoiceId }) + } + } + if (rhId != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return + try { + platform.androidUnfinishedBadgePurchases() + .firstNotNullOfOrNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.takeIf { r -> r.invoiceId == invoiceId } } + ?.let { finish(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + // the purchase stays unfinished, and the next sweep hands it over and finishes it + Log.e(TAG, "BadgeStore.storePurchaseResolved: ${e.message}") } } - // at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer + // at launch, on return to the foreground and on a profile switch, never on a timer suspend fun presentUnfinished() { try { if (!useBadgeTestProducts && platform.androidHasPlatformStore) { @@ -390,7 +414,8 @@ private fun compactPrice(product: BadgeProduct): String { } } -// the codes core refuses a receipt with for good, after which the store may stop re-delivering it +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it; +// a sweep that hands over a receipt refused while the app was not running learns it only from this answer private fun badgeReceiptRefused(err: ChatError?): Boolean { val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt index 90f459a520..fbb3d1a017 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -17,7 +17,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt index 4c808d60a6..4648a12774 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt @@ -16,7 +16,7 @@ import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.BadgeIssueFailure import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, failure: BadgeIssueFailure? = null, onDismiss: () -> Unit) { 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 d631735e3e..c0050c8406 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 @@ -7,11 +7,9 @@ import androidx.compose.runtime.remember import chat.simplex.common.model.BadgeModel import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel -import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView -import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* @OptIn(ExperimentalAnimationApi::class) @Composable @@ -31,14 +29,6 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) { } else if (purchaseState != null) { // holds the purchase screens' slot, so a consumable cannot be bought twice BadgesPurchaseStateView(purchaseState.title, purchaseState.message, BadgeStore.creditError(chatModel.currentUser.value?.userId), onDismiss = close) - LaunchedEffect(purchaseState) { - BadgeStore.refusals.collect { refusal -> - // only the issuing screen belongs to the active profile's held purchase, so a refusal shown there reads as its own - if (purchaseState == BadgePurchaseState.Issuing) { - AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = chatModel.controller.redeemErrorText(refusal, purchase = true)) - } - } - } } else if (checkingPurchases) { BadgesPurchaseStateView(MR.strings.badges_checking_purchases_title, null, onDismiss = close) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt index c40a80f774..f6f6ecb12d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -78,14 +78,6 @@ fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { } SectionSpacer() } - if (BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null) { - SectionView(stringResource(MR.strings.badges_store_purchase_title)) { - SectionItemView { - Text(stringResource(MR.strings.badges_store_purchase_processing), color = MaterialTheme.colors.secondary) - } - } - SectionSpacer() - } if (appPrefs.developerTools.get()) { val clipboard = LocalClipboardManager.current SectionView(stringResource(MR.strings.badges_credential)) { diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 280096d38d..2990970884 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -3196,8 +3196,6 @@ Waiting for approval Nothing has been charged. Checking your purchases - Store purchase - A store purchase is being processed. Buy in browser Redeem code Add badge to your profile? diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index e35cd6ffe5..c43b0fe3e4 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -221,7 +221,8 @@ undocumentedEvents = "CEvtSndFileRedirectStartXFTP", "CEvtSndFileStart", -- legacy SMP files "CEvtSndStandaloneFileComplete", - "CEvtStorePurchaseResolved", + "CEvtStorePurchaseCredited", + "CEvtStorePurchaseRefused", "CEvtConnectionsDiff", "CEvtSubscriptionEnd", "CEvtTerminalEvent", diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1c9fd9d68b..e8fe0b44f8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -85,7 +85,7 @@ import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyM import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..), OpenStorePurchase (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeIssueFailure, BadgeState (..), OpenStorePurchase (..)) import Simplex.Chat.PaymentService (ServicePayment) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -999,7 +999,8 @@ data ChatEvent | CEvtServiceReplySent {connectionId :: AgentConnId} | CEvtBadgeChanged {user :: User, badgeState :: Maybe BadgeState} -- badge state changed, including a renewal that arrived without a command | CEvtBadgeAlert {user :: User, badgeAlert :: BadgeAlert} - | CEvtStorePurchaseResolved {user :: User} -- a held store receipt was credited or refused, so its store transaction can be finished + | CEvtStorePurchaseCredited {user :: User, invoiceId :: Text} -- the held store receipt of this invoice was credited, so its store transaction can be finished + | CEvtStorePurchaseRefused {user :: User, invoiceId :: Text, refusal :: BadgeIssueFailure} -- the held store receipt of this invoice was refused | CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason} | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response | CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b309687589..d7c7d70226 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -5458,7 +5458,7 @@ runStoreReceiptWorker a userId Worker {doWork} = do forever $ do lift $ waitForWork doWork withWork_ a doWork (getDueReceipt scheduled) $ \case - BadgeReceiptRecord {receiptId, status = RSHeld {stash, payment, retryDelay}} -> creditStoreReceipt a userId receiptId stash payment retryDelay + BadgeReceiptRecord {receiptId, status = RSHeld {stash, invoiceId, payment, retryDelay}} -> creditStoreReceipt a userId receiptId stash invoiceId payment retryDelay BadgeReceiptRecord {receiptId} -> notHeld receiptId where getDueReceipt scheduled = @@ -5479,31 +5479,36 @@ runStoreReceiptWorker a userId Worker {doWork} = do atomically $ modifyTVar' scheduled $ S.delete receiptId void $ atomically $ tryPutTMVar doWork () -data StoreReceiptOutcome = SROCredited (Maybe User) | SRORefused | SRORetry | SRODeferred +data StoreReceiptOutcome = SROCredited (Maybe User) | SRORefused BadgeIssueFailure | SRORetry | SRODeferred -- | Under the owner's badge lock, which keeps a profile's requests to the service serial. -creditStoreReceipt :: AgentClient -> UserId -> Int64 -> BadgeStash -> Text -> Maybe Int64 -> CM () -creditStoreReceipt a userId receiptId stash@BadgeStash {masterKey} payment retryDelay = do +creditStoreReceipt :: AgentClient -> UserId -> Int64 -> BadgeStash -> Maybe Text -> Text -> Maybe Int64 -> CM () +creditStoreReceipt a userId receiptId stash@BadgeStash {masterKey} invoiceId_ payment retryDelay = do ChatConfig {badgeRetryInterval = ri, badgeConsecutiveRetries} <- asks config withRetryIntervalCount (maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) retryDelay) $ \n delay loop -> do liftIO $ waitWhileSuspended a liftIO $ waitForUserNetwork a + -- decided under the lock and acted on outside it: the retry sleeps, and announcing takes the chat lock outcome <- withEntityLock "badgePurchase" (CLBadgeUser userId) $ tryAllErrors attempt >>= \case Right (Right (present_, _)) -> pure $ SROCredited present_ Right (Left (code, retryAfter)) - | storeReceiptRefused code -> SRORefused <$ withStore' (\db -> refuseStoreReceipt db receiptId $ serviceFailure code retryAfter) + | storeReceiptRefused code -> do + let failure = serviceFailure code retryAfter + SRORefused failure <$ withStore' (\db -> refuseStoreReceipt db receiptId failure) | otherwise -> failed ri delay (serviceFailure code retryAfter) retryAfter Left e -> failed ri delay (badgeIssueFailure e) Nothing - -- only a settlement is announced, and the sweep it prompts signals this worker only for a receipt still held, so it cannot loop + -- the invoice id is the app's only key to the store transaction, so without one nothing is emitted and its sweep finishes it case outcome of SROCredited present_ -> do user <- withStore $ \db -> getUser db userId toView . CEvtBadgeChanged user =<< getUserBadgeState user - toView $ CEvtStorePurchaseResolved user - -- last, so a failed broadcast cannot lose the settlement; outside the badge lock, as it takes the chat lock + forM_ invoiceId_ $ toView . CEvtStorePurchaseCredited user + -- last, so a failed broadcast cannot lose the settlement mapM_ presentUserBadgeToContacts present_ - SRORefused -> withStore (`getUser` userId) >>= toView . CEvtStorePurchaseResolved + SRORefused failure -> forM_ invoiceId_ $ \invoiceId -> do + user <- withStore (`getUser` userId) + toView $ CEvtStorePurchaseRefused user invoiceId failure SRORetry | n + 1 < badgeConsecutiveRetries -> loop _ -> pure () where diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index e749a7fc37..28db3e5d67 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -118,7 +118,7 @@ data BadgeReceiptRecord = BadgeReceiptRecord } data BadgeReceiptStatus - = RSHeld {stash :: BadgeStash, payment :: Text, nextAttemptAt :: UTCTime, retryDelay :: Maybe Int64} + = RSHeld {stash :: BadgeStash, invoiceId :: Maybe Text, payment :: Text, nextAttemptAt :: UTCTime, retryDelay :: Maybe Int64} | RSCredited {badgePurchaseId :: Int64} | RSRefused {refusal :: Maybe BadgeIssueFailure} @@ -171,18 +171,18 @@ getReceiptRecord db StoreTransactionRef {provider, transactionRef} = db [sql| SELECT r.badge_store_receipt_id, r.user_id, r.purchase_key, r.purchase_priv_key, r.master_key, - r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error + r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error FROM badge_store_receipts r LEFT JOIN badge_purchases p ON p.badge_store_receipt_id = r.badge_store_receipt_id WHERE r.provider = ? AND r.transaction_ref = ? |] (provider, transactionRef) where - toReceiptRecord ((receiptId, ownerId, purchaseKey, purchasePrivKey, mk) :. (payment_, nextAttemptAt_, retryDelay, purchaseId_, refusal)) = + toReceiptRecord ((receiptId, ownerId, purchaseKey, purchasePrivKey, mk) :. (invoiceId, payment_, nextAttemptAt_, retryDelay, purchaseId_, refusal)) = BadgeReceiptRecord {receiptId, ownerId, status} where status = case (payment_, nextAttemptAt_) of - (Just payment, Just nextAttemptAt) -> RSHeld {stash = toBadgeStash BSRStoreReceipt (receiptId, purchaseKey, purchasePrivKey, mk), payment, nextAttemptAt, retryDelay} + (Just payment, Just nextAttemptAt) -> RSHeld {stash = toBadgeStash BSRStoreReceipt (receiptId, purchaseKey, purchasePrivKey, mk), invoiceId, payment, nextAttemptAt, retryDelay} _ -> maybe (RSRefused refusal) RSCredited purchaseId_ -- | The record made when Buy is tapped, before any receipt: the store echoes its invoice id. @@ -220,7 +220,7 @@ getNextHeldStoreReceipt db userId = DB.query db [sql| - SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.payment, r.next_attempt_at, r.retry_delay + SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay FROM badge_store_receipts r JOIN users u ON u.user_id = r.user_id WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL @@ -229,8 +229,8 @@ getNextHeldStoreReceipt db userId = |] (Only userId) where - toHeld (stashRow@(receiptId, _, _, _) :. (payment, nextAttemptAt, retryDelay)) = - BadgeReceiptRecord {receiptId, ownerId = userId, status = RSHeld {stash = toBadgeStash BSRStoreReceipt stashRow, payment, nextAttemptAt, retryDelay}} + toHeld (stashRow@(receiptId, _, _, _) :. (invoiceId, payment, nextAttemptAt, retryDelay)) = + BadgeReceiptRecord {receiptId, ownerId = userId, status = RSHeld {stash = toBadgeStash BSRStoreReceipt stashRow, invoiceId, payment, nextAttemptAt, retryDelay}} recordStoreReceiptFailure :: DB.Connection -> Int64 -> Int64 -> UTCTime -> BadgeIssueFailure -> IO () recordStoreReceiptFailure db receiptId retryDelay nextAttemptAt failure = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 30211ea181..76c588776c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -484,7 +484,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)] CEvtBadgeChanged u st -> ttyUser u $ viewUserBadgeState st CEvtBadgeAlert u alert -> ttyUser u $ viewBadgeAlert alert - CEvtStorePurchaseResolved u -> ttyUser u ["store purchase resolved"] + CEvtStorePurchaseCredited u invoiceId -> ttyUser u ["store purchase credited: invoice " <> plain invoiceId] + CEvtStorePurchaseRefused u invoiceId refusal -> ttyUser u ["store purchase refused: invoice " <> plain invoiceId <> ", " <> plain (safeDecodeUtf8 $ strEncode refusal)] CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"] CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 82d8eeed1c..13e90fe7e2 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -1941,9 +1941,10 @@ testPurchaseBadge :: HasCallStack => TestParams -> IO () testPurchaseBadge ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - let purchase = "/_badge purchase 1 " <> paymentArg supporterPlay + invoiceId <- createInvoice alice 1 + let purchase = purchaseWithInvoice 1 invoiceId supporterPlay alice ##> purchase - inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] -- the app hands a purchase over until it is answered credited, and one already credited adds nothing alice ##> purchase alice <## "badge already redeemed" @@ -1957,19 +1958,21 @@ testPurchaseBadgeAppStore :: HasCallStack => TestParams -> IO () testPurchaseBadgeAppStore ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc, bsStore = FakeStore {appleLegendJWS}} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleLegendJWS}) - inAnyOrder alice [[openPurchase ""], creditedLines "" "1: legend"] + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = appleLegendJWS} + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: legend"] storePayments cc `shouldReturn` [("apple", Just 7000, Just "USD", 1)] testPurchaseStash :: HasCallStack => TestParams -> IO () testPurchaseStash ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 let records = rowCount (chatController alice) "badge_store_receipts" - refused = "/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase") + refused = purchaseWithInvoice 1 invoiceId (googlePayment "badge_supporter_01" "not-a-purchase") -- the store does not vouch for it, so it can never be credited, and the record says so alice ##> refused - alice <### [StartsWith (openPurchase ""), "store purchase resolved"] + alice <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final receipt_invalid")] alice ##> refused alice <## "cannot get badge: badge service error: receipt_invalid" records `shouldReturn` 1 @@ -1992,14 +1995,16 @@ testPurchaseStashReceiptUsed ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do - let purchase = "/_badge purchase 1 " <> paymentArg supporterPlay + invoiceId <- createInvoice alice 1 + -- the store echoes the buyer's invoice on any device the purchase reaches + let purchase = purchaseWithInvoice 1 invoiceId supporterPlay alice ##> purchase - inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] bob ##> purchase - bob <### [StartsWith (openPurchase ""), "store purchase resolved"] + bob <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final receipt_used")] bob ##> purchase bob <## "cannot get badge: badge service error: receipt_used" - storeReceiptRows (chatController bob) `shouldReturn` [(1, Nothing, True)] + storeReceiptRows (chatController bob) `shouldReturn` [(1, Just (T.pack invoiceId), True)] heldStoreReceipts (chatController bob) `shouldReturn` 0 rowCount (chatController alice) "badge_store_receipts" `shouldReturn` 1 rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 @@ -2033,12 +2038,13 @@ testPurchaseSameReceiptOtherProfile :: HasCallStack => TestParams -> IO () testPurchaseSameReceiptOtherProfile ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) - inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] alice ##> "/create user alisa" showActiveUser alice "alisa" -- the store transaction is the device's, so it stays with the profile it was bought under - alice ##> ("/_badge purchase 2 " <> paymentArg supporterPlay) + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay alice <## "[user: alice] badge already redeemed" rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 alice ##> "/p" @@ -2049,17 +2055,18 @@ testPurchaseStrandedUnderOtherProfile ps = do broken <- newIORef True withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do - let purchase userId = "/_badge purchase " <> show (userId :: Int) <> " " <> paymentArg supporterPlay + invoiceId <- createInvoice alice 1 + let purchase userId = purchaseWithInvoice userId invoiceId supporterPlay alice ##> purchase 1 - storePurchaseOpen alice "" + alice <##. invoicePurchase "" invoiceId waitStoreReceiptError (chatController alice) "service_error final internal" alice ##> "/create user alisa" showActiveUser alice "alisa" -- handed over again under whichever profile is active, the purchase stays with the keys alice holds alice ##> purchase 2 - storePurchaseOpen alice "[user: alice] " + alice <##. invoicePurchase "[user: alice] " invoiceId writeIORef broken False - mapM_ (alice <##.) $ creditedLines "[user: alice] " "1: supporter" + mapM_ (alice <##.) $ creditedLines "[user: alice] " invoiceId "1: supporter" (alice "/create user alisa" showActiveUser alice "alisa" alice ##> purchaseWithInvoice 2 invoiceId supporterPlay - inAnyOrder alice [["[user: alice] store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "[user: alice] " "1: supporter"] + inAnyOrder alice [[invoicePurchase "[user: alice] " invoiceId], creditedLines "[user: alice] " invoiceId "1: supporter"] (alice "/user alice" @@ -2110,7 +2117,7 @@ testInvoiceSameReceiptTwice ps = withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do invoiceId <- createInvoice alice 1 alice ##> purchaseWithInvoice 1 invoiceId supporterPlay - inAnyOrder alice [["store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "" "1: supporter"] + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] alice ##> purchaseWithInvoice 1 invoiceId supporterPlay alice <## "badge already redeemed" storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] @@ -2125,7 +2132,7 @@ testInvoiceUnknown ps = -- a reinstall or a second device: the store echoes an invoice this database never created let unknown = "0b6a5e6c-4f3e-4d51-9f55-3a0f7c2f9e11" alice ##> purchaseWithInvoice 2 unknown supporterPlay - inAnyOrder alice [["store purchase open: invoice " <> unknown <> ", transaction "], creditedLines "" "1: supporter"] + inAnyOrder alice [[invoicePurchase "" unknown], creditedLines "" unknown "1: supporter"] storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True)] testInvoiceLateReceipt :: HasCallStack => TestParams -> IO () @@ -2137,7 +2144,7 @@ testInvoiceLateReceipt ps = alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> purchaseWithInvoice 2 invoiceId supporterPlay - inAnyOrder alice [["[user: alice] store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "[user: alice] " "1: supporter"] + inAnyOrder alice [[invoicePurchase "[user: alice] " invoiceId], creditedLines "[user: alice] " invoiceId "1: supporter"] (alice unpaid), ConsoleString ("store purchase open: invoice " <> presented), - StartsWith ("store purchase open: invoice " <> refused <> ", transaction "), - "store purchase resolved" + StartsWith (invoicePurchase "" refused), + ConsoleString (refusedLine "" refused "service_error final receipt_invalid") ] alice ##> purchaseWithInvoice 1 presented (googlePayment "badge_supporter_01" googlePendingToken) alice <## ("store purchase open: invoice " <> unpaid) @@ -2216,11 +2223,12 @@ testStoreReceiptRetried ps = do broken <- newIORef True withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do - alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) - storePurchaseOpen alice "" + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <##. invoicePurchase "" invoiceId waitStoreReceiptError (chatController alice) "service_error final internal" writeIORef broken False - mapM_ (alice <##.) $ creditedLines "" "1: supporter" + mapM_ (alice <##.) $ creditedLines "" invoiceId "1: supporter" rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 testStoreReceiptNotBlocked :: HasCallStack => TestParams -> IO () @@ -2231,8 +2239,9 @@ testStoreReceiptNotBlocked ps = alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) storePurchaseOpen alice "" waitStoreReceiptError (chatController alice) "service_error retry provider_unavailable" - alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) - inAnyOrder alice [[openPurchase "", openPurchase ""], creditedLines "" "1: supporter"] + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = appleSupporterJWS} + inAnyOrder alice [[openPurchase "", invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] heldStoreReceipts (chatController alice) `shouldReturn` 1 testStoreRefusalKept :: HasCallStack => TestParams -> IO () @@ -2243,7 +2252,10 @@ testStoreRefusalKept ps = do withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do let refused = "/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase") alice ##> refused - alice <### [StartsWith (openPurchase ""), "store purchase resolved"] + storePurchaseOpen alice "" + -- with no invoice id the app has no key to the store transaction, so the refusal is not announced + waitHeldStoreReceipts (chatController alice) 0 + (alice refused alice <## "cannot get badge: badge service error: receipt_invalid" alice ##> "/_badge state 1" @@ -2264,7 +2276,7 @@ testStoreSettledOtherProfile ps = alice <### [ StartsWith ("[user: alice] store purchase open: invoice " <> shown <> ", transaction "), ConsoleString ("store purchase open: invoice " <> hidden), - "[user: alice] store purchase resolved" + ConsoleString (refusedLine "[user: alice] " shown "service_error final receipt_invalid") ] alice ##> "/_hide user 1 \"password\"" alice <## "user alice:" @@ -2280,11 +2292,12 @@ testStoreReceiptTwoAtOnce :: HasCallStack => TestParams -> IO () testStoreReceiptTwoAtOnce ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - let handOver = void $ sendChatCmdStr (chatController alice) ("/_badge purchase 1 " <> paymentArg supporterPlay) + invoiceId <- createInvoice alice 1 + let handOver = void $ sendChatCmdStr (chatController alice) (purchaseWithInvoice 1 invoiceId supporterPlay) concurrentlyN_ [handOver, handOver] - mapM_ (alice <##.) $ creditedLines "" "1: supporter" + mapM_ (alice <##.) $ creditedLines "" invoiceId "1: supporter" (alice TestParams -> IO () @@ -2294,9 +2307,11 @@ testStoreReceiptCleanup ps = withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do unpaid <- createInvoice alice 1 alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase")) - alice <### [ConsoleString ("store purchase open: invoice " <> unpaid), StartsWith (openPurchase ""), "store purchase resolved"] + alice <## ("store purchase open: invoice " <> unpaid) + storePurchaseOpen alice "" + waitHeldStoreReceipts (chatController alice) 0 alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) - inAnyOrder alice [["store purchase open: invoice " <> unpaid, openPurchase ""], creditedLines "" "1: supporter"] + alice <### [ConsoleString ("store purchase open: invoice " <> unpaid), StartsWith (openPurchase ""), StartsWith "1: supporter"] alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googleUnreachableToken)) alice <##. "1: supporter" alice <## ("store purchase open: invoice " <> unpaid) @@ -2435,6 +2450,12 @@ storePurchaseOpen cc userPrefix = cc <##. openPurchase userPrefix openPurchase :: String -> String openPurchase userPrefix = userPrefix <> "store purchase open: invoice none, transaction " +invoicePurchase :: String -> String -> String +invoicePurchase userPrefix invoiceId = userPrefix <> "store purchase open: invoice " <> invoiceId <> ", transaction " + -- | The worker's credit as the owner's terminal shows it: the badge, then the settlement. -creditedLines :: String -> String -> [String] -creditedLines userPrefix badge = [userPrefix <> badge, userPrefix <> "store purchase resolved"] +creditedLines :: String -> String -> String -> [String] +creditedLines userPrefix invoiceId badge = [userPrefix <> badge, userPrefix <> "store purchase credited: invoice " <> invoiceId] + +refusedLine :: String -> String -> String -> String +refusedLine userPrefix invoiceId failure = userPrefix <> "store purchase refused: invoice " <> invoiceId <> ", " <> failure From efab293609c760a1a506e529cdcb08e3380abc06 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:48:50 +0400 Subject: [PATCH 07/84] desktop: deep links on macOS, Windows and Linux (#7605) * tests: close the walk stream in withTempDir * desktop: receive app links from args and macOS * desktop: forward an app link to the running app * desktop: initialize the desktop platform lazily * tests: share the temp directory helper * desktop: register simplexchat scheme in packages * desktop: register simplexchat scheme at runtime * ui: end the badge page with a link on desktop * desktop: raise the window over other apps * docs: describe desktop deep links in the spec * docs: plan desktop deep links * docs: report on desktop deep links * desktop: open simplex connection links * docs: describe simplex links on desktop * desktop: keep a link from opening twice * desktop: retry taking a signal held open * desktop: keep the user's AppImage link handler * desktop: register Windows links through Capabilities * desktop: add %u to the deb entry only once * docs: describe the review fixes * ui: open the badge link screen on the left * ui: import moko accessors in the badge screens --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/multiplatform/CODE.md | 6 +- .../common/platform/AppCommon.android.kt | 3 + .../chat/simplex/common/platform/AppCommon.kt | 2 + .../simplex/common/views/badges/BadgeStore.kt | 6 +- .../views/badges/BadgesRedeemCodeView.kt | 8 +- .../views/badges/BadgesSupportSimplexView.kt | 9 +- .../common/views/chatlist/ChatListView.kt | 6 +- .../chat/simplex/app/BadgePageUrlTest.kt | 19 + .../kotlin/chat/simplex/common/AppLinks.kt | 33 + .../kotlin/chat/simplex/common/DesktopApp.kt | 5 + .../chat/simplex/common/SingleInstance.kt | 193 ++++- .../chat/simplex/common/WindowActivation.kt | 63 ++ .../common/platform/AppLinkScheme.desktop.kt | 368 ++++++++++ .../common/platform/Platform.desktop.kt | 6 +- .../chat/simplex/app/AppLinkFixtures.kt | 14 + .../chat/simplex/app/AppLinkSchemeTest.kt | 680 ++++++++++++++++++ .../kotlin/chat/simplex/app/AppLinksTest.kt | 71 ++ .../chat/simplex/app/SingleInstanceTest.kt | 281 +++++++- .../kotlin/chat/simplex/app/TempDir.kt | 20 + .../chat/simplex/app/WindowActivationTest.kt | 86 +++ apps/multiplatform/desktop/build.gradle.kts | 12 + .../kotlin/chat/simplex/desktop/Main.kt | 19 +- .../resources/distribute/SimpleX.desktop | 3 +- apps/multiplatform/product/concepts.md | 1 + apps/multiplatform/product/gaps.md | 33 + apps/multiplatform/spec/README.md | 8 +- apps/multiplatform/spec/architecture.md | 18 +- apps/multiplatform/spec/client/navigation.md | 8 +- apps/multiplatform/spec/impact.md | 4 + plans/2026-09-24-badge-buy-in-browser.md | 12 +- plans/2026-09-28-desktop-deep-links-report.md | 150 ++++ plans/2026-09-28-desktop-deep-links.md | 145 ++++ scripts/desktop/make-appimage-linux.sh | 2 +- scripts/desktop/make-deb-linux.sh | 4 + scripts/flatpak/chat.simplex.simplex.desktop | 1 + 35 files changed, 2219 insertions(+), 80 deletions(-) create mode 100644 apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt create mode 100644 plans/2026-09-28-desktop-deep-links-report.md create mode 100644 plans/2026-09-28-desktop-deep-links.md diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md index 30462d18e8..d27b4b9718 100644 --- a/apps/multiplatform/CODE.md +++ b/apps/multiplatform/CODE.md @@ -285,9 +285,13 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) | Source Location | Spec Document | Product Document | |----------------|---------------|-----------------| -| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md | +| desktop/.../desktop/Main.kt | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md | | common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | | common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md | +| common/.../common/AppLinks.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md | +| common/.../common/SingleInstance.kt (desktopMain) | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md | +| common/.../common/WindowActivation.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | +| common/.../common/platform/AppLinkScheme.desktop.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md | | common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md | | common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md | | common/.../common/platform/AnimatedImage.desktop.kt (desktopMain) | spec/client/chat-view.md | product/views/chat.md | diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt index a5e105ce93..f192b4da35 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt @@ -25,6 +25,9 @@ val defaultLocale: Locale = Locale.getDefault() actual fun isAppVisibleAndFocused(): Boolean = isAppOnForeground +// the manifest declares the scheme +actual fun appLinkSchemeRegistered(): Boolean = true + @SuppressLint("StaticFieldLeak") lateinit var androidAppContext: Context var mainActivity: WeakReference = WeakReference(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index a6ede20e41..1da9d8ff40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -25,6 +25,8 @@ expect val deviceName: String expect fun isAppVisibleAndFocused(): Boolean +expect fun appLinkSchemeRegistered(): Boolean + val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE 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 6e846678c2..5f63f38670 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 @@ -52,10 +52,10 @@ fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() // the page's app flag rides in the fragment, which never reaches the service // TEST ONLY: pointed at the dev deployment; restore the two lines below before merging -val badgePageUrl: String = - if (appPlatform.isAndroid) "https://smp7.simplex.im/#/tier?app=true" +fun badgePageUrl(linkReturns: Boolean): String = + if (linkReturns) "https://smp7.simplex.im/#/tier?app=true" else "https://smp7.simplex.im/#/tier?app=desktop" -// if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true" +// if (linkReturns) "https://badges.simplex.chat/#/tier?app=true" // else "https://badges.simplex.chat/#/tier?app=desktop" // where the store allows a link out to the badge page: the US for now, a set expected to widen. 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 5c943b254c..b3e74d3e2c 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 @@ -262,7 +262,7 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) { private var badgeLinkStep: MutableState? = null private fun isBadgeLinkOpen(): Boolean = - ModalManager.end.hasModalOpen(ModalViewId.BADGE_LINK) + ModalManager.start.hasModalOpen(ModalViewId.BADGE_LINK) fun isBadgeLinkIssuing(): Boolean = isBadgeLinkOpen() && badgeLinkStep?.value == BadgeLinkStep.Issuing @@ -275,7 +275,7 @@ fun openBadgeLink(rhId: Long?, codeText: String) { // 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 -> + ModalManager.start.showCustomModal(id = ModalViewId.BADGE_LINK) { close -> BadgesRedeemLinkView(rhId, code, step, close) } badgeLinkStep = step @@ -298,7 +298,7 @@ fun BadgesRedeemLinkView(rhId: Long?, code: String, step: MutableState ModalView(::closeIfShowing) { BeingIssued(onDismiss = ::closeIfShowing) } - BadgeLinkStep.Redeemed, BadgeLinkStep.ViewingBadge -> BadgesView(ModalManager.end, close) + BadgeLinkStep.Redeemed, BadgeLinkStep.ViewingBadge -> BadgesView(ModalManager.start, close) } } 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 040a2c6e19..f81d561120 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 @@ -130,7 +130,7 @@ private fun BuyInBrowserTextButton() { val uriHandler = LocalUriHandler.current TextButtonBelowOnboardingButton( text = stringResource(MR.strings.badges_buy_in_browser), - onClick = { uriHandler.openUriCatching(badgePageUrl) } + onClick = { uriHandler.openUriCatching(badgePageUrl(appLinkSchemeRegistered())) } ) } @@ -142,9 +142,10 @@ private fun BuyInBrowserButton(modalManager: ModalManager, unwindToDepth: Int) { 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)) { + val linkReturns = appLinkSchemeRegistered() + uriHandler.openUriCatching(badgePageUrl(linkReturns)) + // with no scheme to bring the code back, the code is pasted into this screen, opened beside the browser + if (!linkReturns && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) { modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) } } } 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 ba64f807ba..31dfc4f64d 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 @@ -789,13 +789,17 @@ 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" +internal const val appLinkScheme = "simplexchat" +internal const val connectionLinkScheme = "simplex" 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) +fun isConnectionLink(uri: String): Boolean = + uri.startsWith("$connectionLinkScheme:", 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 diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt new file mode 100644 index 0000000000..c6baf89fcb --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt @@ -0,0 +1,19 @@ +package chat.simplex.app + +import chat.simplex.common.views.badges.badgePageUrl +import kotlin.test.Test +import kotlin.test.assertTrue + +class BadgePageUrlTest { + @Test + fun pageEndsWithALinkBackWhenTheLinkReturnsToTheApp() { + val url = badgePageUrl(linkReturns = true) + assertTrue(url.endsWith("#/tier?app=true"), "url: $url") + } + + @Test + fun pageEndsWithACodeToPasteWhenNoLinkReturns() { + val url = badgePageUrl(linkReturns = false) + assertTrue(url.endsWith("#/tier?app=desktop"), "url: $url") + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt new file mode 100644 index 0000000000..067d4edfd6 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt @@ -0,0 +1,33 @@ +package chat.simplex.common + +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chatlist.isAppLink +import chat.simplex.common.views.chatlist.isConnectionLink +import java.awt.Desktop + +// A one-time link with its post-quantum key is about 2 KB, near 4 KB with a second such key; +// the bound caps what the app accepts from outside. +internal const val MAX_LINK_BYTES = 8192 + +internal fun isAcceptedLink(uri: String): Boolean = + uri.toByteArray(Charsets.UTF_8).size <= MAX_LINK_BYTES && (isAppLink(uri) || isConnectionLink(uri)) + +fun linkFromArgs(args: Array): String? = + args.singleOrNull()?.takeIf(::isAcceptedLink) + +// The link is stored before showWindow, so a failure to show the window cannot lose it. +internal fun openDesktopLink(uri: String) { + chatModel.appOpenUrl.value = chatModel.remoteHostId() to uri + showWindow() +} + +// macOS delivers a link as an Apple Event, never in args; the JDK queues it until a handler is set +fun installOpenUriHandler() { + if (!Desktop.isDesktopSupported()) return + val desktop = Desktop.getDesktop() + if (!desktop.isSupported(Desktop.Action.APP_OPEN_URI)) return + desktop.setOpenURIHandler { event -> + val uri = event.uri.toString() + if (isAcceptedLink(uri)) openDesktopLink(uri) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 3e48621d03..77dad81aa4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -259,11 +259,16 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState { lockHandle = result.lock singleInstanceLock = true - deleteShowFile() - startShowFileWatcher() + deleteStaleSignalFiles(dataDir.toPath(), lockAttemptTime) return true } LockResult.Failed -> { return true } LockResult.Taken -> { - // Ensure the signal file exists (createShowFile is a no-op if it does) - // and wait up to 1s for the primary's watcher to consume it. If still - // there after the wait, the primary is hung — let the user decide. - createShowFile() - val deadline = System.currentTimeMillis() + 1000 - while (Files.exists(showPath) && System.currentTimeMillis() < deadline) { - try { Thread.sleep(50) } catch (_: InterruptedException) { break } - } - if (!Files.exists(showPath)) return false - val start = showSingleInstanceAlert() - if (start) deleteShowFile() - return start + if (desktopPlatform.isWindows()) allowPrimaryForeground() + return startDespiteRunningInstance(dataDir.toPath(), link, SIGNAL_WAIT_MILLIS, ::showSingleInstanceAlert) } } } @@ -84,20 +90,129 @@ private fun tryAcquireLock(): LockResult { } } -private fun deleteShowFile() { - try { Files.deleteIfExists(showPath) } catch (e: IOException) { - Log.w(TAG, "single-instance: cannot delete show file: ${e.message}") +// A signal still present after the wait means the running instance is hung, so the user decides. +internal fun startDespiteRunningInstance(dir: Path, link: String?, waitMillis: Long, askToStart: () -> Boolean): Boolean { + signalRunningInstance(dir, link) + val show = dir.resolve(SHOW_FILE) + val deadline = System.currentTimeMillis() + waitMillis + while (Files.exists(show) && System.currentTimeMillis() < deadline) { + try { Thread.sleep(SIGNAL_POLL_MILLIS) } catch (_: InterruptedException) { break } + } + if (!Files.exists(show) || !askToStart()) return false + // The running instance can recover while the alert is up; once it has taken the signal, it opens the link itself. + return try { + Files.deleteIfExists(show) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot withdraw signal file: ${e.message}") + true } } -private fun createShowFile() { - try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) { - // Another duplicate already signalled; primary will pick it up. - } catch (e: IOException) { - Log.w(TAG, "single-instance: cannot create show file: ${e.message}") +private fun deleteSignalFile(path: Path) { + try { Files.deleteIfExists(path) } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete signal file: ${e.message}") } } +internal fun deleteStaleSignalFiles(dir: Path, lockAttemptTime: FileTime) { + val staleBefore = FileTime.from(lockAttemptTime.toInstant().minus(FILE_TIME_TOLERANCE)) + try { + // only a running instance takes a signal, so a taken file at startup was left by a crash + Files.deleteIfExists(dir.resolve(TAKEN_SHOW_FILE)) + Files.newDirectoryStream(dir, "$SHOW_FILE*").use { files -> + files.forEach { file -> + try { + if (Files.getLastModifiedTime(file) < staleBefore) Files.deleteIfExists(file) + } catch (_: NoSuchFileException) { + // a second process renamed its temp file meanwhile + } + } + } + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete stale signal files: ${e.message}") + } catch (e: DirectoryIteratorException) { + Log.w(TAG, "single-instance: cannot delete stale signal files: ${e.cause?.message}") + } +} + +// The temp file is owner-only on POSIX, as the link is a bearer secret, and the rename makes it appear whole. +internal fun signalRunningInstance(dir: Path, link: String?) { + var tmp: Path? = null + try { + tmp = Files.createTempFile(dir, SHOW_FILE, SHOW_TMP_SUFFIX) + Files.write(tmp, (link ?: "").toByteArray(Charsets.UTF_8)) + Files.move(tmp, dir.resolve(SHOW_FILE), ATOMIC_MOVE) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot signal running instance: ${e.message}") + tmp?.let(::deleteSignalFile) + // an empty signal needs no free space and still brings the running instance forward + try { + Files.createFile(dir.resolve(SHOW_FILE)) + } catch (_: FileAlreadyExistsException) { + // another process has already signalled + } catch (createError: IOException) { + Log.w(TAG, "single-instance: cannot create signal file: ${createError.message}") + } + } +} + +// The signal is renamed before it is read, so a newer signal renamed over it meanwhile is not deleted unread. +internal fun takeSignal(dir: Path, move: (Path, Path) -> Unit = ::moveAtomically): ShowSignal? { + val taken = dir.resolve(TAKEN_SHOW_FILE) + try { + moveWithRetry(dir.resolve(SHOW_FILE), taken, move) + } catch (_: NoSuchFileException) { + return null + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot take signal file: ${e.message}") + return ShowSignal(null) + } + val bytes = try { + Files.newInputStream(taken).use { it.readNBytes(MAX_LINK_BYTES + 1) } + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot read signal file: ${e.message}") + null + } finally { + deleteSignalFile(taken) + } + return ShowSignal(bytes?.toString(Charsets.UTF_8)?.takeIf(::isAcceptedLink)) +} + +private fun moveAtomically(from: Path, to: Path) { + Files.move(from, to, ATOMIC_MOVE) +} + +// Another program, such as an antivirus scanner on Windows, can hold the file open for a moment. +private fun moveWithRetry(from: Path, to: Path, move: (Path, Path) -> Unit) { + try { + move(from, to) + } catch (e: NoSuchFileException) { + throw e + } catch (e: IOException) { + try { + Thread.sleep(SIGNAL_RETRY_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } + move(from, to) + } +} + +// Win32 ASFW_ANY lets every process take the foreground +private const val ASFW_ANY = -1 + +// Windows lets only the foreground process raise a window; the launching browser passed that right to this process. +private fun allowPrimaryForeground() { + val allowed = try { + NativeLibrary.getInstance("user32").getFunction("AllowSetForegroundWindow").invokeInt(arrayOf(ASFW_ANY)) != 0 + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "single-instance: AllowSetForegroundWindow unavailable: ${e.message}") + return + } + if (!allowed) Log.w(TAG, "single-instance: AllowSetForegroundWindow refused") +} + private fun showSingleInstanceAlert(): Boolean { val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title) val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding) @@ -109,26 +224,34 @@ private fun showSingleInstanceAlert(): Boolean { return result == javax.swing.JOptionPane.YES_OPTION } -private fun startShowFileWatcher() { +fun startShowFileWatcher() { if (watcher != null) return + val dir = dataDir.toPath() val ws = try { - dataDir.toPath().fileSystem.newWatchService() + dir.fileSystem.newWatchService() } catch (e: IOException) { Log.w(TAG, "single-instance: WatchService failed: ${e.message}") return } - dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE) + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) watcher = ws thread(name = "simplex-single-instance", isDaemon = true) { - while (true) { - val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread } - for (event in key.pollEvents()) { - if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") { - deleteShowFile() - SwingUtilities.invokeLater { showWindow() } - } + watchShowSignals(ws, dir) { signal -> + SwingUtilities.invokeLater { + if (signal.link != null) openDesktopLink(signal.link) else showWindow() } - if (!key.reset()) return@thread } } } + +// dir must already be registered with ws; a signal written before that raised no event, so it is taken first. +internal fun watchShowSignals(ws: WatchService, dir: Path, onSignal: (ShowSignal) -> Unit) { + takeSignal(dir)?.let(onSignal) + while (true) { + val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return } catch (_: InterruptedException) { return } + for (event in key.pollEvents()) { + if ((event.context() as? Path)?.fileName?.toString() == SHOW_FILE) takeSignal(dir)?.let(onSignal) + } + if (!key.reset()) return + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt new file mode 100644 index 0000000000..0859a75225 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt @@ -0,0 +1,63 @@ +package chat.simplex.common + +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.platform.unix.X11 +import java.awt.Toolkit +import java.awt.Window + +// EWMH source indication of a pager, which window managers exempt from focus stealing prevention +private const val NET_ACTIVE_WINDOW_SOURCE_PAGER = 2L +private const val CLIENT_MESSAGE_FORMAT_BITS = 32 + +// AWT's toFront() sends only XRaiseWindow, which wlroots compositors (Sway) drop and focus stealing +// prevention refuses; _NET_ACTIVE_WINDOW is the request X11 and XWayland window managers act on. +internal fun requestX11Activation(window: Window) { + val x11: X11 + val windowId: Long + try { + x11 = X11.INSTANCE + windowId = Native.getWindowID(window) + } catch (e: LinkageError) { + Log.w(TAG, "window activation: native library unavailable: ${e.message}") + return + } catch (e: IllegalStateException) { + Log.w(TAG, "window activation: no native window: ${e.message}") + return + } + // The map request goes out on AWT's connection; flushing it first lets the window manager map the window before activating it. + Toolkit.getDefaultToolkit().sync() + sendActivation(x11, windowId) +} + +internal fun sendActivation(x11: X11, windowId: Long) { + val display = x11.XOpenDisplay(null) + if (display == null) { + Log.w(TAG, "window activation: cannot open the X display") + return + } + try { + val event = activationEvent(X11.Window(windowId), x11.XInternAtom(display, "_NET_ACTIVE_WINDOW", false)) + val mask = NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()) + x11.XSendEvent(display, x11.XDefaultRootWindow(display), 0, mask, event) + x11.XFlush(display) + } finally { + x11.XCloseDisplay(display) + } +} + +internal fun activationEvent(window: X11.Window, activeWindowAtom: X11.Atom): X11.XEvent { + val event = X11.XEvent() + event.setType(X11.XClientMessageEvent::class.java) + event.xclient.type = X11.ClientMessage + event.xclient.window = window + event.xclient.message_type = activeWindowAtom + event.xclient.format = CLIENT_MESSAGE_FORMAT_BITS + event.xclient.data.setType(Array::class.java) + event.xclient.data.l[0] = NativeLong(NET_ACTIVE_WINDOW_SOURCE_PAGER) + // the request carries no user interaction timestamp + event.xclient.data.l[1] = NativeLong(X11.CurrentTime.toLong()) + return event +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt new file mode 100644 index 0000000000..1187316959 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt @@ -0,0 +1,368 @@ +package chat.simplex.common.platform + +import chat.simplex.common.views.chatlist.appLinkScheme +import chat.simplex.common.views.chatlist.connectionLinkScheme +import com.sun.jna.NativeLibrary +import com.sun.jna.WString +import com.sun.jna.platform.win32.Advapi32Util +import com.sun.jna.platform.win32.Win32Exception +import com.sun.jna.platform.win32.WinReg.HKEY +import com.sun.jna.platform.win32.WinReg.HKEY_CURRENT_USER +import com.sun.jna.platform.win32.WinReg.HKEY_LOCAL_MACHINE +import com.sun.jna.ptr.IntByReference +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +internal const val APP_LINK_MIME_TYPE = "x-scheme-handler/$appLinkScheme" +internal const val CONNECTION_LINK_MIME_TYPE = "x-scheme-handler/$connectionLinkScheme" +private const val WINDOWS_CLASSES_KEY = "Software\\Classes" +private const val WINDOWS_PROG_ID_PREFIX = "SimpleX." +private const val WINDOWS_CAPABILITIES_KEY = "Software\\chat.simplex.app\\Capabilities" +private const val WINDOWS_REGISTERED_APPLICATIONS_KEY = "Software\\RegisteredApplications" +private const val APP_DESCRIPTION = "Private and secure open-source messenger - no user IDs (not even random numbers)" +private const val WINDOWS_FIRST_ICON_INDEX = 0 +// values from shlwapi.h and shlobj_core.h +private const val ASSOCF_IS_PROTOCOL = 0x1000 +private const val ASSOCSTR_EXECUTABLE = 2 +private const val S_OK = 0 +private const val S_FALSE = 1 +private const val SHCNE_ASSOCCHANGED = 0x08000000 +private const val SHCNF_IDLIST = 0 +// the Win32 name of a key's default value +private const val REGISTRY_DEFAULT_VALUE = "" +internal const val APPIMAGE_ENTRY_NAME = "chat.simplex.app-links.desktop" +private const val APPLICATIONS_DIR = "applications" +// the XDG Base Directory default when XDG_DATA_DIRS is unset +private const val DEFAULT_XDG_DATA_DIRS = "/usr/local/share:/usr/share" +private const val APP_DISPLAY_NAME = "SimpleX Chat" +private const val XDG_MIME = "xdg-mime" +// jpackage names the deb's desktop entry -.desktop +private const val DEB_ENTRY_NAME = "simplex-simplex.desktop" +private const val PROCESS_TIMEOUT_SECONDS = 5L +private const val ROOT_USER = "root" +// the characters the desktop entry spec requires an Exec argument to quote +private const val EXEC_RESERVED_CHARACTERS = " \t\n\"'\\><~|&;$*?#()`" + +internal sealed interface DesktopInstallation { + data object MacBundle : DesktopInstallation + data class WindowsExe(val path: String) : DesktopInstallation + data object Flatpak : DesktopInstallation + data class AppImage(val path: String) : DesktopInstallation + data object LinuxPackage : DesktopInstallation + data object Unpackaged : DesktopInstallation +} + +// Only simplexchat: decides how the badge page ends, so simplex: is registered without being checked here. +@Volatile +private var registered = false + +actual fun appLinkSchemeRegistered(): Boolean = registered + +fun registerLinkSchemes() { + thread(name = "simplex-link-schemes", isDaemon = true) { + registered = registerForThisInstallation() + } +} + +// appPath is jpackage.app-path, set by jpackage's launcher, so only a packaged app has one. +internal fun desktopInstallation(platform: DesktopPlatform, appPath: String?, env: Map): DesktopInstallation { + val appImage = env["APPIMAGE"] + val appDir = env["APPDIR"] + return when { + appPath == null -> DesktopInstallation.Unpackaged + platform.isMac() -> DesktopInstallation.MacBundle + platform.isWindows() -> DesktopInstallation.WindowsExe(appPath) + env["container"] == "flatpak" -> DesktopInstallation.Flatpak + // The AppImage runtime exports APPIMAGE to every child process, so it describes this app only + // when the launcher runs from the AppImage's own mount. + appImage != null && appDir != null && Path.of(appPath).startsWith(realPathOrSelf(appDir)) -> DesktopInstallation.AppImage(appImage) + else -> DesktopInstallation.LinuxPackage + } +} + +// jpackage.app-path is resolved through /proc/self/exe, while APPDIR keeps any symlink in TMPDIR. +private fun realPathOrSelf(path: String): Path = + try { + Path.of(path).toRealPath() + } catch (_: IOException) { + Path.of(path) + } + +private fun registerForThisInstallation(): Boolean = + when (val installation = desktopInstallation(desktopPlatform, System.getProperty("jpackage.app-path"), System.getenv())) { + // The bundle's Info.plist and the Flatpak's exported desktop entry register the schemes. + DesktopInstallation.MacBundle, DesktopInstallation.Flatpak -> true + is DesktopInstallation.WindowsExe -> registerWindowsSchemes(installation.path, SystemWindowsShell) + is DesktopInstallation.AppImage -> + registerAppImageScheme(installation.path, File(unixDataHome, APPLICATIONS_DIR), systemApplicationsDirs(), ::runProcess) + DesktopInstallation.LinuxPackage -> linuxPackageRegistered(::runProcess) + DesktopInstallation.Unpackaged -> false + } + +internal fun windowsOpenCommand(appPath: String): String = "\"$appPath\" \"%1\"" + +internal fun windowsCommandProgram(command: String): String = + if (command.startsWith("\"")) command.substring(1).substringBefore('"') else command.substringBefore(' ') + +internal data class RegistryValue(val key: String, val name: String, val value: String) + +private fun windowsSchemeKey(scheme: String): String = "$WINDOWS_CLASSES_KEY\\$scheme" + +private fun windowsCommandKey(scheme: String): String = "${windowsSchemeKey(scheme)}\\shell\\open\\command" + +private fun windowsProgId(scheme: String): String = "$WINDOWS_PROG_ID_PREFIX$scheme" + +private fun windowsProgIdKey(scheme: String): String = "$WINDOWS_CLASSES_KEY\\${windowsProgId(scheme)}" + +private fun windowsProgIdCommandKey(scheme: String): String = "${windowsProgIdKey(scheme)}\\shell\\open\\command" + +private fun windowsIcon(appPath: String): String = "\"$appPath\",$WINDOWS_FIRST_ICON_INDEX" + +// The scheme key declares the URL scheme, but its command opens links only while no app registered through +// Capabilities claims the scheme: Windows settings offer only the ProgIds that Capabilities name. +internal fun windowsSchemeValues(scheme: String, appPath: String): List { + val schemeKey = windowsSchemeKey(scheme) + return listOf( + RegistryValue(key = schemeKey, name = REGISTRY_DEFAULT_VALUE, value = "URL:$APP_DISPLAY_NAME"), + // without this value Windows does not treat the key as a URL scheme + RegistryValue(key = schemeKey, name = "URL Protocol", value = ""), + RegistryValue(key = "$schemeKey\\DefaultIcon", name = REGISTRY_DEFAULT_VALUE, value = windowsIcon(appPath)), + RegistryValue(key = windowsCommandKey(scheme), name = REGISTRY_DEFAULT_VALUE, value = windowsOpenCommand(appPath)), + RegistryValue(key = "$WINDOWS_CAPABILITIES_KEY\\URLAssociations", name = scheme, value = windowsProgId(scheme)), + ) +} + +// The ProgId is what links resolve to once the user picks SimpleX in settings, so it is repaired even while +// another program holds the scheme key, but another installed copy's ProgId is kept like any other handler. +internal fun windowsProgIdValues(scheme: String, appPath: String): List { + val progIdKey = windowsProgIdKey(scheme) + return listOf( + RegistryValue(key = progIdKey, name = REGISTRY_DEFAULT_VALUE, value = "URL:$APP_DISPLAY_NAME"), + RegistryValue(key = "$progIdKey\\DefaultIcon", name = REGISTRY_DEFAULT_VALUE, value = windowsIcon(appPath)), + RegistryValue(key = windowsProgIdCommandKey(scheme), name = REGISTRY_DEFAULT_VALUE, value = windowsOpenCommand(appPath)), + ) +} + +// The application is listed last, so Windows never lists it before its capabilities are complete. +internal val windowsApplicationValues: List = listOf( + RegistryValue(key = WINDOWS_CAPABILITIES_KEY, name = "ApplicationName", value = APP_DISPLAY_NAME), + RegistryValue(key = WINDOWS_CAPABILITIES_KEY, name = "ApplicationDescription", value = APP_DESCRIPTION), + RegistryValue(key = WINDOWS_REGISTERED_APPLICATIONS_KEY, name = APP_DISPLAY_NAME, value = WINDOWS_CAPABILITIES_KEY), +) + +internal interface WindowsShell { + fun readUser(key: String, name: String): String? + fun readMachine(key: String, name: String): String? + fun writeUser(value: RegistryValue) + // the program the shell starts for a link, after any choice made in Windows settings + fun linkHandler(scheme: String): String? + fun notifyAssociationsChanged() +} + +internal fun registerWindowsSchemes(appPath: String, shell: WindowsShell): Boolean { + var changed = false + val tracked = object : WindowsShell by shell { + override fun writeUser(value: RegistryValue) { + shell.writeUser(value) + changed = true + } + } + val associated = listOf(appLinkScheme, connectionLinkScheme).filter { registerWindowsScheme(it, appPath, tracked) } + if (associated.isNotEmpty()) writeMissing(windowsApplicationValues, tracked, "the application") + // The notification also clears Explorer's icon cache, so it is sent only after a change. + if (changed) shell.notifyAssociationsChanged() + return shell.linkHandler(appLinkScheme)?.let { isThisProgram(it, appPath) } == true +} + +private fun registerWindowsScheme(scheme: String, appPath: String, shell: WindowsShell): Boolean = + !heldByAnotherProgram(windowsProgIdCommandKey(scheme), appPath, shell) && + writeMissing(windowsProgIdValues(scheme, appPath), shell, "the $scheme: ProgId") && + !heldByAnotherProgram(windowsCommandKey(scheme), appPath, shell) && + writeMissing(windowsSchemeValues(scheme, appPath), shell, "the $scheme: scheme") + +private fun isThisProgram(program: String, appPath: String): Boolean = + File(program).absolutePath.equals(File(appPath).absolutePath, ignoreCase = true) + +// A per-user key hides the machine-wide one, so the machine-wide command counts only without it. +private fun heldByAnotherProgram(commandKey: String, appPath: String, shell: WindowsShell): Boolean = + try { + val current = shell.readUser(commandKey, REGISTRY_DEFAULT_VALUE) ?: shell.readMachine(commandKey, REGISTRY_DEFAULT_VALUE) + val program = current?.let(::windowsCommandProgram) + program != null && !isThisProgram(program, appPath) && File(program).isFile + } catch (e: Win32Exception) { + Log.w(TAG, "link scheme: cannot read the handler under $commandKey: ${e.message}") + true + } + +private fun writeMissing(values: List, shell: WindowsShell, what: String): Boolean { + fun stored() = values.all { shell.readUser(it.key, it.name) == it.value } + return try { + if (!stored()) values.forEach(shell::writeUser) + stored() + } catch (e: Win32Exception) { + Log.w(TAG, "link scheme: cannot register $what in the registry: ${e.message}") + false + } +} + +private object SystemWindowsShell : WindowsShell { + override fun readUser(key: String, name: String): String? = registryString(HKEY_CURRENT_USER, key, name) + + override fun readMachine(key: String, name: String): String? = registryString(HKEY_LOCAL_MACHINE, key, name) + + override fun writeUser(value: RegistryValue) { + Advapi32Util.registryCreateKey(HKEY_CURRENT_USER, value.key) + Advapi32Util.registrySetStringValue(HKEY_CURRENT_USER, value.key, value.name, value.value) + } + + // jna-platform 5.14 binds neither AssocQueryStringW nor SHChangeNotify. + override fun linkHandler(scheme: String): String? = + try { + val assocQueryString = NativeLibrary.getInstance("shlwapi").getFunction("AssocQueryStringW") + val length = IntByReference(0) + // with no buffer the call reports the length it needs + val sized = assocQueryString.invokeInt(arrayOf(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, WString(scheme), null, null, length)) + if (sized != S_FALSE) { + null + } else { + val out = CharArray(length.value) + val result = assocQueryString.invokeInt(arrayOf(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, WString(scheme), null, out, length)) + if (result == S_OK) String(out).substringBefore('\u0000') else null + } + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "link scheme: cannot query the $scheme: handler: ${e.message}") + null + } + + override fun notifyAssociationsChanged() { + try { + NativeLibrary.getInstance("shell32").getFunction("SHChangeNotify").invokeVoid(arrayOf(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, null, null)) + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "link scheme: cannot announce changed associations: ${e.message}") + } + } +} + +// registryGetStringValue throws a RuntimeException for a value of another type, which another program could write. +private fun registryString(hive: HKEY, key: String, name: String): String? = + if (Advapi32Util.registryValueExists(hive, key, name)) { + Advapi32Util.registryGetValue(hive, key, name) as? String + } else { + null + } + +// Per the desktop entry spec, quoting escapes " ` $ \ and the string escape then doubles every backslash. +// xdg-open 1.1.3 cannot run a quoted program, so quotes are added only when required. +internal fun desktopExecArgument(arg: String): String { + if (arg.none { it in EXEC_RESERVED_CHARACTERS }) return arg + val quoted = arg.replace("\\", "\\\\").replace("\"", "\\\"").replace("`", "\\`").replace("$", "\\$") + return "\"" + quoted.replace("\\", "\\\\") + "\"" +} + +internal fun appImageDesktopEntry(appImagePath: String): String? { + // A line break would end the Exec value and start a key of the path's choosing. + // GLib looks the program up before it expands %%, so no escaping lets it find a path containing %. + if (appImagePath.any { it == '\n' || it == '\r' || it == '%' }) return null + return """ + |[Desktop Entry] + |Type=Application + |Name=$APP_DISPLAY_NAME + |NoDisplay=true + |Exec=${desktopExecArgument(appImagePath)} %u + |MimeType=$APP_LINK_MIME_TYPE;$CONNECTION_LINK_MIME_TYPE; + |""".trimMargin() +} + +// The entry runs this file for every link, so neither it nor any directory above it may be replaceable by another user, +// including the user's own directory inside /tmp, which can be cleaned away and recreated by anyone. +internal fun onlyOwnerCanReplace(path: Path, user: String): Boolean = + try { + generateSequence(path) { it.parent }.all { onlyOwnerCanWrite(Files.readAttributes(it, PosixFileAttributes::class.java), user) } + } catch (_: IOException) { + false + } + +// A group named after the user is a user private group, which umask 002 systems make group-writable. +internal fun onlyOwnerCanWrite(attributes: PosixFileAttributes, user: String): Boolean { + val permissions = attributes.permissions() + return attributes.owner().name in setOf(user, ROOT_USER) && + PosixFilePermission.OTHERS_WRITE !in permissions && + (PosixFilePermission.GROUP_WRITE !in permissions || attributes.group().name == user) +} + +internal fun registerAppImageScheme(appImagePath: String, applicationsDir: File, systemApplicationsDirs: List, run: (List) -> String?): Boolean { + if (!onlyOwnerCanReplace(Path.of(appImagePath), System.getProperty("user.name"))) { + Log.w(TAG, "link scheme: not registered, as other users may replace $appImagePath") + return false + } + val content = appImageDesktopEntry(appImagePath) ?: return false + val entry = File(applicationsDir, APPIMAGE_ENTRY_NAME) + try { + if (!entry.isFile || entry.readText() != content) { + applicationsDir.mkdirs() + entry.writeText(content) + run(listOf("update-desktop-database", applicationsDir.path)) + } + } catch (e: IOException) { + Log.w(TAG, "link scheme: cannot write desktop entry: ${e.message}") + return false + } + val entryDirs = listOf(applicationsDir) + systemApplicationsDirs + val registered = makeAppImageEntryDefault(APP_LINK_MIME_TYPE, entryDirs, run) + makeAppImageEntryDefault(CONNECTION_LINK_MIME_TYPE, entryDirs, run) + return registered +} + +private fun makeAppImageEntryDefault(mimeType: String, entryDirs: List, run: (List) -> String?): Boolean { + val current = xdgDefaultSchemeHandler(mimeType, run) + if (current == APPIMAGE_ENTRY_NAME) return true + // another installed handler keeps the default; only a missing one is replaced + if (current != null && entryDirs.any { File(it, current).isFile }) return false + run(listOf(XDG_MIME, "default", APPIMAGE_ENTRY_NAME, mimeType)) + return xdgDefaultSchemeHandler(mimeType, run) == APPIMAGE_ENTRY_NAME +} + +private fun xdgDefaultSchemeHandler(mimeType: String, run: (List) -> String?): String? = + run(listOf(XDG_MIME, "query", "default", mimeType))?.trim()?.takeIf { it.isNotEmpty() } + +private fun systemApplicationsDirs(): List = + (System.getenv("XDG_DATA_DIRS")?.takeIf { it.isNotEmpty() } ?: DEFAULT_XDG_DATA_DIRS).split(File.pathSeparator).map { File(it, APPLICATIONS_DIR) } + +internal fun linuxPackageRegistered(run: (List) -> String?): Boolean = + xdgDefaultSchemeHandler(APP_LINK_MIME_TYPE, run) == DEB_ENTRY_NAME + +internal fun runProcess(command: List): String? { + val process = try { + ProcessBuilder(command).redirectError(ProcessBuilder.Redirect.DISCARD).start() + } catch (e: IOException) { + Log.w(TAG, "link scheme: cannot run ${command.first()}: ${e.message}") + return null + } + return try { + process.outputStream.close() + if (!process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + Log.w(TAG, "link scheme: ${command.first()} timed out") + null + } else if (process.exitValue() == 0) { + process.inputStream.bufferedReader().readText() + } else { + null + } + } catch (e: IOException) { + Log.w(TAG, "link scheme: ${command.first()} failed: ${e.message}") + null + } catch (_: InterruptedException) { + process.destroyForcibly() + Thread.currentThread().interrupt() + null + } finally { + process.inputStream.close() + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index 7ea41d3593..8dd4ba96b6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -5,8 +5,10 @@ import java.util.* private val home = System.getProperty("user.home") private val unixConfigPath = (System.getenv("XDG_CONFIG_HOME") ?: "$home/.config") + "/simplex" -private val unixDataPath = (System.getenv("XDG_DATA_HOME") ?: "$home/.local/share") + "/simplex" -val desktopPlatform = detectDesktopPlatform() +internal val unixDataHome = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share" +private val unixDataPath = "$unixDataHome/simplex" +// DesktopPlatform's constants read this file's paths, so an eager value is null when the enum initializes first. +val desktopPlatform by lazy { detectDesktopPlatform() } enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String, val githubAssetName: String) { LINUX_X86_64("so", unixConfigPath, unixDataPath, "simplex-desktop-x86_64.AppImage"), diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt new file mode 100644 index 0000000000..0723c12ec3 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt @@ -0,0 +1,14 @@ +package chat.simplex.app + +internal const val BADGE_LINK_PREFIX = "simplexchat:/badge/code/" +internal const val BADGE_LINK = "${BADGE_LINK_PREFIX}SB0000000000000000000" +internal const val OTHER_BADGE_LINK = "${BADGE_LINK_PREFIX}SB1111111111111111111" + +internal fun badgeLinkOfBytes(size: Int): String = BADGE_LINK_PREFIX + "A".repeat(size - BADGE_LINK_PREFIX.length) + +internal const val SHORT_CONNECTION_LINK = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" +private const val ONE_TIME_LINK_PREFIX = "simplex:/invitation#/?v=2-7&smp=smp%3A%2F%2Fexample&e2e=v%3D2-3%26kem_key%3D" +// an sntrup761 public key is 1158 bytes, 1544 characters in base64url +private const val KEM_KEY_CHARACTERS = 1544 + +internal fun oneTimeLinkWithKemKey(): String = ONE_TIME_LINK_PREFIX + "A".repeat(KEM_KEY_CHARACTERS) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt new file mode 100644 index 0000000000..b86ead9733 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt @@ -0,0 +1,680 @@ +package chat.simplex.app + +import chat.simplex.common.platform.APPIMAGE_ENTRY_NAME +import chat.simplex.common.platform.DesktopInstallation +import chat.simplex.common.platform.DesktopPlatform +import chat.simplex.common.platform.RegistryValue +import chat.simplex.common.platform.APP_LINK_MIME_TYPE +import chat.simplex.common.platform.CONNECTION_LINK_MIME_TYPE +import chat.simplex.common.platform.appImageDesktopEntry +import chat.simplex.common.platform.desktopExecArgument +import chat.simplex.common.platform.desktopInstallation +import chat.simplex.common.platform.linuxPackageRegistered +import chat.simplex.common.platform.onlyOwnerCanReplace +import chat.simplex.common.platform.onlyOwnerCanWrite +import chat.simplex.common.platform.registerAppImageScheme +import chat.simplex.common.platform.registerWindowsSchemes +import chat.simplex.common.platform.runProcess +import chat.simplex.common.platform.windowsCommandProgram +import chat.simplex.common.platform.windowsApplicationValues +import chat.simplex.common.platform.windowsOpenCommand +import chat.simplex.common.platform.WindowsShell +import chat.simplex.common.platform.windowsProgIdValues +import chat.simplex.common.platform.windowsSchemeValues +import com.sun.jna.platform.win32.Win32Exception +import com.sun.jna.platform.win32.WinError +import com.sun.jna.platform.win32.WinNT +import org.junit.Assume.assumeNoException +import org.junit.Assume.assumeTrue +import java.io.File +import java.lang.reflect.Proxy +import java.nio.file.FileSystemException +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.GroupPrincipal +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.attribute.UserPrincipal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private const val OWNER_WRITABLE = "rwxr-xr-x" +private const val GROUP_WRITABLE = "rwxrwxr-x" +private const val OTHERS_WRITABLE = "rwxr-xrwx" +private const val WORLD_WRITABLE = "rwxrwxrwx" +private const val APPIMAGE_FILE_NAME = "SimpleX.AppImage" +private const val XDG_MIME = "xdg-mime" + +class AppLinkSchemeTest { + private val windowsExe = "C:\\Program Files\\SimpleX\\SimpleX.exe" + private val appLinkCommandKey = "Software\\Classes\\simplexchat\\shell\\open\\command" + private val connectionCommandKey = "Software\\Classes\\simplex\\shell\\open\\command" + private val appLinkCommand = appLinkCommandKey to "" + private val connectionCommand = connectionCommandKey to "" + private val olderWindowsCommand = "\"C:\\Old\\SimpleX.exe\" \"%1\"" + private val appImagePath = "/home/user/$APPIMAGE_FILE_NAME" + + @Test + fun windowsCommandQuotesTheAppAndTheLink() { + assertEquals( + "\"C:\\Program Files\\SimpleX\\SimpleX.exe\" \"%1\"", + windowsOpenCommand(windowsExe) + ) + } + + @Test + fun windowsSchemeIsAUrlProtocolNamingItsProgId() { + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\simplexchat", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\simplexchat", name = "URL Protocol", value = ""), + RegistryValue(key = "Software\\Classes\\simplexchat\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = appLinkCommandKey, name = "", value = "\"$windowsExe\" \"%1\""), + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities\\URLAssociations", name = "simplexchat", value = "SimpleX.simplexchat"), + ), + windowsSchemeValues("simplexchat", windowsExe), + "app link scheme" + ) + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\simplex", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\simplex", name = "URL Protocol", value = ""), + RegistryValue(key = "Software\\Classes\\simplex\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = connectionCommandKey, name = "", value = "\"$windowsExe\" \"%1\""), + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities\\URLAssociations", name = "simplex", value = "SimpleX.simplex"), + ), + windowsSchemeValues("simplex", windowsExe), + "connection link scheme" + ) + } + + @Test + fun windowsProgIdOpensTheExe() { + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat\\shell\\open\\command", name = "", value = "\"$windowsExe\" \"%1\""), + ), + windowsProgIdValues("simplexchat", windowsExe) + ) + } + + @Test + fun windowsApplicationIsListedInDefaultAppsAfterItsCapabilities() { + assertEquals( + listOf( + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities", name = "ApplicationName", value = "SimpleX Chat"), + RegistryValue( + key = "Software\\chat.simplex.app\\Capabilities", + name = "ApplicationDescription", + value = "Private and secure open-source messenger - no user IDs (not even random numbers)" + ), + RegistryValue(key = "Software\\RegisteredApplications", name = "SimpleX Chat", value = "Software\\chat.simplex.app\\Capabilities"), + ), + windowsApplicationValues + ) + } + + @Test + fun windowsRegistrationWritesEveryValueWhenTheCommandsPointAtAMissingProgram() { + val shell = FakeWindowsShell(user = mutableMapOf(appLinkCommand to olderWindowsCommand, connectionCommand to olderWindowsCommand)) + registerWindowsSchemes(windowsExe, shell) + assertEquals(fullRegistration(), shell.writes) + assertTrue(shell.notified, "Windows is told that associations changed") + } + + @Test + fun windowsCommandProgramIsTheQuotedOrFirstArgument() { + assertEquals("C:\\Program Files\\Other\\Other.exe", windowsCommandProgram("\"C:\\Program Files\\Other\\Other.exe\" \"%1\""), "quoted") + assertEquals("C:\\Other\\Other.exe", windowsCommandProgram("C:\\Other\\Other.exe %1"), "unquoted") + assertEquals("C:\\Other\\Other.exe", windowsCommandProgram("C:\\Other\\Other.exe"), "no arguments") + } + + @Test + fun windowsRegistrationLeavesAFullRegistrationAlone() { + val shell = FakeWindowsShell(user = registered()) + registerWindowsSchemes(windowsExe, shell) + assertEquals(listOf(), shell.writes, "nothing is written again") + assertFalse(shell.notified, "an unchanged registration does not clear Explorer's caches") + } + + @Test + fun windowsRegistrationRepairsAValueMissingBesideAMatchingCommand() { + val shell = FakeWindowsShell(user = registered().apply { remove("Software\\Classes\\simplexchat" to "URL Protocol") }) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsSchemeValues("simplexchat", windowsExe), shell.writes, "only the incomplete scheme is rewritten") + } + + @Test + fun windowsRegistrationAnnouncesARepairedApplicationListing() { + val shell = FakeWindowsShell(user = registered().apply { remove("Software\\RegisteredApplications" to "SimpleX Chat") }) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsApplicationValues, shell.writes, "only the application listing is rewritten") + assertTrue(shell.notified, "Windows is told that associations changed") + } + + @Test + fun windowsRegistrationKeepsAnotherInstalledHandler() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val shell = FakeWindowsShell(user = mutableMapOf(appLinkCommand to otherCommand, connectionCommand to otherCommand)) + registerWindowsSchemes(windowsExe, shell) + assertEquals( + windowsProgIdValues("simplexchat", windowsExe) + windowsProgIdValues("simplex", windowsExe), + shell.writes, + "only this app's own ProgIds are written; no scheme is taken and no application is listed without one" + ) + } + + @Test + fun windowsRegistrationKeepsAMachineWideHandler() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val shell = FakeWindowsShell(machine = mutableMapOf(appLinkCommand to otherCommand)) + registerWindowsSchemes(windowsExe, shell) + assertTrue(shell.writes.none { it.key == appLinkCommandKey }, "a per-user key would hide the machine-wide handler") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the free scheme is taken") + } + + @Test + fun windowsRegistrationRepairsItsProgIdWhileAnotherProgramHoldsTheScheme() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val staleProgId = windowsProgIdValues("simplexchat", "C:\\Old\\SimpleX.exe").associate { (it.key to it.name) to it.value } + val shell = FakeWindowsShell(user = (staleProgId + (appLinkCommand to otherCommand)).toMutableMap()) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsProgIdValues("simplexchat", windowsExe), shell.writes.take(3), "the ProgId points at this exe again") + } + + @Test + fun windowsRegistrationReportsTheProgramTheShellStarts() { + assertFalse(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = "C:\\Other\\Other.exe")), "another app picked in settings") + assertTrue(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = windowsExe.uppercase())), "paths differ only in case") + assertFalse(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = null)), "the shell has no answer") + } + + @Test + fun windowsRegistrationContinuesPastARegistryError() { + val shell = FakeWindowsShell(failingWrites = setOf("Software\\Classes\\simplexchat")) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "the app link scheme could not be written") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the connection link scheme is still written") + assertTrue(windowsApplicationValues.all { it in shell.writes }, "the application is listed for the scheme that is ours") + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseHandlerCannotBeRead() { + val shell = FakeWindowsShell(failingReads = setOf(appLinkCommandKey, connectionCommandKey)) + registerWindowsSchemes(windowsExe, shell) + assertEquals( + windowsProgIdValues("simplexchat", windowsExe) + windowsProgIdValues("simplex", windowsExe), + shell.writes, + "an unreadable handler is left alone, and no application is listed without a scheme" + ) + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseMachineWideHandlerCannotBeRead() { + val shell = FakeWindowsShell(failingMachineReads = setOf(appLinkCommandKey)) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "the app link scheme is left alone") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the readable scheme is taken") + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseProgIdCannotBeWritten() { + val shell = FakeWindowsShell(failingWrites = setOf("Software\\Classes\\SimpleX.simplexchat")) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "URLAssociations must not name a missing ProgId") + } + + @Test + fun windowsRegistrationKeepsAnotherInstalledCopysProgId() = withTempDir { dir -> + val otherCopy = Files.createFile(dir.resolve("SimpleX.exe")).toString() + val otherProgId = windowsProgIdValues("simplexchat", otherCopy).associate { (it.key to it.name) to it.value } + val shell = FakeWindowsShell(user = otherProgId.toMutableMap()) + registerWindowsSchemes(windowsExe, shell) + assertTrue(shell.writes.none { it.key.contains("simplexchat") }, "links picked for the other copy keep reaching it: neither its ProgId nor the scheme is taken") + } + + @Test + fun windowsRegistrationTreatsItsOwnProgramAsItsOwnHandler() = withTempDir { dir -> + val exe = Files.createFile(dir.resolve("SimpleX.exe")).toString() + val otherExe = Files.createFile(dir.resolve("Other.exe")).toString() + val shell = FakeWindowsShell( + user = mutableMapOf(appLinkCommand to "\"$exe\" %1"), + machine = mutableMapOf(appLinkCommand to "\"$otherExe\" \"%1\""), + ) + registerWindowsSchemes(exe, shell) + assertTrue(windowsSchemeValues("simplexchat", exe).all { it in shell.writes }, "a command for this exe is updated, and the hidden machine-wide one is ignored") + } + + private fun fullRegistration(): List = + windowsProgIdValues("simplexchat", windowsExe) + windowsSchemeValues("simplexchat", windowsExe) + + windowsProgIdValues("simplex", windowsExe) + windowsSchemeValues("simplex", windowsExe) + windowsApplicationValues + + private fun registered(): MutableMap, String> = + fullRegistration().associate { (it.key to it.name) to it.value }.toMutableMap() + + private inner class FakeWindowsShell( + private val user: MutableMap, String> = mutableMapOf(), + private val machine: Map, String> = mapOf(), + private val handler: String? = windowsExe, + private val failingReads: Set = setOf(), + private val failingMachineReads: Set = setOf(), + private val failingWrites: Set = setOf(), + ) : WindowsShell { + val writes = mutableListOf() + var notified = false + + override fun readUser(key: String, name: String): String? { + if (key in failingReads) throw AccessDenied() + return user[key to name] + } + + override fun readMachine(key: String, name: String): String? { + if (key in failingMachineReads) throw AccessDenied() + return machine[key to name] + } + + override fun writeUser(value: RegistryValue) { + if (value.key in failingWrites) throw AccessDenied() + writes += value + user[value.key to value.name] = value.value + } + + override fun linkHandler(scheme: String): String? = handler + + override fun notifyAssociationsChanged() { + notified = true + } + } + + // Win32Exception's public constructors format the message through kernel32, which only Windows has. + private class AccessDenied : Win32Exception(WinError.ERROR_ACCESS_DENIED, WinNT.HRESULT(WinError.ERROR_ACCESS_DENIED), "access denied") + + @Test + fun execArgumentOfAPlainPathIsLeftBare() { + assertEquals("/home/user/Downloads/simplex-desktop-x86_64.AppImage", desktopExecArgument("/home/user/Downloads/simplex-desktop-x86_64.AppImage")) + } + + @Test + fun execArgumentIsQuotedForEveryReservedCharacter() { + for (c in " \t\n\"'\\><~|&;$*?#()`") { + val arg = desktopExecArgument("/a${c}b") + assertTrue(arg.startsWith("\"") && arg.endsWith("\""), "reserved character ${c.code} must be quoted, got $arg") + } + } + + @Test + fun execArgumentKeepsSpacesInsideTheQuotes() { + assertEquals("\"/home/user/My Apps/simplex.AppImage\"", desktopExecArgument("/home/user/My Apps/simplex.AppImage")) + } + + @Test + fun execArgumentEscapesReservedCharacters() { + assertEquals("\"/a\\\\\"b\"", desktopExecArgument("/a\"b"), "double quote") + assertEquals("\"/a\\\\`b\"", desktopExecArgument("/a`b"), "backtick") + assertEquals("\"/a\\\\\$b\"", desktopExecArgument("/a\$b"), "dollar") + } + + @Test + fun execArgumentTurnsABackslashIntoFour() { + assertEquals("\"/a\\\\\\\\b\"", desktopExecArgument("/a\\b")) + } + + @Test + fun appImageEntryHandlesTheSchemeAndStaysOutOfMenus() { + assertEquals( + """ + |[Desktop Entry] + |Type=Application + |Name=SimpleX Chat + |NoDisplay=true + |Exec="/home/user/My Apps/simplex.AppImage" %u + |MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex; + |""".trimMargin(), + appImageDesktopEntry("/home/user/My Apps/simplex.AppImage") + ) + } + + @Test + fun appImageEntryIsRefusedForAPathItCannotRun() { + assertNull(appImageDesktopEntry("/a\nExec=evil"), "line feed") + assertNull(appImageDesktopEntry("/a\rb"), "carriage return") + assertNull(appImageDesktopEntry("/a%1.AppImage"), "percent") + } + + private val linux = DesktopPlatform.LINUX_X86_64 + private val linuxAppPath = "/opt/simplex/bin/simplex" + + @Test + fun unpackagedRunHasNoInstallation() { + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(linux, null, mapOf()), "linux") + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(DesktopPlatform.MAC_AARCH64, null, mapOf()), "macOS") + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(DesktopPlatform.WINDOWS_X86_64, null, mapOf()), "windows") + } + + @Test + fun packagedMacAndWindowsAreRecognised() { + assertEquals(DesktopInstallation.MacBundle, desktopInstallation(DesktopPlatform.MAC_AARCH64, "/Applications/SimpleX.app/Contents/MacOS/SimpleX", mapOf())) + assertEquals(DesktopInstallation.WindowsExe(windowsExe), desktopInstallation(DesktopPlatform.WINDOWS_X86_64, windowsExe, mapOf())) + } + + @Test + fun flatpakWinsWhenTheAppImageCheckWouldAlsoMatch() { + val env = mapOf("container" to "flatpak", "APPIMAGE" to "/home/user/a.AppImage", "APPDIR" to "/tmp/.mount_a") + assertEquals(DesktopInstallation.Flatpak, desktopInstallation(linux, "/tmp/.mount_a/usr/bin/simplex", env)) + } + + @Test + fun appImageIsRecognisedWhenTheLauncherRunsFromItsMount() { + val env = mapOf("APPIMAGE" to appImagePath, "APPDIR" to "/tmp/.mount_Simple") + assertEquals( + DesktopInstallation.AppImage(appImagePath), + desktopInstallation(linux, "/tmp/.mount_Simple/usr/bin/simplex", env) + ) + } + + @Test + fun appImageIsRecognisedWhenItsMountIsReachedThroughASymlink() = withTempDir { tmp -> + val mount = Files.createDirectories(tmp.resolve("real/.mount_Simple/usr/bin")) + val linkedTmp = try { + Files.createSymbolicLink(tmp.resolve("linked"), tmp.resolve("real")) + } catch (e: FileSystemException) { + assumeNoException("symbolic links cannot be created here", e) + return@withTempDir + } + val env = mapOf("APPIMAGE" to appImagePath, "APPDIR" to linkedTmp.resolve(".mount_Simple").toString()) + assertEquals( + DesktopInstallation.AppImage(appImagePath), + desktopInstallation(linux, mount.toRealPath().resolve("simplex").toString(), env) + ) + } + + @Test + fun inheritedAppImageVariablesDoNotMakeTheDebAnAppImage() { + val inherited = mapOf("APPIMAGE" to "/home/user/Terminal.AppImage", "APPDIR" to "/tmp/.mount_Termin") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, linuxAppPath, inherited), "another AppImage's mount") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, linuxAppPath, mapOf("APPIMAGE" to "/home/user/a.AppImage")), "no APPDIR") + val prefix = mapOf("APPIMAGE" to "/home/user/a.AppImage", "APPDIR" to "/tmp/.mount_ab") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, "/tmp/.mount_abc/usr/bin/simplex", prefix), "mount path that only shares a prefix") + } + + @Test + fun fileOnlyItsOwnerCanWriteIsAccepted() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "alice", group = "staff", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun rootOwnedFileIsAccepted() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "root", group = "root", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun fileOfAnotherOwnerIsRefused() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "bob", group = "staff", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun worldWritableFileIsRefused() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "alice", group = "alice", OTHERS_WRITABLE), user = "alice")) + } + + @Test + fun groupWritableFileIsRefusedForASharedGroup() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "alice", group = "staff", GROUP_WRITABLE), user = "alice")) + } + + @Test + fun groupWritableFileIsAcceptedForTheUsersOwnGroup() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "alice", group = "alice", GROUP_WRITABLE), user = "alice"), "owned by the user") + assertTrue(onlyOwnerCanWrite(attributes(owner = "root", group = "alice", GROUP_WRITABLE), user = "alice"), "owned by root") + assertFalse(onlyOwnerCanWrite(attributes(owner = "root", group = "root", GROUP_WRITABLE), user = "alice"), "root's own group") + } + + private fun attributes(owner: String, group: String, permissions: String): PosixFileAttributes = + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PosixFileAttributes::class.java)) { _, method, _ -> + when (method.name) { + "owner" -> UserPrincipal { owner } + "group" -> GroupPrincipal { group } + "permissions" -> PosixFilePermissions.fromString(permissions) + else -> throw UnsupportedOperationException(method.name) + } + } as PosixFileAttributes + + @Test + fun appImageOnlyItsOwnerCanWriteIsAccepted() = withAppImage { + assertTrue(onlyOwnerCanReplace(it, Files.getOwner(it).name)) + } + + @Test + fun appImageInAWorldWritableDirectoryIsRefused() = withAppImage(directoryPermissions = WORLD_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name), "the directory must be checked as well as the file") + } + + @Test + fun appImageUnderAWorldWritableAncestorIsRefused() = withAppImage(ancestorPermissions = WORLD_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name), "every directory up to the root must be checked") + } + + @Test + fun worldWritableAppImageIsRefused() = withAppImage(filePermissions = OTHERS_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name)) + } + + @Test + fun appImageOfAnotherOwnerIsRefused() = withAppImage { + val owner = Files.getOwner(it).name + assumeTrue("root is accepted as an owner", owner != "root") + assertFalse(onlyOwnerCanReplace(it, "$owner-other"), "a file owned by $owner checked for $owner-other") + } + + @Test + fun missingAppImageIsRefused() { + assumePosix() + assertFalse(onlyOwnerCanReplace(Path.of("/nonexistent-simplex-dir/SimpleX.AppImage"), "root")) + } + + // Built under the module's build directory, as the check walks up to the root and the system temp directory is open to everyone. + private fun withAppImage( + directoryPermissions: String = OWNER_WRITABLE, + filePermissions: String = OWNER_WRITABLE, + ancestorPermissions: String = OWNER_WRITABLE, + fileName: String = APPIMAGE_FILE_NAME, + check: (Path) -> Unit + ) { + assumePosix() + val base = Path.of("build").toAbsolutePath() + val user = System.getProperty("user.name") + assumeTrue( + "$base and every directory above it must be owned by $user or root and closed to others", + Files.isDirectory(base) && generateSequence(base) { it.parent }.all { + val attributes = Files.readAttributes(it, PosixFileAttributes::class.java) + attributes.owner().name in setOf(user, "root") && + PosixFilePermission.OTHERS_WRITE !in attributes.permissions() && + (PosixFilePermission.GROUP_WRITE !in attributes.permissions() || attributes.group().name == user) + } + ) + withTempDir(base) { tmp -> + val ancestor = Files.createDirectory(tmp.resolve("shared")) + val dir = Files.createDirectory(ancestor.resolve("apps")) + val appImage = Files.createFile(dir.resolve(fileName)) + Files.setPosixFilePermissions(appImage, PosixFilePermissions.fromString(filePermissions)) + Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString(directoryPermissions)) + Files.setPosixFilePermissions(ancestor, PosixFilePermissions.fromString(ancestorPermissions)) + check(appImage) + } + } + + private fun assumePosix() = + assumeTrue("needs POSIX permissions", "posix" in FileSystems.getDefault().supportedFileAttributeViews()) + + @Test + fun runProcessReturnsTheOutputOfASuccessfulRun() { + assumeShell() + assertEquals("x\n", runProcess(listOf("sh", "-c", "echo x"))) + } + + @Test + fun runProcessKeepsErrorOutputOutOfTheResult() { + assumeShell() + // more than a pipe holds, so error output left in a pipe would block the process until the timeout + assertEquals("x\n", runProcess(listOf("sh", "-c", "head -c 100000 /dev/zero >&2; echo x"))) + } + + @Test + fun runProcessGivesTheProcessAnEndOfInput() { + assumeShell() + assertEquals("x\n", runProcess(listOf("sh", "-c", "cat >/dev/null; echo x")), "a process reading its input must not wait for the timeout") + } + + @Test + fun runProcessReturnsNothingForAFailedRun() { + assumeShell() + assertNull(runProcess(listOf("sh", "-c", "echo x; exit 3"))) + } + + private fun queryDefault(mimeType: String) = listOf(XDG_MIME, "query", "default", mimeType) + private fun setDefault(mimeType: String) = listOf(XDG_MIME, "default", APPIMAGE_ENTRY_NAME, mimeType) + private val queryBothDefaults = listOf(queryDefault(APP_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE)) + private val setBothDefaults = listOf( + queryDefault(APP_LINK_MIME_TYPE), setDefault(APP_LINK_MIME_TYPE), queryDefault(APP_LINK_MIME_TYPE), + queryDefault(CONNECTION_LINK_MIME_TYPE), setDefault(CONNECTION_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE), + ) + + @Test + fun appImageRegistrationWritesTheEntryAndMakesItTheDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = null) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the entry became the default handler") + assertEquals(appImageDesktopEntry(appImage), File(applicationsDir, APPIMAGE_ENTRY_NAME).readText(), "the missing directory is created") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationLeavesAnUnchangedEntryThatIsAlreadyTheDefault() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals(queryBothDefaults, xdg.calls, "nothing is rewritten or set again") + } + + @Test + fun appImageRegistrationTakesTheDefaultFromAHandlerNoLongerInstalled() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the entry became the default handler again") + assertEquals(setBothDefaults, xdg.calls, "the entry is not rewritten, only made the default") + } + + @Test + fun appImageRegistrationSetsOnlyTheDefaultThatPointsElsewhere() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME, connectionDefault = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals( + listOf(queryDefault(APP_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE), setDefault(CONNECTION_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE)), + xdg.calls + ) + } + + @Test + fun appImageRegistrationRewritesTheEntryOfAMovedAppImage() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry("/old/SimpleX.AppImage")!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals(appImageDesktopEntry(appImage), File(applicationsDir, APPIMAGE_ENTRY_NAME).readText()) + assertEquals(listOf(updateDatabase(applicationsDir)) + queryBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationFailsWhenAnotherHandlerKeepsTheAppLinkDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = "other.desktop", keptMimeTypes = setOf(APP_LINK_MIME_TYPE)) + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the default handler did not change") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls, "the connection link default is still set") + } + + @Test + fun appImageRegistrationSucceedsWhenAnotherHandlerKeepsOnlyTheConnectionDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = "other.desktop", keptMimeTypes = setOf(CONNECTION_LINK_MIME_TYPE)) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "only simplexchat: decides the badge page") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationKeepsAnotherInstalledHandler() = withRegistration { appImage, applicationsDir -> + val systemDir = File(applicationsDir.parentFile, "system-applications").apply { mkdirs() } + File(systemDir, "other.desktop").writeText("[Desktop Entry]\n") + val xdg = FakeXdgMime(default = "other.desktop") + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(systemDir), xdg::run), "links stay with the installed handler") + assertEquals(listOf(updateDatabase(applicationsDir)) + queryBothDefaults, xdg.calls, "the user's handler is not replaced") + } + + @Test + fun appImageRegistrationKeepsAnotherInstalledConnectionHandlerOnly() = withRegistration { appImage, applicationsDir -> + val systemDir = File(applicationsDir.parentFile, "system-applications").apply { mkdirs() } + File(systemDir, "other.desktop").writeText("[Desktop Entry]\n") + val xdg = FakeXdgMime(default = null, connectionDefault = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(systemDir), xdg::run), "app links have no handler, so the entry takes them") + assertEquals( + listOf( + updateDatabase(applicationsDir), queryDefault(APP_LINK_MIME_TYPE), setDefault(APP_LINK_MIME_TYPE), queryDefault(APP_LINK_MIME_TYPE), + queryDefault(CONNECTION_LINK_MIME_TYPE), + ), + xdg.calls + ) + } + + @Test + fun appImageRegistrationIsRefusedForAPathTheEntryCannotRun() = + withRegistration(fileName = "Simple%X.AppImage") { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = null) + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "a path with % cannot be registered") + assertEquals(listOf(), xdg.calls, "nothing is run") + assertFalse(applicationsDir.exists(), "no entry is written") + } + + @Test + fun linuxPackageCountsAsRegisteredOnlyWhenItsEntryIsTheDefault() { + assertTrue(linuxPackageRegistered(FakeXdgMime(default = "simplex-simplex.desktop")::run), "the deb's own entry") + assertFalse(linuxPackageRegistered(FakeXdgMime(default = "other.desktop")::run), "another handler") + assertFalse(linuxPackageRegistered(FakeXdgMime(default = null)::run), "no handler") + assertFalse( + linuxPackageRegistered(FakeXdgMime(default = "other.desktop", connectionDefault = "simplex-simplex.desktop")::run), + "only the connection link handler" + ) + } + + private fun updateDatabase(applicationsDir: File) = listOf("update-desktop-database", applicationsDir.path) + + private class FakeXdgMime(default: String?, connectionDefault: String? = default, private val keptMimeTypes: Set = setOf()) { + private val defaults = mutableMapOf(APP_LINK_MIME_TYPE to default, CONNECTION_LINK_MIME_TYPE to connectionDefault) + val calls = mutableListOf>() + + fun run(command: List): String? { + calls += command + if (command.take(3) == listOf(XDG_MIME, "query", "default")) return defaults[command[3]]?.let { "$it\n" } + // xdg-mime sets the default only when given an entry and then a MIME type, the order it requires + if (command.size == 4 && command[1] == "default" && command[2].endsWith(".desktop") && command[3] !in keptMimeTypes) { + defaults[command[3]] = command[2] + } + return "" + } + } + + private fun withRegistration(fileName: String = APPIMAGE_FILE_NAME, test: (appImage: String, applicationsDir: File) -> Unit) = + withAppImage(fileName = fileName) { appImage -> + assumeTrue("the AppImage must be owned by user.name", Files.getOwner(appImage).name == System.getProperty("user.name")) + test(appImage.toString(), appImage.parent.parent.parent.resolve("share/applications").toFile()) + } + + private fun assumeShell() = + assumeTrue("needs sh", System.getenv("PATH")?.split(File.pathSeparator)?.any { Files.isExecutable(Path.of(it, "sh")) } == true) +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt new file mode 100644 index 0000000000..59c3c8c667 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt @@ -0,0 +1,71 @@ +package chat.simplex.app + +import chat.simplex.common.MAX_LINK_BYTES +import chat.simplex.common.linkFromArgs +import chat.simplex.common.isAcceptedLink +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AppLinksTest { + @Test + fun acceptsTheOnlyArgumentWhenItIsAnAppLink() { + assertEquals(BADGE_LINK, linkFromArgs(arrayOf(BADGE_LINK))) + } + + @Test + fun acceptsTheSchemeInAnyCase() { + val link = "SimplexChat:/badge/code/SB0000000000000000000" + assertEquals(link, linkFromArgs(arrayOf(link))) + } + + @Test + fun ignoresNoArguments() { + assertNull(linkFromArgs(arrayOf())) + } + + @Test + fun acceptsAConnectionLink() { + assertEquals(SHORT_CONNECTION_LINK, linkFromArgs(arrayOf(SHORT_CONNECTION_LINK)), "short link") + val oneTimeLink = oneTimeLinkWithKemKey() + assertEquals(oneTimeLink, linkFromArgs(arrayOf(oneTimeLink)), "one-time link of ${oneTimeLink.length} bytes with its post-quantum key") + val upperCase = "SimpleX:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" + assertEquals(upperCase, linkFromArgs(arrayOf(upperCase)), "scheme in another case") + } + + @Test + fun ignoresWebAndOtherLinks() { + assertNull(linkFromArgs(arrayOf("https://simplex.chat/contact#/?v=2-7")), "web link") + assertNull(linkFromArgs(arrayOf("simplexfile:/x")), "another scheme sharing the prefix") + assertNull(linkFromArgs(arrayOf("/home/user/simplex:/a")), "path") + } + + @Test + fun ignoresALinkThatIsNotTheOnlyArgument() { + // a URL split by quotes in a handler command arrives as several arguments + assertNull(linkFromArgs(arrayOf("--flag", BADGE_LINK)), "flag before link") + assertNull(linkFromArgs(arrayOf(BADGE_LINK, "extra")), "argument after link") + } + + @Test + fun ignoresAnEmptyArgument() { + assertNull(linkFromArgs(arrayOf(""))) + } + + @Test + fun lengthBoundIsInclusive() { + val atLimit = badgeLinkOfBytes(MAX_LINK_BYTES) + val overLimit = atLimit + "A" + assertTrue(isAcceptedLink(atLimit), "link of exactly $MAX_LINK_BYTES bytes") + assertFalse(isAcceptedLink(overLimit), "link of ${overLimit.length} bytes") + assertNull(linkFromArgs(arrayOf(overLimit))) + } + + @Test + fun lengthBoundCountsUtf8Bytes() { + // The link has 4213 characters but 8413 bytes, so a bound counted in characters would accept it. + assertNull(linkFromArgs(arrayOf("simplexchat:/" + "é".repeat(4200)))) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt index 1b495c1774..0bfa16b4d3 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt @@ -1,14 +1,48 @@ package chat.simplex.app +import chat.simplex.common.FILE_TIME_TOLERANCE +import chat.simplex.common.MAX_LINK_BYTES +import chat.simplex.common.SHOW_FILE +import chat.simplex.common.SHOW_TMP_SUFFIX +import chat.simplex.common.ShowSignal +import chat.simplex.common.TAKEN_SHOW_FILE +import chat.simplex.common.deleteStaleSignalFiles +import chat.simplex.common.signalRunningInstance +import chat.simplex.common.startDespiteRunningInstance +import chat.simplex.common.takeSignal +import chat.simplex.common.watchShowSignals +import org.junit.Assume.assumeFalse +import org.junit.Assume.assumeTrue import java.nio.channels.FileChannel import java.nio.channels.OverlappingFileLockException +import java.nio.file.FileSystemException import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption.CREATE import java.nio.file.StandardOpenOption.READ import java.nio.file.StandardOpenOption.WRITE +import java.nio.file.StandardWatchEventKinds +import java.nio.file.attribute.FileTime +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermission +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// generous for a loaded build machine; the watcher normally delivers within milliseconds +private const val SIGNAL_WAIT_SECONDS = 15L +// short, as the running instance in these tests never answers within the wait +private const val RUNNING_INSTANCE_WAIT_MILLIS = 300L class SingleInstanceTest { @Test @@ -43,14 +77,245 @@ class SingleInstanceTest { second.close() } - private fun withTempDir(block: (java.nio.file.Path) -> Unit) { - val tmp = Files.createTempDirectory("simplex-singleinstance-test") - try { - block(tmp) - } finally { - Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach { - try { Files.delete(it) } catch (_: java.io.IOException) {} - } + @Test + fun secondProcessExitsWhenTheRunningInstanceTakesTheSignal() = withTempDir { dir -> + val taken = LinkedBlockingQueue() + val runningInstance = thread { + while (!Files.exists(dir.resolve(SHOW_FILE))) Thread.sleep(5) + takeSignal(dir)?.let(taken::add) + } + val start = startDespiteRunningInstance(dir, BADGE_LINK, TimeUnit.SECONDS.toMillis(SIGNAL_WAIT_SECONDS)) { + error("a responsive instance must not raise the alert") + } + runningInstance.join() + assertFalse(start, "the second process must exit") + assertEquals(BADGE_LINK, taken.poll()?.link, "the running instance opens the link") + } + + @Test + fun secondProcessExitsWhenTheUserDeclinesToStart() = withTempDir { dir -> + assertFalse(startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { false }) + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the signal stays for the running instance to take once it recovers") + } + + @Test + fun secondProcessStartsAndWithdrawsTheSignalWhenTheUserStartsAnyway() = withTempDir { dir -> + assertTrue(startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { true }) + assertEquals(listOf(), fileNames(dir), "the withdrawn signal must not open the link in the running instance too") + } + + @Test + fun secondProcessExitsWhenTheRunningInstanceRecoversDuringTheAlert() = withTempDir { dir -> + var opened: String? = null + val start = startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { + opened = takeSignal(dir)?.link + true + } + assertEquals(BADGE_LINK, opened, "the recovered instance opened the link") + assertFalse(start, "starting anyway would open the same link a second time") + } + + @Test + fun signalCarriesTheLinkAndIsTakenOnce() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "only the signal may exist after signalling") + assertEquals(BADGE_LINK, takeSignal(dir)?.link) + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + assertNull(takeSignal(dir), "a signal already taken must not be taken again") + } + + @Test + fun signalCarriesAOneTimeLinkWithItsPostQuantumKey() = withTempDir { dir -> + val link = oneTimeLinkWithKemKey() + signalRunningInstance(dir, link) + assertEquals(link, takeSignal(dir)?.link, "a connection link of ${link.length} bytes") + } + + @Test + fun signalWithoutLinkIsTakenWithNoLink() = withTempDir { dir -> + signalRunningInstance(dir, null) + val signal = takeSignal(dir) + assertNotNull(signal, "an empty signal must still be taken") + assertNull(signal.link, "an empty signal carries no link") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + assertNull(takeSignal(dir), "a signal already taken must not be taken again") + } + + @Test + fun laterSignalReplacesAnUntakenOne() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + signalRunningInstance(dir, OTHER_BADGE_LINK) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "the later signal must replace the earlier one") + assertEquals(OTHER_BADGE_LINK, takeSignal(dir)?.link) + } + + @Test + fun signalIsReadableOnlyByItsOwner() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + val view = Files.getFileAttributeView(dir.resolve(SHOW_FILE), PosixFileAttributeView::class.java) + assumeTrue("POSIX file permissions are not supported here", view != null) + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + view.readAttributes().permissions() + ) + } + + @Test + fun takeSignalReplacesATakenFileLeftByACrash() = withTempDir { dir -> + Files.writeString(dir.resolve(TAKEN_SHOW_FILE), OTHER_BADGE_LINK) + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the new signal, not the leftover, must be read") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + } + + @Test + fun takeSignalRetriesAMoveThatFailsOnce() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + var attempts = 0 + val signal = takeSignal(dir) { from, to -> + attempts++ + if (attempts == 1) throw FileSystemException(from.toString(), null, "held open by another program") + Files.move(from, to, StandardCopyOption.ATOMIC_MOVE) + } + assertEquals(2, attempts, "one failed attempt and one retry") + assertEquals(BADGE_LINK, signal?.link, "the retry must still deliver the link") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + } + + @Test + fun takeSignalLeavesTheSignalWhenTheRetryFailsToo() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + var attempts = 0 + val signal = takeSignal(dir) { from, _ -> + attempts++ + throw FileSystemException(from.toString(), null, "held open by another program") + } + assertEquals(2, attempts, "one failed attempt and one retry") + assertNotNull(signal, "the window still comes forward") + assertNull(signal.link, "a signal that cannot be read carries no link") + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the signal stays for the next attempt") + } + + @Test + fun takeSignalAcceptsALinkOfExactlyTheBound() = withTempDir { dir -> + val atBound = badgeLinkOfBytes(MAX_LINK_BYTES) + signalRunningInstance(dir, atBound) + assertEquals(atBound, takeSignal(dir)?.link, "a link of exactly $MAX_LINK_BYTES bytes") + } + + @Test + fun takeSignalDropsContentThatIsNotAnAcceptedLink() = withTempDir { dir -> + val show = dir.resolve(SHOW_FILE) + fun assertTakenWithoutLink(case: String) { + val signal = takeSignal(dir) + assertNotNull(signal, "$case: rejected content is still a signal, so the window comes forward") + assertNull(signal.link, case) + } + Files.writeString(show, "https://simplex.chat/contact#/?v=2-7") + assertTakenWithoutLink("web link") + assertEquals(listOf(), fileNames(dir), "a rejected signal must still be removed") + + Files.writeString(show, badgeLinkOfBytes(MAX_LINK_BYTES + 1)) + assertTakenWithoutLink("link over the length bound") + + // 4214 characters but 8414 bytes; a read cut at the bound would end on a whole character and decode to an accepted link + Files.writeString(show, "simplexchat:/x" + "é".repeat(4200)) + assertTakenWithoutLink("multi-byte link over the byte bound") + assertEquals(listOf(), fileNames(dir), "a rejected signal must still be removed") + } + + @Test + fun deleteStaleSignalFilesRemovesOldTempAndTakenFilesOnly() = withTempDir { dir -> + val lockAttemptTime = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val old = FileTime.from(lockAttemptTime.minusSeconds(3600)) + Files.setLastModifiedTime(Files.createFile(dir.resolve("simplex.started")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("simplex_v1_chat.db")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("other.tmp")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("${SHOW_FILE}123$SHOW_TMP_SUFFIX")), old) + Files.createFile(dir.resolve("${SHOW_FILE}456$SHOW_TMP_SUFFIX")) + Files.createFile(dir.resolve(TAKEN_SHOW_FILE)) + Files.createFile(dir.resolve(SHOW_FILE)) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals( + listOf("other.tmp", SHOW_FILE, "${SHOW_FILE}456$SHOW_TMP_SUFFIX", "simplex.started", "simplex_v1_chat.db"), + fileNames(dir), + "an old temp file and a taken file must go; a fresh temp file is a signal being written, and the lock and database stay" + ) + } + + @Test + fun deleteStaleSignalFilesKeepsASignalWithinTheToleranceAndDropsAnOlderOne() = withTempDir { dir -> + val lockAttemptTime = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val show = dir.resolve(SHOW_FILE) + signalRunningInstance(dir, BADGE_LINK) + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minus(FILE_TIME_TOLERANCE))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "a signal exactly at the tolerance must be kept") + + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minusSeconds(1))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "a signal one second behind the lock attempt must be kept") + + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minus(FILE_TIME_TOLERANCE).minusSeconds(1))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(), fileNames(dir), "a signal older than the tolerance must be deleted") + } + + @Test + fun watcherTakesASignalWrittenBeforeItsWatch() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + watch(dir) { signals -> + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "a signal written before the watch raised no event") } } + + @Test + fun watcherTakesEverySignalArrivingWhileItWatches() = withTempDir { dir -> + assumeWatchServiceReportsEachCreation(dir) + watch(dir) { signals -> + // Each signal is written after the previous one arrived, so later ones can come only from watch events. + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "first signal") + signalRunningInstance(dir, OTHER_BADGE_LINK) + assertEquals(OTHER_BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "second signal, seen as a creation") + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "third signal, after the watch key was reset") + } + } + + @Test + fun signalIsWrittenThroughATempFileBesideIt() = withTempDir { dir -> + // a temp file elsewhere may sit on another file system, where the atomic move fails and the link is dropped + assumeWatchServiceReportsEachCreation(dir) + val created = dir.fileSystem.newWatchService().use { ws -> + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) + signalRunningInstance(dir, BADGE_LINK) + ws.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.pollEvents()?.map { it.context().toString() }.orEmpty() + } + assertTrue( + created.any { it != TAKEN_SHOW_FILE && it.startsWith(SHOW_FILE) && it.endsWith(SHOW_TMP_SUFFIX) }, + "a temp file must be created in the signal directory, created: $created" + ) + } + + // macOS polls the directory and keeps a taken name cached, so it misses short-lived files and reports a reused name as a modification + private fun assumeWatchServiceReportsEachCreation(dir: Path) = + assumeFalse("needs a watch service that reports each creation", dir.fileSystem.newWatchService().use { it.javaClass.simpleName == "PollingWatchService" }) + + private fun watch(dir: Path, test: (signals: LinkedBlockingQueue) -> Unit) { + val signals = LinkedBlockingQueue() + val watcherThread = dir.fileSystem.newWatchService().use { ws -> + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) + thread(isDaemon = true) { watchShowSignals(ws, dir) { signals.put(it) } }.also { + test(signals) + // a second, duplicate delivery would arrive within this wait + assertNull(signals.poll(1, TimeUnit.SECONDS), "each signal is taken once") + } + } + watcherThread.join(TimeUnit.SECONDS.toMillis(SIGNAL_WAIT_SECONDS)) + assertFalse(watcherThread.isAlive, "closing the watch service ends the watcher") + } + + private fun fileNames(dir: Path): List = + Files.list(dir).use { files -> files.map { it.fileName.toString() }.sorted().toList() } } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt new file mode 100644 index 0000000000..95b65da588 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt @@ -0,0 +1,20 @@ +package chat.simplex.app + +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +private const val TEMP_DIR_PREFIX = "simplex-desktop-test" + +internal fun withTempDir(parent: Path? = null, block: (Path) -> Unit) { + val tmp = if (parent == null) Files.createTempDirectory(TEMP_DIR_PREFIX) else Files.createTempDirectory(parent, TEMP_DIR_PREFIX) + try { + block(tmp) + } finally { + Files.walk(tmp).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { + try { Files.delete(it) } catch (_: IOException) {} + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt new file mode 100644 index 0000000000..76dd2b46fb --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt @@ -0,0 +1,86 @@ +package chat.simplex.app + +import chat.simplex.common.activationEvent +import chat.simplex.common.sendActivation +import com.sun.jna.NativeLong +import com.sun.jna.platform.unix.X11 +import org.junit.Assume.assumeNoException +import java.lang.reflect.Proxy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame + +private const val WINDOW_ID = 42L +private const val ATOM_ID = 7L + +class WindowActivationTest { + @Test + fun activationEventAsksTheWindowManagerToActivateTheWindowAsAPager() { + assumeLibX11() + val atom = X11.Atom(ATOM_ID) + val event = activationEvent(X11.Window(WINDOW_ID), atom) + val message = event.xclient + assertEquals(WINDOW_ID, message.window.toLong(), "target window") + assertEquals(atom, message.message_type, "_NET_ACTIVE_WINDOW atom") + assertEquals(32, message.format, "data format in bits") + // JNA writes only the selected member of a union, so the bytes XSendEvent receives are checked too + event.write() + assertEquals(X11.ClientMessage, event.pointer.getInt(0), "event type in native memory") + assertEquals(2L, message.data.pointer.getNativeLong(0).toLong(), "source indication in native memory: a pager, which focus stealing prevention lets through") + assertEquals(0L, message.data.pointer.getNativeLong(NativeLong.SIZE.toLong()).toLong(), "timestamp in native memory: CurrentTime") + } + + @Test + fun activationIsSentToTheRootWindowAndTheDisplayIsClosed() { + assumeLibX11() + val x11 = FakeX11() + sendActivation(x11.proxy, WINDOW_ID) + assertEquals(listOf("XOpenDisplay", "XInternAtom", "XDefaultRootWindow", "XSendEvent", "XFlush", "XCloseDisplay"), x11.calls) + assertNull(x11.displayName, "the display named by DISPLAY") + assertEquals("_NET_ACTIVE_WINDOW", x11.atomName, "atom of the request") + val send = x11.sendArgs + assertSame(x11.display, send[0], "display") + assertEquals(x11.root, send[1], "the request goes to the root window, where the window manager listens") + assertEquals(NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()), send[3], "event mask the window manager selects") + val event = (send[4] as X11.XEvent).xclient + assertEquals(WINDOW_ID, event.window.toLong(), "window to activate") + assertEquals(x11.atom, event.message_type, "the interned atom is the request type") + } + + @Test + fun displayIsClosedWhenSendingFails() { + assumeLibX11() + val x11 = FakeX11(sendFails = true) + assertFailsWith { sendActivation(x11.proxy, WINDOW_ID) } + assertEquals("XCloseDisplay", x11.calls.last(), "the display must be closed after a failure") + } + + private class FakeX11(private val sendFails: Boolean = false) { + val display = X11.Display() + val root = X11.Window(1) + val atom = X11.Atom(ATOM_ID) + val calls = mutableListOf() + var displayName: Any? = null + var atomName: Any? = null + var sendArgs: Array = emptyArray() + + val proxy = Proxy.newProxyInstance(X11::class.java.classLoader, arrayOf(X11::class.java)) { _, method, args -> + calls += method.name + when (method.name) { + "XOpenDisplay" -> { displayName = args[0]; display } + "XInternAtom" -> { atomName = args[1]; atom } + "XDefaultRootWindow" -> root + "XSendEvent" -> { sendArgs = args.copyOf(); if (sendFails) throw IllegalStateException("send failed"); 1 } + "XFlush", "XCloseDisplay" -> 0 + else -> throw UnsupportedOperationException(method.name) + } + } as X11 + } + + // JNA loads libX11 to lay out any X11 structure, and only X11 desktops have it + private fun assumeLibX11() { + try { X11.INSTANCE } catch (e: LinkageError) { assumeNoException("libX11 is not available", e) } + } +} diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 7c7455c7f3..caaa1ba272 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -85,6 +85,18 @@ compose { extraKeysRawXml = """ NSMicrophoneUsageDescription SimpleX needs microphone access to record voice messages + CFBundleURLTypes + + + CFBundleURLName + $bundleID + CFBundleURLSchemes + + simplexchat + simplex + + + """ } val identity = rootProject.extra["desktop.mac.signing.identity"] as String? diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index e407448d1b..46640b3dbb 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -9,19 +9,27 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.* import chat.simplex.common.acquireSingleInstance +import chat.simplex.common.installOpenUriHandler +import chat.simplex.common.linkFromArgs import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.size import chat.simplex.common.platform.* import chat.simplex.common.platform.DesktopPlatform import chat.simplex.common.showApp +import chat.simplex.common.singleInstanceLock +import chat.simplex.common.startShowFileWatcher import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import kotlinx.coroutines.* import java.io.File -fun main() { +fun main(args: Array) { try { - if (!acquireSingleInstance()) return + val link = linkFromArgs(args) + if (!acquireSingleInstance(link)) return + link?.let { chatModel.appOpenUrl.value = null to it } + // started after the launch link is stored, so a link it forwards later is not overwritten + if (singleInstanceLock) startShowFileWatcher() // Clean shared temp dirs only in the owning instance (not in a Files.desktop val // initializer, which a transient second instance would also run). Early: before settings writes. preferencesTmpDir.deleteRecursively() @@ -31,13 +39,14 @@ fun main() { runMigrations() setupUpdateChecker() initApp() + registerLinkSchemes() tmpDir.deleteRecursively() tmpDir.mkdir() // Only the owning instance cleans tmpDir on exit (see preferencesTmpDir above). tmpDir.deleteOnExit() - // showApp is inside the try: its first statements (SystemTray probe, Compose setup) are the - // process's first AWT init, which is itself a known startup failure cause (#4146). Crashes - // after the window appears are handled by the WindowExceptionHandler in showApp instead. + // installOpenUriHandler and showApp stay inside the try: whichever touches AWT first makes the process's + // first AWT init, a known startup failure cause (#4146); later crashes go to showApp's WindowExceptionHandler. + if (desktopPlatform.isMac()) installOpenUriHandler() return showApp() } catch (e: Throwable) { showStartupError(e) // the jpackage launcher otherwise hides the error behind "Failed to launch JVM" (#4146) diff --git a/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop b/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop index 79281a4dc8..68844ed5e6 100644 --- a/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop +++ b/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop @@ -2,6 +2,7 @@ Type=Application Name=SimpleX Chat Comment=Private and secure open-source messenger - no user IDs (not even random numbers) -Exec=/opt/simplex/bin/simplex +Exec=/opt/simplex/bin/simplex %u Icon=/opt/simplex/lib/simplex Categories=Network;Chat; +MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex; diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index d29ac5c2e5..bb44f5ab01 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -101,6 +101,7 @@ Key files that exist only on one platform, grouped by concern. | `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | JVM entry point, Haskell/VLC init, PlatformInterface setup | | `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Compose Desktop window creation and lifecycle | | `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | Window position/size persistence | +| `common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt` | Deep link handling from launch arguments and macOS | | `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | In-app update checker | | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt` | VLC-based video detection | | `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | VLC video player implementation | diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index aae24bdca8..a17a2ed2cc 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -13,6 +13,7 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des 5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified) 6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented) 7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) +8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration) --- @@ -289,3 +290,35 @@ This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self - GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop. - The `testCrypto()` function referenced in `AppCommon.desktop.kt:39` is commented out with a `// LALAL` marker, suggesting crypto testing was planned but never completed. + +--- + +## GAP-08: Desktop: App Link Registration + +**Severity:** Low +**Category:** Platform +**Platform:** Desktop only + +### Description + +Desktop registers the `simplexchat:` scheme so the badge page can return to the app, and the `simplex:` scheme so connection links open in it. The registration has known limits: + +- Uninstalling or deleting the app leaves the per-user registration behind: the HKCU keys on Windows (the scheme keys, the ProgIds, `Capabilities` and the `RegisteredApplications` entry) and the hidden AppImage desktop entry on Linux. A later link then points at a missing program. On Windows, if the app was installed into a folder other users can write to (for example a custom folder at the root of `C:\`), another user could put a program at that path and have it run on the next link; the default Program Files install is not affected. This risk is accepted. +- A Flatpak counts as registered without a check, as it cannot query the host's default handler. It works only once the Flathub release pins a commit with the desktop file's MimeType line and its wrapper passes `"$@"` to the app. +- On macOS a second copy of the app does not forward a link to the running one; macOS delivers the link to the running bundle itself. +- With two installations (a deb and an AppImage, or two installed copies of the Windows app), the scheme stays with whichever registered first while it is installed; the other gets `app=desktop`. On Windows, when several apps registered through `Capabilities` handle a scheme and none was picked, Windows chooses or asks; the app cannot set `UserChoice` itself. +- The link arrives as a command-line argument, which other local users can read through `/proc` on Linux. This risk is accepted. + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt` -- registration and its result for the badge page +- `common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt` -- forwarding to a running instance + +### Impact + +A stale registration or an unregistered Flatpak sends the user back to a link that opens nothing; the badge page's *Show code* and the Redeem code screen remain. + +### Recommendation + +1. Remove the registration in an uninstaller where one exists (MSI), and on AppImage when the entry's file is gone. +2. Removing the command-line exposure needs the page to encrypt the code to a key the app passes in the URL fragment. diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index c6dbf1abf2..59f3fbe78a 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -107,7 +107,7 @@ Common Module (commonMain) | Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source | | Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) | | Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories | -| Gaps | [product/gaps.md](../product/gaps.md) | 7 known gaps with recommendations | +| Gaps | [product/gaps.md](../product/gaps.md) | 8 known gaps with recommendations | | Flows | [product/flows/](../product/flows/) | onboarding, messaging, connection, calling, file-transfer, group-lifecycle | | Views | [product/views/](../product/views/) | chat-list, chat, settings, onboarding, call, new-chat, contact-info, group-info, user-profiles | @@ -119,7 +119,7 @@ Common Module (commonMain) |---|---|---|---| | Android Application | [`SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L41) | `class SimplexApp` | 41 | | Android Activity | [`MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L27) | `class MainActivity` | 27 | -| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) | `fun main()` | 21 | +| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) | `fun main(args: Array)` | 26 | | Desktop App Window | [`DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) | `fun showApp()` | 33 | | Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 | | Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 | @@ -130,8 +130,8 @@ Common Module (commonMain) | Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 | | Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | `abstract class NtfManager` | 20 | | Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 | -| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L33) | `fun initHaskell(packageName: String)` | 33 | -| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L41) | `fun runMigrations()` | 41 | +| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L36) | `fun initHaskell(packageName: String)` | 36 | +| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L48) | `fun runMigrations()` | 48 | | Android Service | [`SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt#L41) | `class SimplexService` | 41 | | Gradle Root | [`settings.gradle.kts`](../settings.gradle.kts#L22) | `include(":android", ":desktop", ":common")` | 22 | | Common Build | [`build.gradle.kts`](../common/build.gradle.kts#L14) | `kotlin { androidTarget(); jvm("desktop") }` | 14 | diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index 9911a2670f..cdc43a794a 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -222,21 +222,29 @@ Lifecycle callbacks in `SimplexApp` (implements `LifecycleEventObserver`): ### Desktop -Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) +Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) ``` -main() +main(args) + +-- linkFromArgs(args) // A simplexchat: or simplex: link passed by the OS + +-- acquireSingleInstance(link) // Or forward the link to the running instance and exit + +-- appOpenUrl = link // Stored before the watcher can forward a newer link + +-- startShowFileWatcher() // Only while holding the single-instance lock +-- initHaskell() // Load native lib from resources dir, call initHS() | +-- System.load(libapp-lib.so/dll/dylib) | +-- initHS() +-- runMigrations() +-- setupUpdateChecker() +-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart + +-- registerLinkSchemes() // Background thread, per-OS registration and detection + +-- installOpenUriHandler() // macOS only: links arrive as Apple Events +-- showApp() // Compose window with AppScreen() ``` [`showApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) creates a Compose `Window` with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window. +[`showWindow()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L258) un-minimizes and raises the window when a second launch or a link reaches the running app. On Linux it also sends `_NET_ACTIVE_WINDOW` through [`requestX11Activation()`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt#L17), because wlroots compositors such as Sway ignore the raise that AWT's `toFront()` sends under XWayland. On Windows the second process, which the browser left holding the foreground right, calls `AllowSetForegroundWindow` before it signals so that the running instance may raise its window. During a crash restart `showWindow()` skips the disposed window, so it does not come back; the new window opens visible. + [`initApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) sets the `ntfManager` implementation (desktop notifications via `NtfManager` in `common/model/`) and calls `initChatControllerOnStart()`. --- @@ -341,8 +349,8 @@ Examples from platform files: [`PlatformInterface`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) is an interface with default no-op implementations. It is assigned at runtime by each platform entry point: -- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L187) (line 187) -- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L50) (line 50) +- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L193) +- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L59) The global variable is declared at [`Platform.kt line 50`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L50): ```kotlin @@ -417,6 +425,8 @@ var platform: PlatformInterface = object : PlatformInterface {} | SimplexService.kt | [`android/src/main/java/chat/simplex/app/SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt) | Android foreground service | | Main.kt | [`desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt) | Desktop `main()` | | DesktopApp.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt) | `showApp()`, `SimplexWindowState` | +| SingleInstance.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt) | `acquireSingleInstance()`, `startShowFileWatcher()`, signal file hand-over | +| WindowActivation.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt) | `requestX11Activation()`, `sendActivation()`, `activationEvent()` | ### Theme diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 9cad3d3901..d6d5670c2f 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -109,9 +109,11 @@ When onboarding is complete: ### 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. +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. -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/`) 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. +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. ### Overlay Layers (bottom of MainScreen) @@ -382,3 +384,5 @@ The stage value is stored in `appPrefs.onboardingStage` and persisted across app | `views/SplashView.kt` | Splash / loading screen | | `views/call/CallView.kt` | In-call fullscreen view (ActiveCallView) | | `views/localauth/PasswordEntry.kt` | Column divider utility (contains VerticalDivider) | +| `AppLinks.kt` (desktopMain) | Desktop app links from launch arguments and macOS Apple Events | +| `platform/AppLinkScheme.desktop.kt` (desktopMain) | Runtime `simplexchat:` registration and its result for the badge page | diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index ec302e30b5..1400dc25ae 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -415,6 +415,10 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` |-------------|--------------------------|------------|-------| | `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery | | `StoreWindowState.kt` | — | Low | Window position/size persistence | +| `AppLinks.kt` | — | Medium | `simplexchat:` and `simplex:` links from launch arguments and macOS Apple Events | +| `SingleInstance.kt` | — | Medium | Single-instance lock; forwards a second launch and its link to the running app | +| `WindowActivation.kt` | — | Low | `_NET_ACTIVE_WINDOW` request that raises the window on X11 and XWayland | +| `platform/AppLinkScheme.desktop.kt` | — | Medium | Runtime `simplexchat:` and `simplex:` scheme registration (Windows HKCU, AppImage desktop entry) | | `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | | `platform/AppCommon.desktop.kt` | PC1 through PC31 | Medium | Desktop app initialization actual declarations | | `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | diff --git a/plans/2026-09-24-badge-buy-in-browser.md b/plans/2026-09-24-badge-buy-in-browser.md index 826d8766a5..ec79f0564e 100644 --- a/plans/2026-09-24-badge-buy-in-browser.md +++ b/plans/2026-09-24-badge-buy-in-browser.md @@ -22,7 +22,7 @@ Everything in the app sections below is both platforms unless it says otherwise. 4. **`purchaseBadge` with store receipts**, which depends on none of the above and can run alongside it. 5. **The app's store lane**, last, because nothing in it completes without 4. -(*) **Desktop deep links** sit outside this order entirely — any time or never, blocking nothing. Own section below. +(*) **Desktop deep links** sat outside this order entirely. ## The page @@ -66,15 +66,17 @@ Two invariants that are easy to miss. A store transaction must not be finished u ## Desktop (D1, D2) -No deep link. *Buy in browser* opens the page with `app=desktop` and the Redeem code screen at the same time, so the code has somewhere to go the moment it appears. This is also the fallback for any platform where the scheme turns out not to work: nothing else in the app depends on the link existing. +Where the scheme is not registered, there is no deep link: *Buy in browser* opens the page with `app=desktop` and the Redeem code screen at the same time, so the code has somewhere to go the moment it appears. This is also the fallback for any platform where the scheme turns out not to work: nothing else in the app depends on the link existing. ## Desktop deep links (*) -Optional, off the critical path, and blocking nothing — D1 works without it. Worth investigating on its own; if it lands, desktop joins lane A and D1 becomes the fallback rather than the design. +This was optional and off the critical path, and it blocked nothing, since D1 works without it. -Nothing registers a scheme on desktop today. `appOpenUrl` is wired in commonMain with no desktop implementation behind it, and `desktop/build.gradle.kts` declares Deb, Dmg, Msi and Exe with no protocol entry. Registration is per-OS, three separate small jobs: `CFBundleURLTypes` in the bundle plist plus `Desktop.setOpenURIHandler` on macOS, registry keys under `HKCU\Software\Classes` that the installer would have to write on Windows, and a `.desktop` entry with `MimeType=x-scheme-handler/simplexchat` on Linux. None of them covers an unpackaged run, so the fallback stays either way. +The plan is `plans/2026-09-28-desktop-deep-links.md`. The feature is implemented, and verification on real machines is pending. Desktop joins lane A wherever the scheme is registered, and D1 is the fallback rather than the design. -Getting the URL to an app that is already running is the part that half exists. `SingleInstance.kt` takes a file lock and uses a watched file to bring the running instance forward on a second launch; carrying a URL means giving that channel a payload rather than inventing IPC for it. +Before that work, nothing registered a scheme on desktop. `appOpenUrl` was wired in commonMain with no desktop implementation behind it, and `desktop/build.gradle.kts` declared Deb, Dmg, Msi and Exe with no protocol entry. Registration is per-OS, three separate small jobs: `CFBundleURLTypes` in the bundle plist plus `Desktop.setOpenURIHandler` on macOS, registry keys under `HKCU\Software\Classes` on Windows (the app now writes them at runtime), and a `.desktop` entry with `MimeType=x-scheme-handler/simplexchat` on Linux. None of them covers an unpackaged run, so the fallback stays either way. + +Getting the URL to an app that is already running was the part that half existed. `SingleInstance.kt` took a file lock and used a watched file to bring the running instance forward on a second launch; the implementation gives that channel a payload rather than inventing IPC for it. ## The service diff --git a/plans/2026-09-28-desktop-deep-links-report.md b/plans/2026-09-28-desktop-deep-links-report.md new file mode 100644 index 0000000000..08561f6808 --- /dev/null +++ b/plans/2026-09-28-desktop-deep-links-report.md @@ -0,0 +1,150 @@ +# Desktop deep links: report + +Packaged desktop builds on macOS, Windows and Linux receive `simplexchat:` links from the badge page, and `simplex:` connection links, with the app closed, running or hidden in the tray. Unit tests and builds pass; no packaged build has been run on real machines yet, so the manual checklist below is open. + +The plan is `plans/2026-09-28-desktop-deep-links.md`. + +## Flow + +``` +page: Return to SimpleX → simplexchat:/badge/code/ + macOS Apple Event → installOpenUriHandler → openDesktopLink + Windows, Linux new process main(args) → linkFromArgs + lock acquired → chatModel.appOpenUrl (cold start), then the watcher starts + lock taken → simplex.show with the link → running app → openDesktopLink +openDesktopLink: chatModel.appOpenUrl = remoteHostId() to link, then showWindow() +App.kt LaunchedEffect → connectIfOpenedViaUri → openAppLink → BadgesRedeemLinkView (asks before redeeming) +``` + +## Registration + +| Build | Registered by | What is written | Registered when | +|---|---|---|---| +| macOS `.dmg` | Info.plist, at build | `CFBundleURLTypes` with schemes `simplexchat` and `simplex` in `desktop/build.gradle.kts` | copied to /Applications; LaunchServices picks it up | +| Windows `.msi`, `.exe` | the app, each start | `HKCU\Software\Classes\simplexchat` and `\simplex`: `URL Protocol`, `DefaultIcon`, `shell\open\command = "" "%1"`; ProgIds `SimpleX.simplexchat` and `SimpleX.simplex` with the same command; `HKCU\Software\chat.simplex.app\Capabilities` with `URLAssociations`, listed in `HKCU\Software\RegisteredApplications` | first launch per user; rewritten when a value is missing or the command's program is gone, not while another installed program, per-user or machine-wide, holds it; registered when Windows (`AssocQueryStringW`) names this exe as the program for `simplexchat:` | +| Linux `.deb` | `make-deb-linux.sh`, at build | jpackage's `simplex-simplex.desktop` gets `Exec=... %u` and `MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;` | install; postinst `xdg-desktop-menu install` refreshes the MIME cache | +| AppImage | the app, each start; also the bundled entry | hidden `$XDG_DATA_HOME/applications/chat.simplex.app-links.desktop` with `Exec= %u`, the path quoted only when the desktop-entry spec requires it, then `xdg-mime default` for each scheme; the bundled `chat.simplex.app.desktop` declares the MimeType for AppImage integration tools | first launch; rewritten if the AppImage moved; the default is set only when none is set or the current handler's entry is no longer installed | +| Flatpak | `scripts/flatpak/chat.simplex.simplex.desktop` | `MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;` (Exec already has `%U`) | install of a Flathub release whose pinned commit includes this file | + +The registration code is in `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt`. It runs once on a background thread after `initApp()`. The installation kind comes from `desktopInstallation(platform, appPath, env)`. An unpackaged run (no `jpackage.app-path`) registers nothing, and `APPIMAGE` counts only when the launcher runs from `APPDIR`, resolved through symlinks, because the AppImage runtime exports it to every child process. The AppImage entry is rewritten, followed by `update-desktop-database`, only when its content changed. An AppImage is not registered 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, since the entry would run that file for every link; POSIX ACLs are not inspected. A path with a line break or `%` is not registered either, since GLib looks the program up before it expands `%%`. The path in `Exec` is quoted only when the desktop-entry spec requires it, because `xdg-open` 1.1.3 (Debian 12, Ubuntu 24.04) cannot run a quoted program in its generic mode, which Sway and other desktops it does not recognise use. + +## Receiving the link + +- **Closed, Windows and Linux:** the link is the only argument of `main`. `linkFromArgs` accepts it only when it is the single argument, starts with `simplexchat:` or `simplex:` and is at most 8192 bytes in UTF-8, enough for a one-time link with its post-quantum key (about 2 KB) and for a second such key. +- **Running, Windows and Linux:** the second process writes the link to an owner-only temp file in `dataDir` and renames it to `simplex.show`; if that fails, it writes an empty signal, which still raises the running app. The running app renames it to a private name, accepts at most 8192 bytes, deletes it, opens the link and shows its window. A signal present when the watcher starts is handled too, unless it predates the lock attempt by more than a 2 s tolerance for file time granularity; such a signal is left by an earlier session and is deleted instead, as are old temp files and a taken file left by a crash. On Windows the second process first calls `AllowSetForegroundWindow` so the window can come to the front, and logs a refusal. +- **Hung running app:** the existing "not responding" alert stays. "Start anyway" withdraws the signal and the new process opens the link itself; if the running app recovered and took the signal while the alert was up, the new process exits instead, so the link opens once. If renaming the signal fails, for example while another program holds it open, the running app retries once after 100 ms. +- **macOS:** the link arrives as an Apple Event, closed or running. `installOpenUriHandler` sets the handler on macOS only; the JDK holds an event that arrives during startup until then. +- **Bringing the window forward on Linux:** AWT's `toFront()` sends only `XRaiseWindow`, which Sway drops and focus stealing prevention refuses. `showWindow` makes the window visible at once and also sends `_NET_ACTIVE_WINDOW` (pager source) through JNA, after syncing AWT's connection so the map request comes first. On Sway the result follows `focus_on_window_activation`: the default `urgent` marks the window urgent, `smart` or `focus` focuses it. This was verified on headless Sway 1.9 with XWayland. + +## Badge page ending + +`appLinkSchemeRegistered()` decides the page flag when *Buy in browser* is tapped: + +| Result | Page URL | App side | +|---|---|---| +| registered | `#/tier?app=true` | page ends with *Return to SimpleX* and *Show code* | +| not registered | `#/tier?app=desktop` | Redeem code screen opens beside the browser (fallback) | + +Only `simplexchat:` counts here; `simplex:` is registered alongside it without being checked. By build, macOS is registered when packaged; Windows when the shell (`AssocQueryStringW`) names this exe as the program for `simplexchat:`; Flatpak always; AppImage and deb when `xdg-mime` reports their own entry as default. Android always reports registered. The button reads the flag once, so the URL and the fallback screen agree. + +## Security + +- The link is a bearer token. No log line on this path includes it. +- Any web page or program can send a link. The redeem screen from #7592 asks before redeeming and names the profile. +- The code arrives as a command-line argument. On Linux any local user can read it through `/proc//cmdline` unless `/proc` is mounted with `hidepid`; on a cold start the argument belongs to the app itself and stays readable until it exits, otherwise to the second process for about a second. On Windows only the same user and administrators can read it. This is an accepted risk; removing it needs the page to encrypt the code to a key the app passes in the URL fragment. +- The signal file holding the link is owner-only on POSIX and deleted as soon as it is read. It stays on disk only if the running app is hung. A later start that takes the lock deletes it without opening the link once it is more than 2 s older than the lock attempt; while the hung app still holds the lock, a later launch replaces the file and shows the alert again. +- Another program can claim the same scheme (on Windows, an app registered through `Capabilities` or picked in default-app settings wins; on Linux, the last `xdg-mime default` wins). That risk is accepted, and *Show code* on the page remains. + +## Automated verification (done) + +- `./gradlew :common:desktopTest`: all tests pass; this work adds `AppLinksTest`, `AppLinkSchemeTest`, `BadgePageUrlTest`, `WindowActivationTest` and nineteen `SingleInstanceTest` cases. + - `AppLinksTest`: accepts `simplexchat:` and `simplex:` in any case, including a one-time link with its post-quantum key; rejects `https:`, another scheme sharing the prefix, a path, empty, a link that is not the only argument, and a link over 8192 bytes, including a multi-byte one shorter than that in characters. + - `SingleInstanceTest`: link and empty signals round trip and are taken once; a later signal replaces an untaken one, and a taken file left by a crash is replaced; the file is owner-only (on POSIX); a link of exactly 8192 bytes and a one-time link with its post-quantum key are accepted; non-link content, an oversized link and a multi-byte link over the byte bound are dropped and still removed; the temp file is created beside the signal (skipped where the watch service polls, as on macOS); the startup sweep removes old temp files and a taken file but keeps a fresh temp file and the old lock and database files, and keeps a signal within the 2 s tolerance but drops an older one; the watcher takes a signal written before its watch, and successive signals arriving while it watches (skipped where the watch service polls, as on macOS), through a real `WatchService`, each once. + - `AppLinkSchemeTest`: Windows command quoting, the registry values, the deb counting as registered only when its own entry is the default, the ProgIds and the application listing, and registration with a stand-in shell (writing every value when the commands point at a missing program, leaving a full registration alone without notifying Windows, repairing a missing value or a missing application listing and notifying, keeping another installed program's or another copy's handler per-user or machine-wide, repairing this app's ProgId while another program holds the scheme, updating a command for this exe with other arguments, leaving a scheme alone when its handler cannot be read or its ProgId cannot be written, and reporting registered only when the shell names this exe, in any letter case); desktop-entry quoting that leaves a plain path bare, quotes every reserved character, keeps spaces inside the quotes and escapes `"`, `` ` ``, `$` and `\`; the full AppImage entry and its refusal of line breaks and `%`; `desktopInstallation` precedence, including Flatpak over a matching AppImage, inherited AppImage variables, a mount path that only shares a prefix, and a mount reached through a symlink; the AppImage ownership check for world-writable and group-writable files and directories, a world-writable ancestor, another owner, a root-owned file, and a missing file; AppImage registration writing the entry into a missing directory and setting both defaults, leaving unchanged defaults alone, setting the defaults again for an unchanged entry, setting only the default that points elsewhere, rewriting the entry of a moved AppImage, failing when another handler keeps the `simplexchat:` default, succeeding when it keeps only the `simplex:` one, and refusing a path the entry cannot run; `runProcess` output, discarded error output, an end of input and failure. + The ownership, AppImage registration and `runProcess` cases need POSIX permissions and `sh`, and the symlinked-mount case needs symlink rights, so they are skipped on Windows. + - `BadgePageUrlTest`: `app=true` when the link returns, `app=desktop` otherwise. + - `WindowActivationTest`: the `_NET_ACTIVE_WINDOW` client message targets the window with the pager source and `CurrentTime`, checked in native memory; it is sent to the root window with the window manager's event mask, and the display is closed even when sending fails (skipped where libX11 cannot load, as on Windows and macOS). +- `./gradlew :desktop:compileKotlinJvm` and `./gradlew :common:compileDebugKotlinAndroid`: succeed, no new warnings. +- The `make-deb-linux.sh` sed applied to the v7.0.3 deb's desktop file gives the intended lines, and `update-desktop-database` lists it for `x-scheme-handler/simplexchat`. +- The AppImage entry passes `desktop-file-validate` and reaches the MIME cache. + +## Manual verification (to do) + +Test with `simplexchat:/badge/code/` from a real purchase, or with the page mock (`apps/simplex-badge-service/web/mock/server.py`, open `#/tier?app=true` in a private window, settle with `/control/settle/`). + +### macOS (signed `.dmg`, installed to /Applications) +- [ ] `lsregister -dump | grep simplexchat` lists the installed app +- [ ] app closed: `open 'simplexchat:/badge/code/'` starts it and shows the redeem screen +- [ ] app running, window behind others: link raises it and shows the redeem screen +- [ ] app hidden in the tray: link shows the window +- [ ] dmg still mounted beside the installed copy: the installed copy opens +- [ ] Safari, Chrome, Firefox: *Return to SimpleX* prompts, then opens the app + +### Windows (`.msi` install) +- [ ] after first launch, `HKCU\Software\Classes\simplexchat\shell\open\command` is `"C:\Program Files\SimpleX\SimpleX.exe" "%1"` +- [ ] app closed: `start simplexchat:/badge/code/` starts it and shows the redeem screen +- [ ] app running: link reaches the running app, window comes to the front +- [ ] app hidden in the tray: link shows the window +- [ ] the argument arrives as one piece after the launcher restarts itself +- [ ] Edge, Chrome, Firefox: *Return to SimpleX* prompts, then opens the app +- [ ] after an MSI upgrade, the key still points at the installed exe +- [ ] SimpleX is offered under *Settings → Default apps → SIMPLEXCHAT*; picking another app there makes the page end with `app=desktop` after a restart +- [ ] fresh install, no choice made: the page ends with `app=true`, and `start simplexchat:/badge/code/` opens SimpleX +- [ ] with another app registered through `Capabilities` and no choice made: the page's ending matches what `start simplexchat:` actually opens (`app=true` only if SimpleX opens) + +### Linux +- [ ] deb on GNOME and KDE: `xdg-mime query default x-scheme-handler/simplexchat` gives `simplex-simplex.desktop` +- [ ] deb, app closed and running: `xdg-open 'simplexchat:/badge/code/'` shows the redeem screen +- [ ] AppImage: after first launch the hidden entry exists and is the default; moving the AppImage and relaunching updates it +- [ ] Flatpak: link opens the Flatpak app (needs the Flathub update) +- [ ] Firefox and Chrome from the distribution, snap Firefox on Ubuntu, Flatpak Firefox: *Return to SimpleX* opens the app +- [ ] GNOME on Wayland: window comes forward +- [ ] Sway: default config marks SimpleX urgent; with `focus_on_window_activation focus` the link focuses it +- [ ] deb and AppImage: `xdg-mime query default x-scheme-handler/simplex` names the app's entry + +### All platforms +- [ ] app locked with a passcode: the redeem screen appears after unlock +- [ ] no active profile: the link waits until a profile is active +- [ ] desktop connected to a mobile: the badge goes to the active remote profile +- [ ] a second link while one redeems is ignored +- [ ] no log file or console output contains the code +- [ ] a `simplex:` contact or one-time link from a browser, app closed and running: the connect dialog opens and asks before connecting +- [ ] unpackaged `./gradlew run`: page gets `app=desktop`, Redeem code screen opens beside the browser +- [ ] `./gradlew runDistributable` and the installed app keep each other's registration while both exes exist; delete the build output (or the HKCU keys) to give links back to the installed app +- [ ] two installed copies: the scheme stays with the first; after uninstalling it, the next start of the other copy takes the scheme + +## Known gaps + +The registration gaps below are also recorded as GAP-08 in `apps/multiplatform/product/gaps.md`. + +- A Flatpak always counts as registered, since the sandbox cannot query the host's default handler. If another installation holds the default, the page sends `app=true` and the link opens that installation; *Show code* still works. +- On Sway the window only becomes urgent unless the user sets `focus_on_window_activation smart` or `focus`. No client-side request can do more: Sway accepts activation tokens only for native Wayland windows. +- The Flathub release bump must pin a simplex-chat commit that includes the desktop file's MimeType line, and the Flathub wrapper must pass `"$@"` (done on its `update` branch). +- The HKCU key and the AppImage entry stay after uninstalling or deleting the app. On Windows, after an install into a folder other users can write to (such as a custom folder at the root of `C:\`), another user could place a program at the registered path; this accepted risk does not apply to the default Program Files install. +- On macOS, if LaunchServices starts a second copy of the app while one runs, that copy forwards no link: the URL arrives as an Apple Event the second copy never handles. +- jpackage's deb desktop file already has `Categories=Unknown`, which `desktop-file-validate` rejects, and a generic `Name=simplex`. The scheme still registers, and this work leaves both as they are. + +## Commits + +| Commit | Change | +|---|---| +| `tests: close the walk stream in withTempDir` | `Files.walk` closed in the existing `SingleInstanceTest` helper | +| `desktop: receive app links from args and macOS` | `AppLinks.kt`, `main(args)`, macOS handler | +| `desktop: forward an app link to the running app` | signal file payload in `SingleInstance.kt` | +| `desktop: initialize the desktop platform lazily` | `desktopPlatform` class initialization cycle | +| `tests: share the temp directory helper` | `withTempDir` moved to `TempDir.kt` for the scheme tests | +| `desktop: register simplexchat scheme in packages` | Info.plist, deb, AppImage and Flatpak desktop entries | +| `desktop: register simplexchat scheme at runtime` | `AppLinkScheme.desktop.kt` (Windows, AppImage, detection) | +| `ui: end the badge page with a link on desktop` | `appLinkSchemeRegistered()`, `badgePageUrl(linkReturns)`, fallback | +| `desktop: raise the window over other apps` | `isVisible` in `showWindow` and its skip of a disposed window during a crash restart, `_NET_ACTIVE_WINDOW` in `WindowActivation.kt`, `AllowSetForegroundWindow` on Windows | +| `docs: describe desktop deep links in the spec` | `navigation.md`, `architecture.md`, spec `README.md`, `impact.md`, `CODE.md` Document Map, `concepts.md`, `gaps.md` (GAP-08) | +| `docs: plan desktop deep links` | badge plan, plan | +| `docs: report on desktop deep links` | this report | +| `desktop: open simplex connection links` | `simplex:` accepted and registered beside `simplexchat:`, one 8192-byte bound, desktop link names without `App` | +| `docs: describe simplex links on desktop` | spec, `gaps.md`, plan and this report | +| `desktop: keep a link from opening twice` | `startDespiteRunningInstance()`: "Start anyway" withdraws the signal, and exits if the running app already took it | +| `desktop: retry taking a signal held open` | `takeSignal` retries a failed rename once | +| `desktop: keep the user's AppImage link handler` | the AppImage takes a scheme only when no handler is set or the current handler's entry is no longer installed | +| `desktop: register Windows links through Capabilities` | scheme keys and `SimpleX.` ProgIds, `Capabilities` with `URLAssociations` and `RegisteredApplications`; every value compared and repaired; another installed program's or copy's handler kept, per-user or machine-wide; registered when `AssocQueryStringW` names this exe; `SHChangeNotify` only after a change | +| `desktop: add %u to the deb entry only once` | the deb's `Exec` sed skips a line that already has a field code | +| `docs: describe the review fixes` | spec, `gaps.md`, plan and this report | diff --git a/plans/2026-09-28-desktop-deep-links.md b/plans/2026-09-28-desktop-deep-links.md new file mode 100644 index 0000000000..1097cab987 --- /dev/null +++ b/plans/2026-09-28-desktop-deep-links.md @@ -0,0 +1,145 @@ +# Desktop deep links: `simplexchat:` on Linux, Windows, macOS + +## Context + +The badge page (`badges.simplex.chat`) ends a purchase opened from the app with *Return to SimpleX*, which navigates to `simplexchat:/badge/code/`. Android and iOS handle it since #7592 (`isAppLink` → `openAppLink` → `openBadgeLink` → `BadgesRedeemLinkView`, which confirms before redeeming). Desktop registers no scheme, so it opens the page with `app=desktop` and relies on the user pasting the code (D1). Goal: every packaged desktop build receives the link, cold or running, and sends `app=true` only when a link is expected to reach this installation. D1 stays as the fallback. + +The decisions were to build on #7592, forward to a running instance through the existing signal file with a payload, register `simplexchat:` only (`simplex:` connection links were added afterwards, see the last section), and self-register at runtime on Windows (HKCU) and AppImage. + +## Research summary + +- **macOS**: `CFBundleURLTypes` in Info.plist; LaunchServices registers on copy to /Applications. URL arrives only as an Apple Event through `Desktop.setOpenURIHandler`; JDK 17 queues it until the handler is set (`_OpenURIDispatcher`), so cold start is safe for AWT/Compose. The running app receives it in-process, so no second process starts. +- **Windows**: `HKCU\Software\Classes\simplexchat` with `URL Protocol`, `shell\open\command = "" "%1"`. Compose 1.8.2 exposes no `--resource-dir`, so installer registration needs a WiX fork; runtime HKCU is what Signal, Element, Telegram do. URL arrives as argv of a new process. `jpackage.app-path` is set by the jpackage launcher (JDK 16+). +- **Linux**: `.desktop` with `MimeType=x-scheme-handler/simplexchat;` and `Exec=... %u`, visible once `mimeinfo.cache` is rebuilt. The v7.0.3 deb ships `/opt/simplex/lib/simplex-simplex.desktop` with `Exec=/opt/simplex/bin/simplex` and empty `MimeType=`; its postinst runs `xdg-desktop-menu install`, which runs `update-desktop-database`. AppImage has no install step, so it self-registers in `~/.local/share/applications`. Flatpak exports the `.desktop` MimeType (file is in `scripts/flatpak/`). +- **Browsers**: all prompt before launching; Chrome needs a user gesture (the page's button provides it). No reliable way for the page to know the app opened, so *Show code* stays. +- **Security**: link is a bearer token in argv, which on Linux other local users can read through `/proc` (for the whole session after a cold start); this risk is accepted. The lane already confirms before redeeming. Nothing may log the URI. + +## Flow + +``` +browser ─ simplexchat:/badge/code/X + macOS: Apple Event → setOpenURIHandler → openDesktopLink + Windows/Linux: new process main(args) → linkFromArgs + lock acquired → chatModel.appOpenUrl (cold start), then the watcher starts + lock taken → simplex.show payload → primary watcher → openDesktopLink +openDesktopLink: chatModel.appOpenUrl = remoteHostId() to uri; showWindow() +App.kt LaunchedEffect (existing) → connectIfOpenedViaUri → openAppLink (lane) +``` + +## Changes + +1. **`common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt`** (new, beside `SingleInstance.kt`) + - `isAcceptedLink(uri)`: `isAppLink` (`ChatListView.kt`) within `MAX_LINK_BYTES` UTF-8 bytes. + - `linkFromArgs(args)`: the only argument, when it is an accepted link; anything else is ignored. + - `openDesktopLink(uri)`: on the EDT, `chatModel.appOpenUrl.value = chatModel.remoteHostId() to uri`, then `showWindow()` (`DesktopApp.kt:258`), so a failure to show the window cannot lose the link. `remoteHostId()` matches the redeem screen (`BadgesRedeemCodeView.kt:98`). + - `installOpenUriHandler()`: called on macOS only (so Windows and Linux do not start AWT early); sets a handler that forwards accepted links to `openDesktopLink`. + +2. **`desktop/.../Main.kt`**: `main(args)`. Compute the link and pass it to `acquireSingleInstance(link)`; when this process runs, put it into `chatModel.appOpenUrl` (`null` host), then start the watcher if this process holds the lock, so a link the watcher forwards later is not overwritten. A short-lived second process never touches `chatModel`. After `initApp()`: start registration (step 4) and `installOpenUriHandler()`. Keep everything inside the existing try. + +3. **`SingleInstance.kt`**: the signal carries an optional payload. + - Second process: `Files.createTempFile(dataDir, "simplex.show", ".tmp")` (owner-only on POSIX), write the link or nothing, then `Files.move(tmp, showPath, ATOMIC_MOVE)`, which replaces an untaken signal on Linux and Windows. If that fails, fall back to an empty signal, which still brings the running instance forward. The rename arrives as `ENTRY_CREATE` on inotify and Windows. Existing 1 s wait and hung-primary alert stay; on "start anyway" the payload file is deleted and `main` handles the link itself. + - Primary watcher: rename `simplex.show` to a private temp name, so a newer signal renamed over it is not deleted unread, then accept at most `MAX_LINK_BYTES` bytes, delete it, and post `openDesktopLink(link)` or `showWindow()`. + - Primary start deletes a taken file left by a crash, and any `simplex.show*` file older than its lock attempt minus a 2 s tolerance for file time granularity: such a signal or temp file was left by an earlier session, while a fresh temp file is a signal being written. `main` then starts the watcher, which registers the watch and handles a signal already present, which raised no event. + - Windows: call `AllowSetForegroundWindow(ASFW_ANY)` before signalling so the primary can come forward (best effort). jna-platform 5.14 does not bind it, so it is looked up through `NativeLibrary`. + - Take the directory as a parameter internally so tests can use a temp dir. + - No log line includes the link. + +4. **`common/src/desktopMain/.../platform/AppLinkScheme.desktop.kt`** (new): `registerLinkSchemes()` runs once on a background thread and sets a `@Volatile` result read by the UI. `unixDataHome` is exposed from `Platform.desktop.kt` and `appLinkScheme` from `ChatListView.kt` for reuse. + - macOS: registered when `jpackage.app-path` is set (packaged bundle). + - Windows: when `jpackage.app-path` is set, read the HKCU command via `Advapi32Util`. If it differs from `"" "%1"`, write the default value `URL:SimpleX Chat`, `URL Protocol`, `DefaultIcon`, and `shell\open\command`. Registered if the key now matches. + - Linux: + - `container=flatpak` set (the check `AppUpdater` uses) → registered. + - AppImage, when `APPIMAGE` and `APPDIR` are set and the launcher runs from `APPDIR`, resolved through symlinks (the runtime exports `APPIMAGE` to every child process) → write `$XDG_DATA_HOME/applications/chat.simplex.app-links.desktop`: `NoDisplay=true`, `Exec= %u` with the path quoted only when the desktop-entry spec requires it (`xdg-open` 1.1.3 cannot run a quoted program), and the MimeType line; a path with a line break or `%` is refused (GLib looks the program up before it expands `%%`), and so is an AppImage whose file or any directory above it another user could replace (owned by anyone but the user or root, writable by others, or group-writable by a group other than the user's own; POSIX ACLs are not inspected). Rewrite it and run `update-desktop-database` only when the content differs, then run `xdg-mime default` unless the entry is already the default. + - Otherwise (deb): registered when `xdg-mime query default x-scheme-handler/simplexchat` names the deb's own `simplex-simplex.desktop`. Processes are run without a shell and with a timeout. + - The decision is the function `desktopInstallation(platform, appPath, env)`, so it is unit tested. + - `desktopPlatform` becomes lazy in its own commit: the new tests reference a `DesktopPlatform` constant first, and the eager value was then null through a class initialization cycle. + - Unpackaged runs register nothing and report false. + +5. **Common UI** + - `expect fun appLinkSchemeRegistered(): Boolean` in `platform/AppCommon.kt`, with Android actual `true` and desktop actual returning step 4's result. + - `BadgeStore.kt`: `badgePageUrl(linkReturns)` returns `app=true` or `app=desktop`; the button reads the flag once and passes it, so the URL and the fallback screen agree. + - `BadgesSupportSimplexView.kt` `BuyInBrowserButton`: open the Redeem code screen beside the browser only when not registered (replaces `appPlatform.isDesktop`), and update its comment. + +6. **Packaging** + - `desktop/build.gradle.kts`: add `CFBundleURLTypes` (`CFBundleURLName` `chat.simplex.app`, scheme `simplexchat`) to the existing `extraKeysRawXml`. + - `scripts/desktop/make-deb-linux.sh`: after `dpkg-deb -R`, sed `extracted/opt/*imple*/lib/*.desktop` so `Exec` ends in ` %u` and `MimeType=x-scheme-handler/simplexchat;`, and fail if the MimeType line is missing. This must run before the timestamp `touch`. + - `desktop/src/jvmMain/resources/distribute/SimpleX.desktop` (AppImage only): add the MimeType line and `%u` to Exec. In `scripts/desktop/make-appimage-linux.sh`, make the Exec sed replace only the program, so `%u` is kept. + - `scripts/flatpak/chat.simplex.simplex.desktop`: add the MimeType line. Confirm the Flathub manifest takes this file. + +7. **Window activation** (`WindowActivation.kt`, `DesktopApp.kt showWindow`): `showWindow` skips a disposed window (during a crash restart), sets `isVisible` immediately, and on Linux also sends `_NET_ACTIVE_WINDOW` (pager source) through JNA after syncing AWT's connection, because AWT's `toFront()` sends only `XRaiseWindow`, which Sway drops. + +8. **Docs** + - `apps/multiplatform/spec/client/navigation.md`: replace "Desktop registers no URL scheme" with the desktop paths. + - `apps/multiplatform/spec/architecture.md` and `spec/README.md`: the desktop `main()` steps, `showWindow()` and the line references they shift. + - `spec/impact.md` and the `CODE.md` Document Map: rows for the new desktop files, with `Main.kt` and `SingleInstance.kt` mapped to `navigation.md` as well; the Source Files tables in `navigation.md` and `architecture.md`; `product/concepts.md`: a row for `AppLinks.kt`; `product/gaps.md`: GAP-08 for the known registration gaps, marked `[GAP]` in `navigation.md`. + - `plans/2026-09-24-badge-buy-in-browser.md`: mark the desktop deep links section as done. + +## Commits + +1. `tests: close the walk stream in withTempDir` +2. `desktop: receive app links from args and macOS` +3. `desktop: forward an app link to the running app` +4. `desktop: initialize the desktop platform lazily` +5. `tests: share the temp directory helper` +6. `desktop: register simplexchat scheme in packages` +7. `desktop: register simplexchat scheme at runtime` +8. `ui: end the badge page with a link on desktop` +9. `desktop: raise the window over other apps` +10. `docs: describe desktop deep links in the spec` +11. `docs: plan desktop deep links` +12. `docs: report on desktop deep links` +13. `desktop: open simplex connection links` +14. `docs: describe simplex links on desktop` +15. `desktop: keep a link from opening twice` +16. `desktop: retry taking a signal held open` +17. `desktop: keep the user's AppImage link handler` +18. `desktop: register Windows links through Capabilities` +19. `desktop: add %u to the deb entry only once` +20. `docs: describe the review fixes` + +## Verification + +- **Unit tests** (`common/src/desktopTest`, beside `SingleInstanceTest.kt`): + - `linkFromArgs`: accepts `simplexchat:` in any case; rejects `https:`, empty args, a link that is not the only argument, and a link over the bound in bytes, including a multi-byte one within it in characters. + - Signal payload: link and empty round trip, taken once, replace of an untaken signal and of a taken file left by a crash, owner-only file on POSIX, a link of exactly the byte bound, rejected content including a multi-byte link over the bound, a temp file created beside the signal, a sweep that removes old temp files and a taken file but keeps a fresh temp file and the old lock and database files, keeps a signal within the 2 s tolerance and drops an older one, a watcher that takes a signal written before its watch, and successive signals arriving while it watches, each once, through a real `WatchService`. + - The Windows command string, the registry values and registration with a stand-in registry, the deb's registered check, the AppImage desktop entry for a path with spaces, Exec quoting that leaves a plain path bare and quotes and escapes reserved characters, its refusal of line breaks and `%`, `runProcess` output, discarded error output, an end of input and failure, and `desktopInstallation` precedence including Flatpak over AppImage, inherited AppImage variables, a mount path that only shares a prefix, and a mount reached through a symlink; the AppImage ownership check for world-writable and group-writable files and directories, a world-writable ancestor, another owner, a root-owned file, and a missing file; AppImage registration with a stand-in for `xdg-mime`: a new entry in a missing directory, an unchanged default, an unchanged entry that is no longer the default, a moved AppImage, a default that does not change, and a path the entry cannot run. + - `badgePageUrl` (in `common/src/commonTest`): `app=true` when the link returns, `app=desktop` otherwise. + - `activationEvent` and `sendActivation`: a `_NET_ACTIVE_WINDOW` client message for the window, with the pager source and `CurrentTime`, checked in native memory, sent to the root window through a stand-in for libX11 that also checks the display is closed. +- **Commands**: `./gradlew :common:desktopTest`, `./gradlew :desktop:compileKotlinJvm` and `./gradlew :common:compileDebugKotlinAndroid`, with no new warnings. +- **Manual matrix** on packaged builds. States: app closed, running, hidden in tray, locked with passcode, no active profile. + - Launch commands: `open 'simplexchat:/badge/code/'`, `start simplexchat:/badge/code/`, `xdg-open ...`. + - End to end through the page mock (`apps/simplex-badge-service/web/mock/server.py`, `#/tier?app=true`, settle via `/control/settle/`) in Chrome, Firefox and Safari. + - Linux: deb on GNOME and KDE, snap Firefox, Flatpak, AppImage. + - Check `lsregister -dump | grep simplexchat` on macOS and the HKCU key on Windows. + - Confirm that no log contains the code. +- **Unpackaged `./gradlew run`**: reports not registered and keeps D1. + +## Risks to confirm on hardware + +- How the Windows launcher re-quotes argv when it restarts itself. +- macOS cold start with Compose 1.8.2. +- Snap Firefox on Ubuntu opening custom schemes. +- GNOME Wayland raising the window (best effort). +- Stale HKCU keys and AppImage entries stay after uninstall or deletion; the plan accepts that. +- The deb's existing `Categories=Unknown`, which `desktop-file-validate` rejects, is out of scope and left as is. + +## `simplex:` connection links + +Android already opens `simplex:` links through `connectIfOpenedViaUri`, which sends them to `planAndConnect` (it asks before connecting). Desktop now receives them by the same paths as app links. + +- `isAcceptedLink` accepts `isAppLink` or `isConnectionLink` (`ChatListView.kt`). One bound covers both: `MAX_LINK_BYTES` is 8192, as a one-time link with its post-quantum key is about 2 KB and a second such key would bring it near 4 KB (the sntrup761 key alone is 1544 characters in base64url). The desktop names lose the `App` part: `linkFromArgs`, `openDesktopLink`, `registerLinkSchemes`. +- Every registration declares both schemes: the Info.plist, the deb, AppImage and Flatpak desktop entries, an HKCU key per scheme on Windows, and `xdg-mime default` per MIME type for an AppImage. +- `appLinkSchemeRegistered()` still reports `simplexchat:` only, as it decides how the badge page ends; `simplex:` is registered without being checked. +- Tests: a short link, a one-time link with its key, and the scheme in another case are accepted; another scheme sharing the prefix and a path are not; a forwarded one-time link round trips through the signal file; Windows and AppImage registration write both schemes, and only `simplexchat:` decides the result. + +## Review fixes + +- "Start anyway" after the not-responding alert withdraws the signal with `deleteIfExists`; when the running instance has already taken it, the second process exits, so the link opens once (`startDespiteRunningInstance`, extracted from `acquireSingleInstance` so it is tested). +- `takeSignal` retries a failed rename once after 100 ms, as another program (an antivirus scanner on Windows) can hold the file open for a moment. The move is a parameter, so tests make it fail. +- Windows registration compares and repairs every value, not only the command; reads a value of another type as missing instead of letting JNA's `RuntimeException` end the registration thread; and reports not registered when `UserChoice` names another ProgId (replaced later by asking the shell, below). +- Registration takes a scheme only when no handler is set or the current one is no longer installed: on Windows when the command's program file is missing, for an AppImage when the current default's desktop entry is in no applications directory. Another installation keeps it, and the badge page falls back to `app=desktop`. +- The deb's `Exec` sed adds `%u` only to a line without a field code. +- Windows: a test on Windows 11 showed that a scheme key alone loses to any app registered through `Capabilities`: links went to that app without asking, and default-app settings did not offer SimpleX. Each scheme now also gets a `SimpleX.` ProgId named in `HKCU\Software\chat.simplex.app\Capabilities\URLAssociations`, and the application is listed in `HKCU\Software\RegisteredApplications` last. `UserChoice` was compared with that ProgId until the shell check below replaced it. The scheme key stays, as it declares the URL scheme to Windows. +- Not changed: stale-signal detection still compares file times with the local clock (network file systems with clock skew above 2 s are an accepted edge case). +- A second review of the Windows change: Windows 11 can keep the user's pick in `UserChoiceLatest` and stop updating `UserChoice`, and with several capable apps and no pick Windows chooses or asks. So the registered check no longer reads either key: it asks the shell which program opens `simplexchat:` links (`AssocQueryStringW` with `ASSOCSTR_EXECUTABLE`) and compares it with this exe. This app's own ProgId is rewritten even while another program holds the scheme, so a pick in settings keeps working after a reinstall elsewhere; the "another program" check also reads `HKLM\Software\Classes`, which a per-user key would otherwise hide; the application is listed only when at least one scheme is this app's; and `SHChangeNotify(SHCNE_ASSOCCHANGED)` follows any write. Both functions are called through `NativeLibrary`, as jna-platform 5.14 binds neither. +- A third review: `SHChangeNotify` also clears Explorer's icon cache, so it is sent only after a write. Another installed copy's ProgId (its command names an existing exe other than this one) is kept like any other handler, so two installed copies do not take the user's pick from each other at every start. A command naming this exe with other arguments or letter case counts as this app's own and is updated. A ProgId that cannot be written keeps its scheme unregistered, so `URLAssociations` never names a missing ProgId. diff --git a/scripts/desktop/make-appimage-linux.sh b/scripts/desktop/make-appimage-linux.sh index 650c98d522..ae10710174 100755 --- a/scripts/desktop/make-appimage-linux.sh +++ b/scripts/desktop/make-appimage-linux.sh @@ -39,7 +39,7 @@ cp usr/lib/simplex.png usr/share/icons ln -s usr/bin/*imple* AppRun cp $multiplatform_dir/desktop/src/jvmMain/resources/distribute/*imple*.desktop chat.simplex.app.desktop -sed -i 's|Exec=.*|Exec=simplex|g' *imple*.desktop +sed -i 's|^Exec=[^ ]*|Exec=simplex|' *imple*.desktop sed -i 's|Icon=.*|Icon=simplex|g' *imple*.desktop cp *imple*.desktop usr/share/applications/ cp $multiplatform_dir/desktop/src/jvmMain/resources/distribute/*.appdata.xml usr/share/metainfo diff --git a/scripts/desktop/make-deb-linux.sh b/scripts/desktop/make-deb-linux.sh index 0fa543a81d..5231dea0dd 100755 --- a/scripts/desktop/make-deb-linux.sh +++ b/scripts/desktop/make-deb-linux.sh @@ -48,6 +48,10 @@ dpkg-deb -R ./release/main/deb/simplex*.deb ./extracted rm -f ./extracted/opt/*imple*/lib/app/*skiko-awt-runtime-linux* sed -i -e '/skiko-awt-runtime-linux/d' ./extracted/opt/*imple*/lib/app/simplex.cfg sed -i "/Version/ s/\$/~$VERSION_CODENAME/" ./extracted/DEBIAN/control +# jpackage's desktop entry passes no URL and handles no MIME type; this registers the simplexchat: and simplex: schemes +SCHEME_MIME_TYPE_LINE='MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;' +sed -i -e '/%[uU]/!s|^Exec=.*|& %u|' -e "s|^MimeType=.*|$SCHEME_MIME_TYPE_LINE|" ./extracted/opt/*imple*/lib/*.desktop +grep -qxF "$SCHEME_MIME_TYPE_LINE" ./extracted/opt/*imple*/lib/*.desktop || { echo "desktop entry does not register simplexchat: and simplex:" >&2; exit 1; } find ./extracted/ -exec touch -d "@$SOURCE_DATE_EPOCH" {} + dpkg-deb --build --root-owner-group --uniform-compression ./extracted ./release/main/deb/simplex_${ARCH}.deb diff --git a/scripts/flatpak/chat.simplex.simplex.desktop b/scripts/flatpak/chat.simplex.simplex.desktop index 4f4cd4eece..d18285b278 100644 --- a/scripts/flatpak/chat.simplex.simplex.desktop +++ b/scripts/flatpak/chat.simplex.simplex.desktop @@ -8,3 +8,4 @@ Categories=Utility;Chat;InstantMessaging; Exec=simplex %U Icon=chat.simplex.simplex StartupWMClass=simplex-chat +MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex; From 75e6fe8f5d4489d812afa49591725603c263c857 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:29:05 +0000 Subject: [PATCH 08/84] ios, android: tell the buyer a badge was added when the store purchase completes off screen (#7662) --- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- apps/ios/Shared/Views/Badges/BadgeStore.swift | 10 +++++++++- .../Views/Badges/BadgesRedeemCodeView.swift | 16 ++++++++++------ apps/ios/Shared/Views/Badges/BadgesView.swift | 4 ++++ apps/ios/spec/client/navigation.md | 2 +- .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- .../simplex/common/views/badges/BadgeStore.kt | 14 +++++++++++++- .../views/badges/BadgesRedeemCodeView.kt | 19 +++++++++++++------ .../simplex/common/views/badges/BadgesView.kt | 3 ++- .../common/views/chatlist/ChatListView.kt | 6 +++--- .../simplex/common/views/helpers/ModalView.kt | 3 ++- .../common/views/usersettings/SettingsView.kt | 2 +- apps/multiplatform/spec/client/navigation.md | 2 +- 13 files changed, 61 insertions(+), 24 deletions(-) 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) From b071285d15e4b5e0df3f08c3e63b82fb1769ab07 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:45:12 +0000 Subject: [PATCH 09/84] tests: drop store purchase tests covered by their neighbours, and pin the rules none covered (#7665) --- src/Simplex/Chat/Store/Badges.hs | 5 +- tests/Bots/BadgeService/BotTests.hs | 272 +++++++++------------------- 2 files changed, 86 insertions(+), 191 deletions(-) diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index 28db3e5d67..12a914d35f 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -125,8 +125,9 @@ data BadgeReceiptStatus -- | Resolves a store transaction to the record that owns it, looking up by its reference again after each step -- rather than trusting the write: finding none still leaves the echoed invoice's record to attach it to, and a -- concurrent hand-over of the same transaction may win either write. It stays with the record that already has --- it, whose keys the service may have credited; failing that it joins the record Buy created; and only when the --- store echoed no invoice does the presenting profile get a new one, nothing else saying who paid. +-- it, whose keys the service may have credited; failing that it joins the record Buy created; and when no record +-- can take it - the store echoed no invoice, or the one it echoed is already held by another transaction - the +-- presenting profile gets a new one, nothing else saying who paid. holdStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Maybe Text -> StoreTransactionRef -> ServicePayment -> UTCTime -> IO (Maybe BadgeReceiptRecord) holdStoreReceipt db g User {userId} invoiceId_ txRef@StoreTransactionRef {provider, transactionRef} payment now = getReceiptRecord db txRef >>= \case diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 13e90fe7e2..78d3faa9d4 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -18,7 +18,7 @@ import Bots.BadgeService.ConfigTests (withIssuer) import Bots.BadgeService.FakeStore import BadgeService.Options import BadgeService.Service -import BadgeService.Store (IssuedCode (..), NewStorePurchase (..), createStorePurchase, getBadgeCode) +import BadgeService.Store (IssuedCode (..), getBadgeCode) import BadgeService.Store.Invoices (markCodePaid) import BadgeService.StoreReceipts (StoreRefusal (..), StoreVerifier (..), noStoreVerifier) import Simplex.Messaging.Agent.Store.DB (Binary (..)) @@ -60,7 +60,7 @@ import Simplex.Chat.Core (sendChatCmdStr) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.PaymentService (ServicePayment (..)) -import Simplex.Chat.PaymentService.Types (InvoiceId (..), PaymentProvider (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId (..)) import Simplex.Chat.Store.Badges (getNextHeldStoreReceipt) import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) @@ -131,10 +131,8 @@ badgeServiceTests = do describe "store purchases" $ do describe "what the store answers" $ do it "should credit the months a store receipt paid for and issue the first" testStorePurchase - it "should return the same credential for a receipt its key presents again, writing nothing" testStorePurchaseReplay it "should refuse a receipt another key was credited with" testStoreReceiptUsed it "should refuse a receipt the store does not vouch for, with no retry" testStoreReceiptInvalid - it "should answer a store it cannot reach as retryable, writing nothing" testStoreUnreachable it "should write nothing for a pending purchase, and credit it once when it settles" testStorePending it "should refuse a product that grants no badge" testStoreUnknownProduct it "should refuse a store purchase that carries an upgrade" testStoreUpgradeRefused @@ -143,29 +141,25 @@ badgeServiceTests = do it "should replay a receipt to its own key while the store is down, and to no other" testStoreReplayWhileStoreDown it "should answer a throwing Apple verifier as internal, and a failing or hanging Google one as retryable" testStoreVerifierFailures describe "claim races" $ do - it "should credit a transaction claimed twice at once only once" testStoreClaimRace it "should answer a request that lost the claim race with the credential the winner was given" testStorePurchaseRace it "should refuse as receipt_used a key that lost the claim race to another key, crediting nothing" testStorePurchaseRaceOtherKey describe "the service's own checks" $ do it "should refuse a store with no verifier with no retry, and the client should hold the receipt, showing why it is not credited" testPurchaseWithNoVerifier it "should credit nothing when the verified transaction is not the one the evidence names" testStoreVerifiedOtherTransaction - it "should refuse a verified quantity other than one as internal, and the client should hold the receipt" testStoreQuantityRefused + it "should refuse a verified quantity other than one as internal, writing nothing" testStoreQuantityRefused it "should refuse a store purchase whose purchaseKey is not the verified signer" testStorePurchaseKeyMismatch + it "should refuse a Play product id it will not send to Play, without asking Play" testStoreUnsentProductId describe "the hand-over" $ do - it "should hold a Play receipt until the worker credits it, and answer it as credited after" testPurchaseBadge - it "should credit an App Store purchase by its JWS" testPurchaseBadgeAppStore - it "should keep a refusal on the record, and hold a pending receipt until it settles" testPurchaseStash + it "should hold a Play receipt until the worker credits it, and answer it as credited to its owner under any profile" testPurchaseBadge + it "should refuse an App Store receipt that names no transaction, recording nothing" testPurchaseNamesNoTransaction it "should keep a receipt credited to another key as refused" testPurchaseStashReceiptUsed it "should hold a Play token the service will not send to Play" testPurchaseUnsentPlayToken - it "should hold a store receipt while a badge is held, without attempting it" testPurchaseWhileBadgeHeld - it "should answer a receipt handed over under a second profile as the profile that bought it" testPurchaseSameReceiptOtherProfile it "should credit a purchase first handed over under another profile to that profile" testPurchaseStrandedUnderOtherProfile it "should credit a purchase to a hidden profile without naming it" testPurchaseDeliveredToHiddenProfile describe "invoice first" $ do - it "should credit a receipt to the profile that created its invoice, and answer as that profile" testInvoiceOtherProfile - it "should resolve the same receipt to the same record, and answer it as credited" testInvoiceSameReceiptTwice + it "should credit a receipt to the profile that created its invoice however late it arrives, and answer as that profile" testInvoiceOtherProfile it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown - it "should attach a late receipt to its record, and credit the profile that created it" testInvoiceLateReceipt + it "should record a receipt echoing an invoice another record holds by its transaction alone, and credit it" testInvoiceHeldByAnotherRecord it "should list a record however old until a receipt reaching it is settled" testInvoiceAgedOut it "should refuse an invoice with no service configured or while a badge is held, creating no record" testInvoiceRefusedBeforeCharge it "should hold a receipt for an invoice while a badge is held, without attempting it" testInvoiceWhileBadgeHeld @@ -178,6 +172,7 @@ badgeServiceTests = do it "should resolve one new transaction handed over twice at once to one record" testStoreReceiptTwoAtOnce it "should delete a month-old record that will fund no badge, and keep held and credited ones" testStoreReceiptCleanup it "should keep a receipt whose attempt throws" testStoreReceiptAttemptThrows + it "should credit a held receipt after the app restarts" testStoreReceiptAfterRestart badgeBotName :: Text badgeBotName = "SimpleX Badges" @@ -1708,19 +1703,6 @@ testStorePurchase ps = -- Play reports no price, and only the credit names the payment storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] -testStorePurchaseReplay :: HasCallStack => TestParams -> IO () -testStorePurchaseReplay ps = - withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do - (purchaseKey, masterKey) <- newPurchaseKeys - purchased <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay - ledger <- ledgerRows cc "sx_badge_service_badge_ledger" - replayed <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay - credentialOf replayed `shouldBe` credentialOf purchased - map entryTag (fst $ statementOf replayed) `shouldBe` map entryTag (fst $ statementOf purchased) - ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger - storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] - rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 - testStoreReceiptUsed :: HasCallStack => TestParams -> IO () testStoreReceiptUsed ps = withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do @@ -1740,18 +1722,11 @@ testStoreReceiptInvalid ps = let refused payment = refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey payment) refused (googlePayment "badge_supporter_01" "not-a-purchase") `shouldReturn` (BSEReceiptInvalid, Nothing) refused SPApple {jws = "not.a.jws"} `shouldReturn` (BSEReceiptInvalid, Nothing) + refused SPApple {jws = unsignedJWS "{\"transactionId\":\"2000000812345699\",\"productId\":\"BADGE_SUPPORTER_01\"}"} `shouldReturn` (BSEReceiptInvalid, Nothing) -- refused by the service, which the store would have vouched for refused SPApple {jws = appleSandboxJWS} `shouldReturn` (BSEReceiptInvalid, Nothing) nothingPurchased cc -testStoreUnreachable :: HasCallStack => TestParams -> IO () -testStoreUnreachable ps = - withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do - (purchaseKey, masterKey) <- newPurchaseKeys - unreachable <- serviceCmd env purchaseKey $ purchaseCmd masterKey $ googlePayment "badge_supporter_01" googleUnreachableToken - refusalOf unreachable `shouldSatisfy` \(code, retryAfter) -> code == BSEProviderUnavailable && isJust retryAfter - nothingPurchased cc - testStorePending :: HasCallStack => TestParams -> IO () testStorePending ps = withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = store} -> do @@ -1818,11 +1793,14 @@ testStoreReplayWhileStoreDown ps = -- the key it was credited to is answered from the record replayed <- serviceCmd env purchaseKey $ purchaseCmd masterKey supporterPlay credentialOf replayed `shouldBe` credentialOf purchased + map entryTag (fst $ statementOf replayed) `shouldBe` map entryTag (fst $ statementOf purchased) -- any other key waits for the store, or the answer would tell it which purchases were credited (otherKey, otherMasterKey) <- newPurchaseKeys other <- serviceCmd env otherKey $ purchaseCmd otherMasterKey supporterPlay fst (refusalOf other) `shouldBe` BSEProviderUnavailable ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` ledger + storePayments cc `shouldReturn` [("google", Nothing, Nothing, 1)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 testStoreVerifierFailures :: HasCallStack => TestParams -> IO () testStoreVerifierFailures ps = @@ -1844,16 +1822,10 @@ testStoreVerifiedOtherTransaction ps = testStoreQuantityRefused :: HasCallStack => TestParams -> IO () testStoreQuantityRefused ps = - withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClientCfg, bsController = cc, bsStore = FakeStore {appleQuantityJWS}} -> do + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleQuantityJWS}} -> do (purchaseKey, masterKey) <- newPurchaseKeys refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey SPApple {jws = appleQuantityJWS}) `shouldReturn` (BSEInternal, Nothing) nothingPurchased cc - withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleQuantityJWS}) - storePurchaseOpen alice "" - waitStoreReceiptError (chatController alice) "service_error final internal" - heldStoreReceipts (chatController alice) `shouldReturn` 1 - nothingPurchased cc testPurchaseWithNoVerifier :: HasCallStack => TestParams -> IO () testPurchaseWithNoVerifier ps = @@ -1865,26 +1837,11 @@ testPurchaseWithNoVerifier ps = alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) storePurchaseOpen alice "" -- not yet rather than never, so the receipt stays held for the retry once a verifier is deployed - waitStoreReceiptError (chatController alice) "service_error final provider_not_configured" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final provider_not_configured"] heldStoreReceipts (chatController alice) `shouldReturn` 1 alice ##> "/_badge state 1" alice .<## ", not credited: service_error final provider_not_configured" -testStoreClaimRace :: HasCallStack => TestParams -> IO () -testStoreClaimRace ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsController = cc} -> do - (firstKey, firstMasterKey) <- newPurchaseKeys - (otherKey, otherMasterKey) <- newPurchaseKeys - now <- getCurrentTime - let claim paymentId purchaseKey masterKey = - withDB' "claim" cc $ \db -> - createStorePurchase db NewStorePurchase {paymentId, provider = PPGoogle, providerRef = "ref", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter} now - -- the second insert meets the first's claim, as a request that read before the first wrote would - claim "p1" firstKey firstMasterKey >>= (`shouldSatisfy` either (const False) isJust) - claim "p2" otherKey otherMasterKey `shouldReturn` Right Nothing - rowCount cc "sx_badge_service_payments" `shouldReturn` 1 - rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 - testStorePurchaseRace :: HasCallStack => TestParams -> IO () testStorePurchaseRace ps = do hook <- newIORef (pure ()) @@ -1937,57 +1894,44 @@ testStorePurchaseKeyMismatch ps = alice <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}" nothingPurchased cc +testStoreUnsentProductId :: HasCallStack => TestParams -> IO () +testStoreUnsentProductId ps = do + hook <- newIORef (pure ()) + verifications <- countGoogleVerifications hook + withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \env@BadgeServiceEnv {bsController = cc} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey $ googlePayment "badge_supporter_01/../other" googleSupporterToken) `shouldReturn` (BSEReceiptInvalid, Nothing) + verifications `shouldReturn` 0 + nothingPurchased cc + testPurchaseBadge :: HasCallStack => TestParams -> IO () testPurchaseBadge ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do invoiceId <- createInvoice alice 1 - let purchase = purchaseWithInvoice 1 invoiceId supporterPlay - alice ##> purchase + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] + alice ##> "/create user alisa" + showActiveUser alice "alisa" -- the app hands a purchase over until it is answered credited, and one already credited adds nothing - alice ##> purchase - alice <## "badge already redeemed" - alice ##> "/p" - alice <## "user profile: alice (Alice, * supporter)" - alice <## "use /p [] to change it" + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay + alice <## "[user: alice] badge already redeemed" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 map (\(_, c, m, _, _, t) -> (c, m, t)) <$> ledgerRows (chatController alice) "badge_ledger" `shouldReturn` [(1, 1, Just "payment"), (-1, 0, Just "badge")] + alice ##> "/p" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice, * supporter)" -testPurchaseBadgeAppStore :: HasCallStack => TestParams -> IO () -testPurchaseBadgeAppStore ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc, bsStore = FakeStore {appleLegendJWS}} -> - withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - invoiceId <- createInvoice alice 1 - alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = appleLegendJWS} - inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: legend"] - storePayments cc `shouldReturn` [("apple", Just 7000, Just "USD", 1)] - -testPurchaseStash :: HasCallStack => TestParams -> IO () -testPurchaseStash ps = +testPurchaseNamesNoTransaction :: HasCallStack => TestParams -> IO () +testPurchaseNamesNoTransaction ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do invoiceId <- createInvoice alice 1 - let records = rowCount (chatController alice) "badge_store_receipts" - refused = purchaseWithInvoice 1 invoiceId (googlePayment "badge_supporter_01" "not-a-purchase") - -- the store does not vouch for it, so it can never be credited, and the record says so - alice ##> refused - alice <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final receipt_invalid")] - alice ##> refused - alice <## "cannot get badge: badge service error: receipt_invalid" - records `shouldReturn` 1 - heldStoreReceipts (chatController alice) `shouldReturn` 0 - -- no store transaction to key a record by, so nothing is held or sent - alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = "not.a-jws"}) + alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = "not.a-jws"} alice <## "cannot get badge: invalid store receipt" - records `shouldReturn` 1 - nothingPurchased cc - -- pending keeps the receipt held - alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googlePendingToken)) - storePurchaseOpen alice "" - waitStoreReceiptError (chatController alice) "service_error retry payment_pending" - records `shouldReturn` 2 - heldStoreReceipts (chatController alice) `shouldReturn` 1 + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), False)] nothingPurchased cc testPurchaseStashReceiptUsed :: HasCallStack => TestParams -> IO () @@ -2017,39 +1961,10 @@ testPurchaseUnsentPlayToken ps = withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "token/other")) storePurchaseOpen alice "" - waitStoreReceiptError (chatController alice) "service_error retry provider_unavailable" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry provider_unavailable"] heldStoreReceipts (chatController alice) `shouldReturn` 1 nothingPurchased cc -testPurchaseWhileBadgeHeld :: HasCallStack => TestParams -> IO () -testPurchaseWhileBadgeHeld ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> - withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - code <- issueCode cc BTSupporter 1 - redeemFirstBadge alice code - alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) - alice <##. "1: supporter" - storePurchaseOpen alice "" - storeReceiptDue (chatController alice) 1 `shouldReturn` False - heldStoreReceipts (chatController alice) `shouldReturn` 1 - rowCount cc "sx_badge_service_payments" `shouldReturn` 0 - -testPurchaseSameReceiptOtherProfile :: HasCallStack => TestParams -> IO () -testPurchaseSameReceiptOtherProfile ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> - withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - invoiceId <- createInvoice alice 1 - alice ##> purchaseWithInvoice 1 invoiceId supporterPlay - inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] - alice ##> "/create user alisa" - showActiveUser alice "alisa" - -- the store transaction is the device's, so it stays with the profile it was bought under - alice ##> purchaseWithInvoice 2 invoiceId supporterPlay - alice <## "[user: alice] badge already redeemed" - rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 - alice ##> "/p" - showActiveUser alice "alisa" - testPurchaseStrandedUnderOtherProfile :: HasCallStack => TestParams -> IO () testPurchaseStrandedUnderOtherProfile ps = do broken <- newIORef True @@ -2059,7 +1974,7 @@ testPurchaseStrandedUnderOtherProfile ps = do let purchase userId = purchaseWithInvoice userId invoiceId supporterPlay alice ##> purchase 1 alice <##. invoicePurchase "" invoiceId - waitStoreReceiptError (chatController alice) "service_error final internal" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] alice ##> "/create user alisa" showActiveUser alice "alisa" -- handed over again under whichever profile is active, the purchase stays with the keys alice holds @@ -2081,7 +1996,7 @@ testPurchaseDeliveredToHiddenProfile ps = do let purchase userId = "/_badge purchase " <> show (userId :: Int) <> " " <> paymentArg supporterPlay alice ##> purchase 1 storePurchaseOpen alice "" - waitStoreReceiptError (chatController alice) "service_error final internal" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> "/_hide user 1 \"password\"" @@ -2092,16 +2007,17 @@ testPurchaseDeliveredToHiddenProfile ps = do alice ##> purchase 2 (alice "/user alice password" showActiveUser alice "alice (Alice, * supporter)" testInvoiceOtherProfile :: HasCallStack => TestParams -> IO () testInvoiceOtherProfile ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock = clock} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do invoiceId <- createInvoice alice 1 + getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> purchaseWithInvoice 2 invoiceId supporterPlay @@ -2111,18 +2027,6 @@ testInvoiceOtherProfile ps = alice ##> "/user alice" showActiveUser alice "alice (Alice, * supporter)" -testInvoiceSameReceiptTwice :: HasCallStack => TestParams -> IO () -testInvoiceSameReceiptTwice ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> - withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - invoiceId <- createInvoice alice 1 - alice ##> purchaseWithInvoice 1 invoiceId supporterPlay - inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] - alice ##> purchaseWithInvoice 1 invoiceId supporterPlay - alice <## "badge already redeemed" - storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] - rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 - testInvoiceUnknown :: HasCallStack => TestParams -> IO () testInvoiceUnknown ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> @@ -2135,18 +2039,17 @@ testInvoiceUnknown ps = inAnyOrder alice [[invoicePurchase "" unknown], creditedLines "" unknown "1: supporter"] storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True)] -testInvoiceLateReceipt :: HasCallStack => TestParams -> IO () -testInvoiceLateReceipt ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock = clock} -> +testInvoiceHeldByAnotherRecord :: HasCallStack => TestParams -> IO () +testInvoiceHeldByAnotherRecord ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do invoiceId <- createInvoice alice 1 - getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) - alice ##> "/create user alisa" - showActiveUser alice "alisa" - alice ##> purchaseWithInvoice 2 invoiceId supporterPlay - inAnyOrder alice [[invoicePurchase "[user: alice] " invoiceId], creditedLines "[user: alice] " invoiceId "1: supporter"] - (alice purchaseWithInvoice 1 invoiceId (googlePayment "badge_supporter_01" "not-a-purchase") + alice <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final receipt_invalid")] + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + inAnyOrder alice [[openPurchase ""], ["1: supporter"]] + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True), (1, Nothing, True)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 testInvoiceAgedOut :: HasCallStack => TestParams -> IO () testInvoiceAgedOut ps = @@ -2165,7 +2068,7 @@ testInvoiceAgedOut ps = alice ##> purchaseWithInvoice 1 presented (googlePayment "badge_supporter_01" googlePendingToken) alice <## ("store purchase open: invoice " <> unpaid) alice <##. ("store purchase open: invoice " <> presented <> ", transaction ") - waitStoreReceiptError (chatController alice) "service_error retry payment_pending" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry payment_pending"] -- no age hides a record: one with no receipt and one held stay listed, and only a settled one goes getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) alice ##> "/_badge state 1" @@ -2226,7 +2129,7 @@ testStoreReceiptRetried ps = do invoiceId <- createInvoice alice 1 alice ##> purchaseWithInvoice 1 invoiceId supporterPlay alice <##. invoicePurchase "" invoiceId - waitStoreReceiptError (chatController alice) "service_error final internal" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] writeIORef broken False mapM_ (alice <##.) $ creditedLines "" invoiceId "1: supporter" rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 @@ -2238,7 +2141,7 @@ testStoreReceiptNotBlocked ps = setGoogleDown store True alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) storePurchaseOpen alice "" - waitStoreReceiptError (chatController alice) "service_error retry provider_unavailable" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry provider_unavailable"] invoiceId <- createInvoice alice 1 alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = appleSupporterJWS} inAnyOrder alice [[openPurchase "", invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] @@ -2254,7 +2157,7 @@ testStoreRefusalKept ps = do alice ##> refused storePurchaseOpen alice "" -- with no invoice id the app has no key to the store transaction, so the refusal is not announced - waitHeldStoreReceipts (chatController alice) 0 + heldStoreReceipts (chatController alice) `shouldEventuallyReturn` 0 (alice refused alice <## "cannot get badge: badge service error: receipt_invalid" @@ -2284,7 +2187,7 @@ testStoreSettledOtherProfile ps = alice <## "profile is hidden" alice ##> purchaseWithInvoice 2 hidden (googlePayment "badge_supporter_01" "not-a-purchase-either") (alice ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase")) alice <## ("store purchase open: invoice " <> unpaid) storePurchaseOpen alice "" - waitHeldStoreReceipts (chatController alice) 0 + heldStoreReceipts (chatController alice) `shouldEventuallyReturn` 0 alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) alice <### [ConsoleString ("store purchase open: invoice " <> unpaid), StartsWith (openPurchase ""), StartsWith "1: supporter"] alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googleUnreachableToken)) @@ -2328,14 +2231,8 @@ testStoreReceiptCleanup ps = StartsWith "1: supporter", StartsWith "badge alert: support_ended " ] - waitStoreReceiptRows (chatController alice) 2 `shouldReturn` [(1, Nothing, True), (1, Nothing, True)] + storeReceiptRows (chatController alice) `shouldEventuallyReturn` [(1, Nothing, True), (1, Nothing, True)] heldStoreReceipts (chatController alice) `shouldReturn` 1 - -- a receipt for the deleted invoice now arrives with no record to name its profile, so the presenting one gets it - alice ##> "/create user alisa" - showActiveUser alice "alisa" - alice ##> purchaseWithInvoice 2 unpaid (googlePayment "badge_supporter_01" googlePendingToken) - alice <##. ("store purchase open: invoice " <> unpaid <> ", transaction ") - waitStoreReceiptRows (chatController alice) 3 >>= (`shouldContain` [(2, Just (T.pack unpaid), True)]) testStoreReceiptAttemptThrows :: HasCallStack => TestParams -> IO () testStoreReceiptAttemptThrows ps = do @@ -2344,13 +2241,29 @@ testStoreReceiptAttemptThrows ps = do withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) storePurchaseOpen alice "" - waitStoreReceiptError (chatController alice) "service_error final internal" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] -- a stored payment this version cannot read makes the attempt throw before anything is sent setStoreReceiptPayment (chatController alice) "not a payment" - waitStoreReceiptError (chatController alice) "unexpected held store payment does not decode" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["unexpected held store payment does not decode"] (alice TestParams -> IO () +testStoreReceiptAfterRestart ps = do + broken <- newIORef True + withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg} -> do + let cfg = fastRetryCfg bsClientCfg + invoiceId <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <##. invoicePurchase "" invoiceId + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error final internal"] + pure invoiceId + writeIORef broken False + withTestChatCfg ps cfg "alice" $ \alice -> do + inAnyOrder alice [["subscribed 1 connections on server localhost"], creditedLines "" invoiceId "1: supporter"] + heldStoreReceipts (chatController alice) `shouldReturn` 0 + createInvoice :: HasCallStack => TestCC -> Int -> IO String createInvoice cc userId = do cc ##> ("/_badge invoice " <> show userId) @@ -2368,15 +2281,6 @@ storeReceiptRows ChatController {chatStore} = "SELECT user_id, invoice_id, transaction_ref IS NOT NULL " <> "FROM badge_store_receipts ORDER BY badge_store_receipt_id" --- The store receipt waits poll for 20 seconds, as long as getTermLine waits, since a parallel run can delay the worker that long. -waitStoreReceiptRows :: HasCallStack => ChatController -> Int -> IO [(Int64, Maybe Text, Bool)] -waitStoreReceiptRows cc n = loop (400 :: Int) - where - loop 0 = storeReceiptRows cc >>= \rows -> error $ "expected " <> show n <> " store purchase records, got " <> show rows - loop i = do - rows <- storeReceiptRows cc - if length rows == n then pure rows else threadDelay 50000 >> loop (i - 1) - storeReceiptDue :: ChatController -> Int64 -> IO Bool storeReceiptDue ChatController {chatStore} userId = withTransaction chatStore $ \db -> @@ -2388,20 +2292,10 @@ heldStoreReceipts ChatController {chatStore} = [Only n] <- DB.query_ db "SELECT COUNT(*) FROM badge_store_receipts WHERE payment IS NOT NULL" pure n -waitHeldStoreReceipts :: HasCallStack => ChatController -> Int -> IO () -waitHeldStoreReceipts cc n = loop (400 :: Int) - where - loop 0 = heldStoreReceipts cc >>= (`shouldBe` n) - loop i = heldStoreReceipts cc >>= \held -> if held == n then pure () else threadDelay 50000 >> loop (i - 1) - -waitStoreReceiptError :: HasCallStack => ChatController -> Text -> IO () -waitStoreReceiptError ChatController {chatStore} failure = loop (400 :: Int) - where - loop 0 = heldFailures >>= (`shouldContain` [failure]) - loop i = heldFailures >>= \failures -> if failure `elem` failures then pure () else threadDelay 50000 >> loop (i - 1) - heldFailures = - withTransaction chatStore $ \db -> - map fromOnly <$> DB.query_ db "SELECT credit_error FROM badge_store_receipts WHERE payment IS NOT NULL AND credit_error IS NOT NULL" +heldStoreReceiptErrors :: ChatController -> IO [Text] +heldStoreReceiptErrors ChatController {chatStore} = + withTransaction chatStore $ \db -> + map fromOnly <$> DB.query_ db "SELECT credit_error FROM badge_store_receipts WHERE payment IS NOT NULL AND credit_error IS NOT NULL" setStoreReceiptPayment :: ChatController -> Text -> IO () setStoreReceiptPayment ChatController {chatStore} payment = From cf3d6c45d0a964198cc334e99a20e7e5d02cba5b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:59:39 +0000 Subject: [PATCH 10/84] ios, android: tell the buyer once a run when a swept purchase cannot be handed over (#7674) --- apps/ios/Shared/Views/Badges/BadgeStore.swift | 12 +++++++++++- .../chat/simplex/common/views/badges/BadgeStore.kt | 14 +++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 8194abcc46..2b265b01ab 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -111,6 +111,8 @@ final class BadgeStore: ObservableObject { // set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was // closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again @Published private var reconciledOnce = false + // the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run + private var sweepFailureShown = false private var transactionUpdates: Task? = nil private init() {} @@ -321,9 +323,17 @@ final class BadgeStore: ObservableObject { if !badgeOneTimeProductIds.contains(receipt.productId) { await receipt.transaction.finish() } else if receipt.signatureVerified { - // no buyer to tell: the receipt stays unfinished and the next sweep hands it over again + // the receipt stays unfinished, so the next sweep hands it over again do { try await handOver(receipt) } catch let error { logger.error("BadgeStore.reconcile: \(responseError(error))") + await MainActor.run { + // showAlert presents on the top view controller, which the launch sweep can precede; + // an alert with nowhere to go must not spend the one this run gets + if !sweepFailureShown, getTopViewController() != nil { + sweepFailureShown = true + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(error, purchase: true)) + } + } } } } 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 3b82ac1371..c9b413221d 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 @@ -161,6 +161,8 @@ object BadgeStore { // set once presentUnfinished has read the store, or failed to: until then, a slow payment completed while // the app was closed, or a purchase it died before handing over, are both unknown, so canBuy refuses private val reconciledOnce = mutableStateOf(false) + // the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run + private var sweepFailureShown = false fun purchaseState(userId: Long?): BadgePurchaseState? { if (!badgeStoreAvailable) return null @@ -372,12 +374,22 @@ object BadgeStore { when (outcome) { is BadgePurchaseOutcome.Purchased -> if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt) - // no buyer to tell: the purchase stays unfinished and the next sweep hands it over again + // the purchase stays unfinished, so the next sweep hands it over again else try { handOver(outcome.receipt) } catch (e: Exception) { if (e is CancellationException) throw e Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}") + withContext(Dispatchers.Main) { + if (!sweepFailureShown) { + sweepFailureShown = true + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) + else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + ) + } + } } is BadgePurchaseOutcome.Pending -> if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } From 5cddbffaa1846c1a8a36cc5fe230085dbcb87ae8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:18:09 +0400 Subject: [PATCH 11/84] core: update query plans --- src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index a30f42369a..38fbea289a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4124,7 +4124,7 @@ Plan: SEARCH file_badge_proofs USING INDEX idx_file_badge_proofs_file_id_kind (file_id=?) Query: - SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.payment, r.next_attempt_at, r.retry_delay + SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay FROM badge_store_receipts r JOIN users u ON u.user_id = r.user_id WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL @@ -4138,7 +4138,7 @@ USE TEMP B-TREE FOR ORDER BY Query: SELECT r.badge_store_receipt_id, r.user_id, r.purchase_key, r.purchase_priv_key, r.master_key, - r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error + r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error FROM badge_store_receipts r LEFT JOIN badge_purchases p ON p.badge_store_receipt_id = r.badge_store_receipt_id WHERE r.provider = ? AND r.transaction_ref = ? From eadebccb4f68bffc492a5e1f4076c9d9951b4082 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 Oct 2026 22:59:42 +0100 Subject: [PATCH 12/84] nodejs: send error events to bot (#7691) * nodejs: send error events to bot * version --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> From 4b6ac88c6e808f5d22fe5fcb6937b55d91b2bc98 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 13 Oct 2026 08:40:32 +0100 Subject: [PATCH 13/84] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ccbdaadc5c..a138b24e4e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -193,8 +193,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -586,8 +586,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -760,8 +760,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -848,8 +848,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */, ); path = Libraries; sourceTree = ""; From d673a6b02136018e49f0796da74e089ad49a0446 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 08:42:59 +0100 Subject: [PATCH 14/84] ui: show unicode characters in address as unicode markers (#7698) * ui: show unicode characters in address as unicode markers * defence in depth for links in messages (they are non-clickable from parser) --------- Current code does not have this problem, as markdown parser rejects such links, and they are not clickable - it is needed for new markdown file viewer - this way unicode links in markdown show with unicode sequences instead of look-alike letters. --- .../Views/Chat/ChatItem/CILinkView.swift | 26 ++++++++++++++++--- .../Views/Chat/ChatItem/MsgContentView.swift | 1 + .../ios/Shared/Views/Helpers/ShareSheet.swift | 2 +- .../common/views/chat/item/TextItemView.kt | 12 ++++++--- .../simplex/common/views/helpers/Utils.kt | 18 +++++++++++-- 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 4eb187bcac..3433115ceb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -52,7 +52,7 @@ struct CILinkView: View { func openBrowserAlert(uri: String) { let (url, err) = sanitizeUri(uri) if let url { - let uriStr = url.uri.absoluteString + let uriStr = displayedUri(url.uri.absoluteString) showAlert( NSLocalizedString("Open link?", comment: "alert title"), message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr, @@ -90,9 +90,9 @@ func openBrowserAlert(uri: String) { func showInvalidLinkAlert(_ uri: String, error: String? = nil) { let message = if let error, !error.isEmpty { - error + "\n" + uri + error + "\n" + displayedUri(uri) } else { - uri + displayedUri(uri) } showAlert( NSLocalizedString("Invalid link", comment: "alert title"), @@ -101,6 +101,26 @@ func showInvalidLinkAlert(_ uri: String, error: String? = nil) { ) } +func unicodeMarker(_ s: Unicode.Scalar) -> String { + String(format: "[U+%04X]", s.value) +} + +func displayedUri(_ uri: String) -> String { + let authority = uri.range(of: "://").map { scheme in + scheme.upperBound ..< (uri[scheme.upperBound...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex) + } + var shown = "" + for i in uri.unicodeScalars.indices { + let s = uri.unicodeScalars[i] + if s.properties.generalCategory == .format || (!s.isASCII && authority?.contains(i) == true) { + shown += unicodeMarker(s) + } else { + shown.unicodeScalars.append(s) + } + } + return shown +} + func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) { let parsed = parseSanitizeUri(s, safe: false) return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index c9d8858fa1..f95e31369e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -490,6 +490,7 @@ func messageText( attrs[webLinkAttrKey] = true handleTaps = true } + t = displayedUri(t) case let .hyperLink(text, uri): attrs = linkAttrs() if let text { t = text } diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 56e437e5c8..ebe74b2a50 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -98,7 +98,7 @@ func openExternalLink(_ url: URL) { } else { showAlert( title: NSLocalizedString("Open external link?", comment: "alert title"), - message: s, + message: displayedUri(s), buttonTitle: NSLocalizedString("Open", comment: "alert button"), buttonAction: { UIApplication.shared.open(url) }, cancelButton: true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index da64862c03..c198451464 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -96,6 +96,7 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin } else if (mention != null) mentionText(ft.format.memberName) else ft.text } + is Format.Uri -> displayedUri(ft.text) is Format.HyperLink -> ft.format.showText ?: ft.text is Format.SimplexLink -> { val t = ft.format.showText @@ -283,7 +284,7 @@ fun MarkdownText ( val s = ft.text val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s" withAnnotation(tag = "WEB_URL", annotation = link) { - withStyle(ftStyle) { append(ft.text) } + withStyle(ftStyle) { append(displayedUri(ft.text)) } } } is Format.HyperLink -> { @@ -535,19 +536,22 @@ fun openBrowserAlert(uri: String, uriHandler: UriHandler) { if (res == null) { showInvalidLinkAlert(uri, err) } else { - val message = if (uri.count() > 160) uri.substring(0, 159) + "…" else uri + val shownUri = displayedUri(uri) + val message = if (shownUri.count() > 160) shownUri.substring(0, 159) + "…" else shownUri val sanitizedUri = res.second if (sanitizedUri == null) { AlertManager.shared.showAlertDialog( generalGetString(MR.strings.privacy_chat_list_open_web_link_question), message, confirmText = generalGetString(MR.strings.open_verb), - onConfirm = { safeOpenUri(uri, uriHandler) } + onConfirm = { safeOpenUri(uri, uriHandler) }, + parseHtml = false ) } else { AlertManager.shared.showAlertDialogButtonsColumn( generalGetString(MR.strings.privacy_chat_list_open_web_link_question), message, + parseHtml = false, buttons = { Column { SectionItemView({ @@ -585,7 +589,7 @@ fun safeOpenUri(uri: String, uriHandler: UriHandler) { } fun showInvalidLinkAlert(uri: String, error: String? = null) { - val message = if (error.isNullOrEmpty()) { uri } else { error + "\n" + uri } + val message = if (error.isNullOrEmpty()) { displayedUri(uri) } else { error + "\n" + displayedUri(uri) } AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), message) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index dfe33e3595..6b69a3820f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -617,13 +617,27 @@ fun UriHandler.openExternalLink(uri: String) { } else { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.open_external_link_title), - text = uri, + text = displayedUri(uri), confirmText = generalGetString(MR.strings.open_verb), - onConfirm = { uriHandler.openUriCatching(uri) } + onConfirm = { uriHandler.openUriCatching(uri) }, + parseHtml = false ) } } +val invisibleChar = Regex("\\p{Cf}") + +private val nonAsciiChar = Regex("[^\\x00-\\x7F]") + +fun unicodeMarker(c: String): String = "[U+" + Integer.toHexString(c.codePointAt(0)).uppercase().padStart(4, '0') + "]" + +fun displayedUri(uri: String): String { + val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: uri.length + val authorityEnd = uri.indexOfAny(charArrayOf('/', '?', '#'), authorityStart).takeIf { it >= 0 } ?: uri.length + val authority = uri.substring(authorityStart, authorityEnd).replace(nonAsciiChar) { unicodeMarker(it.value) } + return uri.replaceRange(authorityStart, authorityEnd, authority).replace(invisibleChar) { unicodeMarker(it.value) } +} + fun IntSize.Companion.Saver(): Saver = Saver( save = { it.width to it.height }, restore = { IntSize(it.first, it.second) } From 1bd7c95006a82a92e8d19d233d0d37e72ae8ba24 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 08:53:04 +0100 Subject: [PATCH 15/84] ui: additional check for plain domains (#7699) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift | 7 +++---- .../kotlin/chat/simplex/common/views/helpers/Utils.kt | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 3433115ceb..42259361b7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -106,13 +106,12 @@ func unicodeMarker(_ s: Unicode.Scalar) -> String { } func displayedUri(_ uri: String) -> String { - let authority = uri.range(of: "://").map { scheme in - scheme.upperBound ..< (uri[scheme.upperBound...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex) - } + let authorityStart = uri.range(of: "://")?.upperBound ?? uri.startIndex + let authority = authorityStart ..< (uri[authorityStart...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex) var shown = "" for i in uri.unicodeScalars.indices { let s = uri.unicodeScalars[i] - if s.properties.generalCategory == .format || (!s.isASCII && authority?.contains(i) == true) { + if s.properties.generalCategory == .format || (!s.isASCII && authority.contains(i)) { shown += unicodeMarker(s) } else { shown.unicodeScalars.append(s) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 6b69a3820f..81a64cb527 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -632,7 +632,7 @@ private val nonAsciiChar = Regex("[^\\x00-\\x7F]") fun unicodeMarker(c: String): String = "[U+" + Integer.toHexString(c.codePointAt(0)).uppercase().padStart(4, '0') + "]" fun displayedUri(uri: String): String { - val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: uri.length + val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: 0 val authorityEnd = uri.indexOfAny(charArrayOf('/', '?', '#'), authorityStart).takeIf { it >= 0 } ?: uri.length val authority = uri.substring(authorityStart, authorityEnd).replace(nonAsciiChar) { unicodeMarker(it.value) } return uri.replaceRange(authorityStart, authorityEnd, authority).replace(invisibleChar) { unicodeMarker(it.value) } From 7d870c6ff515d97ed93b81d2964ffd9f310d82f4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 11:24:14 +0100 Subject: [PATCH 16/84] ui: open file after tap when it is received (#7700) * ui: open file after tap when it is received * fix ios * simplify * simplify * better --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 17 +++++ apps/ios/Shared/Model/SimpleXAPI.swift | 3 + .../Views/Chat/ChatItem/CIFileView.swift | 7 +- .../Views/Chat/ChatItem/CIImageView.swift | 1 + .../Views/Chat/ChatItem/CIVideoView.swift | 20 ++++++ .../Views/Chat/ChatItem/CIVoiceView.swift | 5 ++ .../chat/simplex/common/model/ChatModel.kt | 15 +++++ .../common/views/chat/item/CIFileView.kt | 24 +++++-- .../common/views/chat/item/CIImageView.kt | 10 +++ .../common/views/chat/item/CIVideoView.kt | 66 +++++++++++-------- .../common/views/chat/item/CIVoiceView.kt | 3 +- 11 files changed, 132 insertions(+), 39 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 7c1e8e65ab..1ee79e1c4f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -474,10 +474,27 @@ final class ChatModel: ObservableObject { // another in its place; only the badge alert shows regardless. Set while rendering, so not published. var chatListBanner: ChatListBanner? + var fileToOpen: (fileId: Int64, chatId: String?, open: (CIFile) -> Void)? + func bannerSlotFree(for banner: ChatListBanner) -> Bool { chatListBanner == nil || chatListBanner == banner } + func openWhenReceived(_ file: CIFile, open: @escaping (CIFile) -> Void) { + fileToOpen = (fileId: file.fileId, chatId: chatId, open: open) + } + + func receivedFileToOpen(_ file: CIFile) -> Bool { + if let f = fileToOpen { f.fileId == file.fileId && f.chatId == chatId } else { false } + } + + func openReceivedFile(_ file: CIFile) { + if let f = fileToOpen, receivedFileToOpen(file) { + fileToOpen = nil + f.open(file) + } + } + static let shared = ChatModel() let im = ItemsModel.shared diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d04ccf5b03..3ec9c34f8e 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2873,6 +2873,9 @@ func processReceivedMsg(_ res: ChatEvent) async { await chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): await chatItemSimpleUpdate(user, aChatItem) + if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) { + await MainActor.run { m.openReceivedFile(file) } + } case let .rcvFileSndCancelled(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 8bb76ca155..59ab1c24e0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -26,7 +26,7 @@ struct CIFileView: View { var body: some View { if smallViewSize != nil { fileIndicator() - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) } else { // reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil } @@ -56,7 +56,7 @@ struct CIFileView: View { .padding(.bottom, 6) .padding(.leading, 10) .padding(.trailing, 12) - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) .disabled(!itemInteractive) } } @@ -85,7 +85,7 @@ struct CIFileView: View { return false } - private func fileAction() { + private func fileAction(_ file: CIFile?) { logger.debug("CIFileView fileAction") if let file = file { switch (file.fileStatus) { @@ -93,6 +93,7 @@ struct CIFileView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { fileAction($0) } Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index fb6ccb1ea3..469fc5a3fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -54,6 +54,7 @@ struct CIImageView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { _ in showFullScreenImage = true } Task { if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 0df17dfe3d..339d119362 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -120,6 +120,25 @@ struct CIVideoView: View { } } + private func playReceived(_ file: CIFile) { + setupPlayer(file) + if urlDecrypted == nil { + decrypt(file: file, completed: playVideo) + } else { + playVideo() + } + } + + private func playVideo() { + if smallView { + showFullScreenPlayer = urlDecrypted != nil + } else if let urlDecrypted { + m.stopPreviousRecPlay = urlDecrypted + videoPlaying = true + player?.play() + } + } + private func setupPlayer(_ file: CIFile?) { let newUrl = getLoadedVideo(file) if newUrl == url { @@ -424,6 +443,7 @@ struct CIVideoView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file, open: playReceived) Task { if let user = m.currentUser { await receiveFile(user, file.fileId, false, false) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 64fa2a4dd3..6c89a5f788 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -303,6 +303,11 @@ struct VoiceMessagePlayer: View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { playPauseIcon(icon, theme.colors.primary) .simultaneousGesture(TapGesture().onEnded { + chatModel.openWhenReceived(recordingFile) { file in + if let recordingSource = getLoadedFileSource(file) { + startPlayback(recordingSource) + } + } Task { if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index c33a077ae9..1afea4541b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -132,6 +132,8 @@ object BadgeModel { enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake } +data class FileToOpen(val fileId: Long, val inChatList: Boolean) + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -205,8 +207,21 @@ object ChatModel { // another in its place; only the badge alert shows regardless. Set while rendering, so not a state. var chatListBanner: ChatListBanner? = null + var fileToOpen: FileToOpen? = null + fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner + fun openWhenReceived(file: CIFile, inChatList: Boolean) { + fileToOpen = FileToOpen(file.fileId, inChatList) + } + + fun receivedFileToOpen(file: CIFile, inChatList: Boolean): Boolean { + val f = fileToOpen + if (f == null || f.fileId != file.fileId || f.inChatList != inChatList || !file.loaded) return false + fileToOpen = null + return !inChatList || appPlatform.isDesktop || chatId.value == null + } + // preferences val notificationPreviewMode by lazy { mutableStateOf( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index a3c2b63f8f..1523e2ff22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -80,6 +80,7 @@ fun CIFileView( if (prohibited != null) { showProhibitedFileAlert(file, prohibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } @@ -188,16 +189,18 @@ fun CIFileView( val encrypted = file.fileSource.cryptoArgs != null SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) } + val onClick: () -> Unit = { + if (appPlatform.isAndroid && loadedFilePath != null) { + showOpenSaveMenu.value = true + } else { + fileAction() + } + } + if (file != null) OpenWhenReceivedEffect(file, smallView, onClick) Row( Modifier .combinedClickable( - onClick = { - if (appPlatform.isAndroid && loadedFilePath != null) { - showOpenSaveMenu.value = true - } else { - fileAction() - } - }, + onClick = onClick, onLongClick = { showMenu.value = true } ) .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), @@ -239,6 +242,13 @@ fun CIFileView( // the core decides whether a received file is above the size the sender's badge allows fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null +@Composable +fun OpenWhenReceivedEffect(file: CIFile, inChatList: Boolean, open: () -> Unit) { + KeyChangeEffect(file.loaded) { + if (chatModel.receivedFileToOpen(file, inChatList)) open() + } +} + fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) { val badgeIssue = when (prohibited.badgeStatus) { null, BadgeStatus.Active -> "" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index d5ea44126b..a49884beeb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -173,6 +173,15 @@ fun CIImageView( return null } + val view = LocalMultiplatformView() + if (file != null) { + OpenWhenReceivedEffect(file, smallView) { + hideKeyboard(view) + ModalManager.fullscreen.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + } + } Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) .then( @@ -222,6 +231,7 @@ fun CIImageView( if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 21f406db18..62e1b9f737 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -70,6 +70,8 @@ fun CIVideoView( } } val f = filePath.value + val autoPlay = remember { mutableStateOf(false) } + if (file != null) OpenWhenReceivedEffect(file, smallView) { autoPlay.value = true } if (file != null && f != null) { val view = LocalMultiplatformView() val openFullscreen = { @@ -80,7 +82,6 @@ fun CIVideoView( } val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } - val autoPlay = remember { mutableStateOf(false) } val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) } val decrypted = uriDecrypted.value if (decrypted != null && smallView) { @@ -98,7 +99,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, smallView, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -128,7 +129,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, smallView, receiveFile) } } } } @@ -156,22 +157,26 @@ private fun VideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = false, blurred = blurred, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(1f, onLongClick = onLongClick) } else if (!blurred.value) { - PlayButton(false, 1f, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, 1f, onLongClick = onLongClick, onClick = play) } DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(defaultDuration) }, remember { mutableStateOf(0L) }) } @@ -190,22 +195,26 @@ private fun SmallVideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) } else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) { - PlayButton(false, sizeMultiplier, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, sizeMultiplier, onLongClick = onLongClick, onClick = play) } } } @@ -240,7 +249,7 @@ private fun SmallVideoView( PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) openFullscreen() } } @@ -272,7 +281,7 @@ private fun VideoView( player.stop() } val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) play() } // Drop autoPlay only when show preview changes to prevent blinking of the view @@ -566,10 +575,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { +private fun receiveFileIfValidSize(file: CIFile, smallView: Boolean, receiveFile: (Long) -> Unit) { if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 3b3c835e0a..5aeefd9668 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -87,7 +87,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, receiveFile) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } if (smallView) { @@ -95,6 +95,7 @@ fun CIVoiceView( AudioPlayer.stop() } } + OpenWhenReceivedEffect(file, smallView, play) } else if (smallView) { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, sizeMultiplier, {}, {}, longClick, receiveFile) } else { From 2f4a443dda2600278446fd082f73275de2f13ad1 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 14:38:37 +0100 Subject: [PATCH 17/84] ui: markdown viewer (#7702) * ui: markdown viewer * simplify * fixes * plan * ios sheet, center modal on desktop --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/ChatItem/CIFileView.swift | 14 +- .../Chat/ChatItem/MarkdownFileView.swift | 215 ++++++++++++ .../NetworkAndServers/ConditionsWebView.swift | 37 +- .../Views/UserSettings/SettingsView.swift | 2 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/spec/client/chat-view.md | 6 + apps/multiplatform/common/build.gradle.kts | 4 +- .../simplex/common/platform/Images.android.kt | 9 + .../common/views/helpers/Utils.android.kt | 3 +- .../kotlin/chat/simplex/common/App.kt | 5 +- .../chat/simplex/common/model/SimpleXAPI.kt | 2 + .../chat/simplex/common/platform/Images.kt | 1 + .../common/views/chat/item/CIFileView.kt | 4 +- .../views/chat/item/MarkdownFileView.kt | 284 ++++++++++++++++ .../views/chatlist/ChatListNavLinkView.kt | 2 +- .../simplex/common/views/helpers/ModalView.kt | 5 +- .../networkAndServers/OperatorView.kt | 33 +- .../moko-resources/base/strings.xml | 3 + .../simplex/common/platform/Images.desktop.kt | 9 + .../views/chat/item/CIImageView.desktop.kt | 2 +- apps/multiplatform/product/README.md | 4 +- apps/multiplatform/spec/client/chat-view.md | 1 + apps/multiplatform/spec/client/navigation.md | 6 +- plans/2026-10-11-markdown-file-viewer.md | 319 ++++++++++++++++++ 24 files changed, 941 insertions(+), 33 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt create mode 100644 plans/2026-10-11-markdown-file-viewer.md diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 59ab1c24e0..5c65a310b5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -118,7 +118,7 @@ struct CIFileView: View { case .rcvComplete: logger.debug("CIFileView fileAction - in .rcvComplete") if let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") @@ -129,12 +129,12 @@ struct CIFileView: View { case .sndStored: logger.debug("CIFileView fileAction - in .sndStored") if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case .sndComplete: logger.debug("CIFileView fileAction - in .sndComplete") if let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case let .sndError(sndFileError): logger.debug("CIFileView fileAction - in .sndError") @@ -147,6 +147,14 @@ struct CIFileView: View { } } + private func openFile(_ file: CIFile, _ fileSource: CryptoFile) { + if isMarkdownFile(file) { + showMarkdownFile(file) + } else { + saveCryptoFile(fileSource) + } + } + @ViewBuilder private func fileIndicator() -> some View { if let file = file { switch file.fileStatus { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift new file mode 100644 index 0000000000..aa7e827d6b --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift @@ -0,0 +1,215 @@ +import SwiftUI +import SimpleXChat +import Ink + +private let MAX_MARKDOWN_VIEW_SIZE: Int64 = 1_048_576 +private let MAX_MARKDOWN_LINE_LENGTH = 15_000 +private let MAX_MARKDOWN_NESTING = 16 +private let MAX_MARKDOWN_LINK_NESTING = 4 +private let MAX_MARKDOWN_TABLE_ROWS = 200 +private let MAX_MARKDOWN_IMAGE_DIMENSION: CGFloat = 4320 +private let MAX_MARKDOWN_IMAGE_PIXELS: CGFloat = 2160 * 4320 + +func isMarkdownFile(_ file: CIFile) -> Bool { + let ext = (file.fileName as NSString).pathExtension.lowercased() + return file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && (ext == "md" || ext == "markdown") +} + +func showMarkdownFile(_ file: CIFile) { + guard let fileSource = getLoadedFileSource(file) else { return } + Task { + do { + let data = try getFileData(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs) + let text = decodedText(data) + let lines = text.components(separatedBy: "\n") + let html = withinMarkdownLimits(lines) + ? MarkdownParser(modifiers: [Modifier(target: .html) { escapedHTML($0.markdown) }, imagesWithinBudget()]).html(from: text) + : nil + let shownLines = lines.flatMap(chunkedLine) + let hasBidiControls = text.unicodeScalars.contains(where: isBidiControl) + await MainActor.run { + showAppSheet { + MarkdownFileView(file: file, fileSource: fileSource, lines: shownLines, html: html, hasBidiControls: hasBidiControls) + } + } + } catch { + await MainActor.run { + showAlert(NSLocalizedString("Error reading file", comment: "alert title"), message: error.localizedDescription) + } + } + } +} + +private func decodedText(_ data: Data) -> String { + var text = String.UnicodeScalarView() + var parser = Unicode.UTF8.ForwardParser() + var bytes = data.makeIterator() + while true { + switch parser.parseScalar(from: &bytes) { + case let .valid(encoded): text.append(replacingControlChar(Unicode.UTF8.decode(encoded))) + case let .error(length): text.append(contentsOf: repeatElement("?" as Unicode.Scalar, count: length)) + case .emptyInput: return String(text).replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + } + } +} + +private func replacingControlChar(_ s: Unicode.Scalar) -> Unicode.Scalar { + s.value < 0x20 && s != "\t" && s != "\n" && s != "\r" ? "?" : s +} + +private func withinMarkdownLimits(_ lines: [String]) -> Bool { + var tableRows = 0 + var linkNesting = 0 + for line in lines { + tableRows = line.contains("|") ? tableRows + 1 : 0 + if line.count > MAX_MARKDOWN_LINE_LENGTH || nestingLevel(line) > MAX_MARKDOWN_NESTING || tableRows > MAX_MARKDOWN_TABLE_ROWS || !linkNestingWithinLimit(line, &linkNesting) { + return false + } + } + return true +} + +private func linkNestingWithinLimit(_ line: String, _ nesting: inout Int) -> Bool { + if line.isEmpty { nesting = 0 } + for c in line { + if c == "[" { + nesting += 1 + if nesting > MAX_MARKDOWN_LINK_NESTING { return false } + } else if c == "]" && nesting > 0 { + nesting -= 1 + } + } + return true +} + +private func nestingLevel(_ line: String) -> Int { + let content = line.drop(while: { $0 == " " || $0 == "\t" || $0 == ">" }) + let prefix = line[.." }.count + let listItem = content.first.map { "-*+".contains($0) || $0.isNumber } ?? false + return quotes + (listItem ? (prefix.count - quotes) / 2 : 0) +} + +private func chunkedLine(_ line: String) -> [String] { + let chars = Array(line) + return stride(from: 0, to: max(chars.count, 1), by: MAX_MARKDOWN_LINE_LENGTH).map { + String(chars[$0 ..< min($0 + MAX_MARKDOWN_LINE_LENGTH, chars.count)]) + } +} + +private func escapedHTML(_ s: Substring) -> String { + s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") +} + +private func imagesWithinBudget() -> Modifier { + var pixels = MAX_MARKDOWN_IMAGE_PIXELS + return Modifier(target: .images) { image in + let src = image.html.dropFirst("" + } +} + +private func isBidiControl(_ s: Unicode.Scalar) -> Bool { + [0x061C, 0x200E, 0x200F].contains(s.value) || (0x202A...0x202E).contains(s.value) || (0x2066...0x2069).contains(s.value) +} + +private func withInvisibleCharMarkers(_ line: String) -> Text { + var text = Text(verbatim: "") + var plain = "" + for s in line.unicodeScalars { + if s.properties.generalCategory == .format { + text = text + Text(verbatim: plain) + Text(verbatim: unicodeMarker(s)).foregroundColor(.orange) + plain = "" + } else { + plain.unicodeScalars.append(s) + } + } + return text + Text(verbatim: plain) +} + +private func openMarkdownFileLink(_ url: URL) { + let uri = url.absoluteString + let parsed = parseSimpleXMarkdown(uri) + let format = parsed?.count == 1 ? parsed?.first?.format : nil + switch format { + case let .simplexLink(_, _, simplexUri, _)?: + if let simplexUrl = URL(string: simplexUri) { + ChatModel.shared.appOpenUrl = simplexUrl + } else { + showInvalidLinkAlert(uri) + } + case .uri? where uri.hasPrefix("https://") || uri.hasPrefix("http://"): + openBrowserAlert(uri: uri) + default: + showInvalidLinkAlert(uri) + } +} + +struct MarkdownFileView: View { + @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW) private var textView = false + let file: CIFile + let fileSource: CryptoFile + let lines: [String] + let html: String? + let hasBidiControls: Bool + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if hasBidiControls { + Text("This file has text direction characters. The text can be shown in a different order than it is written.") + .foregroundColor(.orange) + .padding() + } + if let html, !textView { + ConditionsWebView(html: html, openLink: openMarkdownFileLink) + .padding(.horizontal) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines.indices, id: \.self) { i in + withInvisibleCharMarkers(lines[i]) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() + } + } + } + .modifier(ThemedBackground()) + .navigationTitle(file.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if html != nil { + Button { + textView.toggle() + } label: { + Image(systemName: textView ? "doc.richtext" : "chevron.left.forwardslash.chevron.right") + } + } + Button { + saveCryptoFile(fileSource) + } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + } + .navigationViewStyle(.stack) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift index 5abbbf8d2e..6a05851c63 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift @@ -12,11 +12,15 @@ import WebKit struct ConditionsWebView: UIViewRepresentable { @State var html: String + var openLink: (URL) -> Void = openExternalLink @EnvironmentObject var theme: AppTheme @State var pageLoaded = false func makeUIView(context: Context) -> WKWebView { - let view = WKWebView() + let config = WKWebViewConfiguration() + config.defaultWebpagePreferences.allowsContentJavaScript = false + config.websiteDataStore = .nonPersistent() + let view = WKWebView(frame: .zero, configuration: config) view.backgroundColor = .clear view.isOpaque = false view.navigationDelegate = context.coordinator @@ -35,8 +39,9 @@ struct ConditionsWebView: UIViewRepresentable { } private func loadPage(_ webView: WKWebView) { + let nonce = UUID().uuidString let styles = """ - """ - let head = "\(styles)" + let csp = "" + let head = "\(csp)\(styles)" webView.loadHTMLString(head + html, baseURL: nil) DispatchQueue.main.async { pageLoaded = true @@ -58,10 +77,16 @@ struct ConditionsWebView: UIViewRepresentable { } func makeCoordinator() -> Cordinator { - Cordinator() + Cordinator(openLink: openLink) } class Cordinator: NSObject, WKNavigationDelegate { + let openLink: (URL) -> Void + + init(openLink: @escaping (URL) -> Void) { + self.openLink = openLink + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { @@ -71,9 +96,9 @@ struct ConditionsWebView: UIViewRepresentable { switch navigationAction.navigationType { case .linkActivated: decisionHandler(.cancel) - openExternalLink(url) + openLink(url) default: - decisionHandler(.allow) + decisionHandler(url.scheme == "about" ? .allow : .cancel) } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 6cf9b5abe6..f7042b0c39 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -84,6 +84,7 @@ let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage" +let DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView" let DEFAULT_CURRENT_THEME = "currentTheme" let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme" @@ -145,6 +146,7 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true, DEFAULT_SHOW_SENT_VIA_RPOXY: false, DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false, + DEFAULT_MARKDOWN_FILE_TEXT_VIEW: false, ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue, DEFAULT_THEME_OVERRIDES: "{}", diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a138b24e4e..e9cfcab5bc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -168,6 +168,7 @@ 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; + 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; @@ -560,6 +561,7 @@ 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; + 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; @@ -1123,6 +1125,7 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, 5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */, 648010AA281ADD15009009B9 /* CIFileView.swift */, + 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */, 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */, 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, @@ -1600,6 +1603,7 @@ 5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, + 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */, 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index c509662c9e..b63d336c34 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -238,6 +238,12 @@ Renders voice messages with waveform visualization, play/pause control, and dura Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download. +### MarkdownFileView + +**File**: [`Shared/Views/Chat/ChatItem/MarkdownFileView.swift`](../../Shared/Views/Chat/ChatItem/MarkdownFileView.swift) + +Sheet with a viewer of Markdown files up to `MAX_MARKDOWN_VIEW_SIZE` (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it. + ### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) **File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 0ff63fa8de..e9503bc6ce 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -77,8 +77,8 @@ kotlin { api("dev.icerock.moko:resources-compose:0.27.1") // Markdown - implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0") - implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0") + implementation("com.mikepenz:multiplatform-markdown-renderer:0.34.0") + implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.34.0") } } val commonTest by getting { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index 1a3703822d..df2e6ccadc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.scale import boofcv.android.ConvertBitmap import boofcv.struct.image.GrayU8 import chat.simplex.common.R +import chat.simplex.common.views.helpers.decodeSampledBitmapFromByteArray import chat.simplex.common.views.helpers.errorBitmap import chat.simplex.common.views.helpers.getFileName import java.io.ByteArrayOutputStream @@ -41,6 +42,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } } +actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = + try { + decodeSampledBitmapFromByteArray(Base64.decode(base64, Base64.DEFAULT), width, width, MAX_IMAGE_DIMENSION).asImageBitmap() + } catch (e: Exception) { + Log.e(TAG, "decodeBase64Image error: $e") + null + } + actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 371bd7676b..7b2f53d365 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -194,11 +194,12 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? } // https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap -private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap { +internal fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int, maxDimension: Int = Int.MAX_VALUE): Bitmap { // First decode with inJustDecodeBounds=true to check dimensions return BitmapFactory.Options().run { inJustDecodeBounds = true BitmapFactory.decodeByteArray(data, 0, data.size, this) + if (outWidth > maxDimension || outHeight > maxDimension) throw IOException("Image dimensions exceed limit") // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 656911443a..ce1152de6a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -415,7 +415,10 @@ fun CenterPartOfScreen() { } } } - else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} + else -> { + ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} + ModalManager.center.showInView() + } } } 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 14bb9304d8..faf27d3ceb 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 @@ -142,6 +142,7 @@ class AppPreferences { val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false) + val markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false) val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) @@ -429,6 +430,7 @@ class AppPreferences { const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" + private const val SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW = "MarkdownFileTextView" private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index d7a241d5ee..47cc034bfb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -15,6 +15,7 @@ import java.io.InputStream import java.net.URI expect fun base64ToBitmap(base64ImageString: String): ImageBitmap +expect fun decodeBase64Image(base64: String, width: Int): ImageBitmap? expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream expect fun cropToSquare(image: ImageBitmap): ImageBitmap diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 1523e2ff22..42cbff3a2f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -106,6 +106,8 @@ fun CIFileView( showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) + isMarkdownFile(file) && file.loaded -> + showMarkdownFile(file) file.forwardingAllowed() -> { withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) @@ -184,7 +186,7 @@ fun CIFileView( val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) } val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() } - val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null + val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null && !isMarkdownFile(file)) getLoadedFilePath(file) else null if (loadedFilePath != null && file?.fileSource != null) { val encrypted = file.fileSource.cryptoArgs != null SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt new file mode 100644 index 0000000000..189e69a925 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt @@ -0,0 +1,284 @@ +package chat.simplex.common.views.chat.item + +import SectionBottomSpacer +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.documentMarkdownStyle +import chat.simplex.res.* +import com.mikepenz.markdown.compose.MarkdownElement +import com.mikepenz.markdown.m2.Markdown +import com.mikepenz.markdown.model.ImageData +import com.mikepenz.markdown.model.ImageTransformer +import com.mikepenz.markdown.model.State +import com.mikepenz.markdown.model.markdownAnnotator +import com.mikepenz.markdown.model.parseMarkdownFlow +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import java.io.ByteArrayInputStream +import java.io.File +import java.net.URI +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction + +private const val DATA_IMAGE = "data:image/" +private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576 +private const val MAX_MARKDOWN_LINE_LENGTH = 15_000 +private const val MAX_MARKDOWN_DEPTH = 32 +private const val MAX_MARKDOWN_TABLE_ROWS = 200 +private const val MAX_MARKDOWN_IMAGE_PIXELS = 2160L * 4320 + +private val controlChar = Regex("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]") + +private val bidiControlChar = Regex("[\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]") + +private val invisibleCharMarkerStyle = SpanStyle(color = WarningOrange) + +private class MarkdownDocument(val lines: List, val formatted: State.Success?, val hasBidiControls: Boolean) + +fun isMarkdownFile(file: CIFile): Boolean = + file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && file.fileName.substringAfterLast('.', "").lowercase() in listOf("md", "markdown") + +fun showMarkdownFile(file: CIFile) { + withLongRunningApi(slow = 600_000) { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file.loadRemoteFile(true) + filePath = getLoadedFilePath(file) + } + if (filePath == null) { + AlertManager.shared.showAlertMsg(MR.strings.file_not_found) + return@withLongRunningApi + } + val bytes = try { + val cryptoArgs = file.fileSource?.cryptoArgs + if (cryptoArgs != null) readCryptoFile(filePath, cryptoArgs) else File(filePath).readBytes() + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message) + return@withLongRunningApi + } + val text = decodedText(bytes) + val lines = text.lines() + val formatted = if (lines.all { it.length <= MAX_MARKDOWN_LINE_LENGTH }) formattedWithinLimits(text) else null + val document = MarkdownDocument( + lines.flatMap { line -> line.chunked(MAX_MARKDOWN_LINE_LENGTH).ifEmpty { listOf("") } }, + formatted, + bidiControlChar.containsMatchIn(text) + ) + ModalManager.center.showModal(endButtons = { + if (formatted != null) MarkdownViewSwitch() + MarkdownFileSaveButton(file, bytes) + }) { + MarkdownFileView(file.fileName, document) + } + } +} + +private fun decodedText(bytes: ByteArray): String = + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + .replaceWith("?") + .decode(ByteBuffer.wrap(bytes)) + .toString() + .replace(controlChar, "?") + +private suspend fun formattedWithinLimits(text: String): State.Success? = + (parseMarkdownFlow(text).first { it !is State.Loading } as? State.Success)?.takeIf { treeWithinLimits(it.node) } + +private fun treeWithinLimits(root: ASTNode): Boolean { + val nodes = ArrayDeque(listOf(root to 1)) + while (nodes.isNotEmpty()) { + val (node, depth) = nodes.removeLast() + if (depth > MAX_MARKDOWN_DEPTH) return false + if (node.type == GFMElementTypes.TABLE && node.children.count { it.type == GFMElementTypes.ROW } > MAX_MARKDOWN_TABLE_ROWS) return false + node.children.forEach { nodes.addLast(it to depth + 1) } + } + return true +} + +@Composable +private fun MarkdownViewSwitch() { + val textView = remember { appPrefs.markdownFileTextView.state } + IconButton({ appPrefs.markdownFileTextView.set(!textView.value) }) { + Icon( + painterResource(if (textView.value) MR.images.ic_article else MR.images.ic_code), + stringResource(if (textView.value) MR.strings.markdown_formatted_view else MR.strings.markdown_text_view), + tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +private fun MarkdownFileSaveButton(file: CIFile, bytes: ByteArray) { + val saveFileLauncher = rememberFileChooserLauncher(false, bytes) { to: URI? -> + if (to != null) copyBytesToFile(ByteArrayInputStream(bytes), to) {} + } + IconButton({ withLongRunningApi { saveFileLauncher.launch(file.fileName) } }) { + Icon(painterResource(MR.images.ic_download), stringResource(MR.strings.save_verb), tint = MaterialTheme.colors.primary) + } +} + +@Composable +private fun MarkdownFileView(fileName: String, document: MarkdownDocument) { + val textView = remember { appPrefs.markdownFileTextView.state } + val formatted = document.formatted + if (textView.value || formatted == null) { + SelectionContainer { + MarkdownFileColumn(fileName, document.hasBidiControls) { + items(document.lines) { line -> + Text(remember(line) { withInvisibleCharMarkers(line) }, Modifier.padding(horizontal = DEFAULT_PADDING), fontFamily = FontFamily.Monospace) + } + } + } + } else { + FormattedMarkdown(fileName, formatted, document.hasBidiControls) + } +} + +private fun withInvisibleCharMarkers(line: String): AnnotatedString = buildAnnotatedString { + var start = 0 + invisibleChar.findAll(line).forEach { + append(line.substring(start, it.range.first)) + withStyle(invisibleCharMarkerStyle) { append(unicodeMarker(it.value)) } + start = it.range.last + 1 + } + append(line.substring(start)) +} + +@Composable +private fun FormattedMarkdown(fileName: String, formatted: State.Success, hasBidiControls: Boolean) { + val style = documentMarkdownStyle(h1 = MaterialTheme.typography.h2.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold)) + val codeSpanStyle = style.typography.inlineCode.toSpanStyle().copy(background = style.colors.inlineCodeBackground) + val annotator = remember(codeSpanStyle) { + markdownAnnotator { content, child -> + when (child.type) { + MarkdownElementTypes.CODE_SPAN -> { + withStyle(codeSpanStyle) { append(" ${child.getTextInNode(content).trim('`')} ") } + true + } + MarkdownElementTypes.IMAGE -> + if (imageLink(content, child)?.startsWith(DATA_IMAGE) == true) { + false + } else { + append(child.getTextInNode(content).toString()) + true + } + else -> false + } + } + } + val imageTransformer = remember { DataImageTransformer() } + val parentUriHandler = LocalUriHandler.current + CompositionLocalProvider(LocalUriHandler provides remember { markdownFileUriHandler(parentUriHandler) }) { + Markdown( + formatted, + colors = style.colors, + typography = style.typography, + padding = style.padding, + imageTransformer = imageTransformer, + annotator = annotator, + components = style.components, + success = { state, components, _ -> + MarkdownFileColumn(fileName, hasBidiControls) { + items(state.node.children) { node -> + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + MarkdownElement(node, components, state.content, skipLinkDefinition = state.linksLookedUp) + } + } + } + } + ) + } +} + +@Composable +private fun MarkdownFileColumn(fileName: String, hasBidiControls: Boolean, content: LazyListScope.() -> Unit) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + LazyColumnWithScrollBar( + contentPadding = PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false) + ) + ) { + item { AppBarTitle(fileName) } + if (hasBidiControls) { + item { + Text(stringResource(MR.strings.markdown_file_bidi_warning), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING), color = WarningOrange) + } + } + content() + item { SectionBottomSpacer() } + } +} + +private fun imageLink(content: String, image: ASTNode): String? = + image.children.firstOrNull { it.type == MarkdownElementTypes.INLINE_LINK } + ?.children?.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION } + ?.getTextInNode(content)?.toString() + +private fun markdownFileUriHandler(parentUriHandler: UriHandler): UriHandler = object: UriHandler { + override fun openUri(uri: String) { + val format = parseToMarkdown(uri)?.singleOrNull()?.format + when { + format is Format.SimplexLink -> parentUriHandler.openVerifiedSimplexUri(format.simplexUri) + format is Format.Uri && (uri.startsWith("https://") || uri.startsWith("http://")) -> openBrowserAlert(uri, parentUriHandler) + else -> showInvalidLinkAlert(uri) + } + } +} + +private class DataImageTransformer : ImageTransformer { + private var pixels = MAX_MARKDOWN_IMAGE_PIXELS + private val images = mutableMapOf() + + @Composable + override fun transform(link: String): ImageData? { + val width = with(LocalDensity.current) { LocalWindowWidth().roundToPx() } + val bitmap by produceState(null, link) { + value = withContext(Dispatchers.Default) { decodedImage(link, width) } + } + return remember(bitmap) { bitmap?.let { ImageData(BitmapPainter(it)) } } + } + + private fun decodedImage(link: String, width: Int): ImageBitmap? = synchronized(this) { + if (link !in images) images[link] = imageWithinBudget(link, width) + images[link] + } + + private fun imageWithinBudget(link: String, width: Int): ImageBitmap? = + link.takeIf { it.startsWith(DATA_IMAGE) } + ?.let { decodeBase64Image(it.substringAfter("base64,"), width) } + ?.takeIf { it.width.toLong() * it.height <= pixels } + ?.also { pixels -= it.width.toLong() * it.height } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 489ebb8c39..32e0befa3f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -586,7 +586,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection onClick = { ModalManager.center.closeModals() ModalManager.end.closeModals() - ModalManager.center.showModalCloseable(settings = true, showClose = appPlatform.isAndroid, cardScreen = true) { close -> + ModalManager.center.showModalCloseable(settings = true, cardScreen = true) { close -> ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close) } showMenu.value = false 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 02c0b45de4..b13c321f70 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 @@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import LocalCardScreen import chat.simplex.common.ui.theme.* @@ -142,9 +141,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { modalViews.add(ModalViewHolder(id, anim, data, modal)) _modalCount.value = modalViews.size - toRemove.size - if (placement == ModalPlacement.CENTER) { - ChatModel.chatId.value = null - } else if (placement == ModalPlacement.END) { + if (placement == ModalPlacement.END) { desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()) + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH * sqrt(appPrefs.fontScale.get())) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 1e46091ea3..20a182551c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -33,10 +33,14 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.res.* import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.compose.components.MarkdownComponents import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.elements.MarkdownHeader import com.mikepenz.markdown.m2.markdownColor import com.mikepenz.markdown.m2.markdownTypography +import com.mikepenz.markdown.model.MarkdownColors +import com.mikepenz.markdown.model.MarkdownPadding +import com.mikepenz.markdown.model.MarkdownTypography import com.mikepenz.markdown.model.markdownPadding import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -771,22 +775,36 @@ expect fun ConditionsBox(modifier: Modifier, scrollState: ScrollState, content: @Composable private fun ConditionsMarkdown(text: String) { + val style = documentMarkdownStyle() Markdown(text, - markdownColor(linkText = MaterialTheme.colors.primary), + style.colors, + style.typography, + Modifier.padding(8.dp), + // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly + flavour = CommonMarkFlavourDescriptor(), + components = style.components, + padding = style.padding + ) +} + +class DocumentMarkdownStyle(val colors: MarkdownColors, val typography: MarkdownTypography, val components: MarkdownComponents, val padding: MarkdownPadding) + +@Composable +fun documentMarkdownStyle(h1: TextStyle = MaterialTheme.typography.body1): DocumentMarkdownStyle = + DocumentMarkdownStyle( + markdownColor(), markdownTypography( - h1 = MaterialTheme.typography.body1, + h1 = h1, h2 = MaterialTheme.typography.h3.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold), h3 = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), h4 = MaterialTheme.typography.h5.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold), h5 = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold), link = MaterialTheme.typography.body1.copy( + color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline ) ), - Modifier.padding(8.dp), - // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly - flavour = CommonMarkFlavourDescriptor(), - components = markdownComponents( + markdownComponents( heading2 = { Spacer(Modifier.height(10.dp)) MarkdownHeader(it.content, it.node, it.typography.h2) @@ -803,9 +821,8 @@ private fun ConditionsMarkdown(text: String) { Spacer(Modifier.height(4.dp)) }, ), - padding = markdownPadding(block = 4.dp) + markdownPadding(block = 4.dp) ) -} @Composable private fun ConditionsLinkView(conditionsLink: String) { diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 93bf4f5838..326be2b05d 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -687,6 +687,9 @@ File saved File not found Error saving file + Formatted view + Text view + This file has text direction characters. The text can be shown in a different order than it is written. Loading the file Please, wait while the file is being loaded from the linked mobile File error diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 9c6139b493..925698154e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import boofcv.io.image.ConvertBufferedImage import boofcv.struct.image.GrayU8 +import chat.simplex.common.views.helpers.decodeBoundedBufferedImage import chat.simplex.res.* import org.jetbrains.skia.Image import java.awt.RenderingHints @@ -54,6 +55,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } } +actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = + try { + decodeBoundedBufferedImage(Base64.getMimeDecoder().decode(base64), width).toComposeImageBitmap() + } catch (e: Throwable) { + Log.e(TAG, "decodeBase64Image error: $e") + null + } + actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 98ffa7c8a4..dd76e86c81 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -23,7 +23,7 @@ actual fun SimpleAndAnimatedImageView( // still image. A full screen modal is shown beside the chat rather than in place of it, so this item keeps // composing under one and would otherwise decode where nobody can see it. val frame = if (smallView) imageBitmap - else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() } + else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() || ModalManager.center.hasModalsOpen() } ImageView(BitmapPainter(frame)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md index 5dc606a50e..3dff974366 100644 --- a/apps/multiplatform/product/README.md +++ b/apps/multiplatform/product/README.md @@ -307,7 +307,7 @@ UserPicker | | OR (when no chat selected): | GroupProfileView | | UserPicker (overlay) | "No selected chat" | AddGroupMembersView | | - profile switcher | | MemberAdmission | -| - quick settings | OR (when modal open): | VerifyCodeView | +| - quick settings | Over them (when modal open): | VerifyCodeView | | | ModalManager.center content | SettingsView subtabs | | ModalManager.start | (settings, new chat, etc.) | | | - secondary modals | | ModalManager.end | @@ -316,7 +316,7 @@ UserPicker ModalManager Placement (Desktop): - ModalManager.start -> left panel overlay (settings subviews) - - ModalManager.center -> center panel (replaces chat, used when chatId is null) + - ModalManager.center -> center panel (drawn over the chat or the placeholder) - ModalManager.end -> right panel (detail/info views) - ModalManager.fullscreen -> full window overlay (onboarding, auth, call) diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index adf8ad0224..3bad2e09c4 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -313,6 +313,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `EmojiItemView.kt` | Large emoji-only message | | `FramedItemView.kt` | Message bubble frame (quoted item, text, media) | | `ImageFullScreenView.kt` | Fullscreen image gallery | +| `MarkdownFileView.kt` | Markdown file viewer, formatted or as text | | `IntegrityErrorItemView.kt` | Message integrity error | | `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | | `TextItemView.kt` | Plain text message with markdown | diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 96dd58052c..d0340e8d23 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -178,7 +178,7 @@ fun DesktopScreen(userPickerState: MutableStateFlow) |---|---|---| | **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay | | **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` | -| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) | +| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView with `ModalManager.center` over it; without a chat: `ModalManager.center` or "no selected chat" placeholder) | | **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) | ### Column Separators @@ -196,7 +196,7 @@ When the UserPicker is visible or a start modal is open (but no center modal), a - When `chatId` is null and no center modals: shows "No selected chat" placeholder. - When `chatId` is null and center modals open: shows `ModalManager.center`. -- When `chatId` is set: shows `ChatView`. +- When `chatId` is set: shows `ChatView`, and `ModalManager.center` over it. - Automatically closes center modals when a chat is selected. ### StartPartOfScreen @@ -223,7 +223,7 @@ class ModalManager(private val placement: ModalPlacement?) | Zone | Android Behavior | Desktop Behavior | |---|---|---| | `start` | Shared (same as all others) | Left column overlay, slides from start | -| `center` | Shared | Center column overlay, replaces ChatView | +| `center` | Shared | Center column overlay, drawn over ChatView | | `end` | Shared | Right column, slides from end | | `fullscreen` | Shared | Fullscreen overlay | diff --git a/plans/2026-10-11-markdown-file-viewer.md b/plans/2026-10-11-markdown-file-viewer.md new file mode 100644 index 0000000000..c5152ea485 --- /dev/null +++ b/plans/2026-10-11-markdown-file-viewer.md @@ -0,0 +1,319 @@ +# Markdown file viewer: implementation plan + +Markdown files from chats are viewed in the Android, desktop and iOS apps, formatted or as text. + +## 1. Files that qualify + +By name and size only: + +``` +name file.fileName ends with .md or .markdown, case-insensitive +size file.fileSize <= MAX_MARKDOWN_VIEW_SIZE +``` + +``` +MAX_MARKDOWN_VIEW_SIZE 1_048_576 +Kotlin views/chat/item/MarkdownFileView.kt, private +iOS Shared/Views/Chat/ChatItem/MarkdownFileView.swift, private +``` + +Function, in each viewer file: + +``` +Kotlin fun isMarkdownFile(file: CIFile): Boolean +iOS func isMarkdownFile(_ file: CIFile) -> Bool +``` + +Other files, and Markdown files over the limit: tap actions unchanged. + +## 2. Tap and long press + +Tap on a qualifying file, every platform: + +``` +file status action +received or sent, file loaded viewer +desktop with a linked mobile loadRemoteFile, then viewer +RcvInvitation, RcvAborted reception started; viewer shown on completion, in the same place +other statuses as now: waiting alerts, error alerts +``` + +Long press: the existing Save item of the item menu (`ChatItemView.kt`, `ChatView.swift`). + +Places: + +``` +Kotlin CIFileView.kt, onClick and fileAction +iOS CIFileView.swift, fileAction +``` + +- Android: "Open with" of `SaveOrOpenFileMenu` is not shown for qualifying files; "Share" in the item menu stays. +- Present locally: `getLoadedFilePath(file)` (Kotlin), `getLoadedFileSource(file)` (iOS), as for other files. + +## 3. Open on completion + +In master (#7700), for images, voice messages, video and files. The viewer adds only the Markdown case to the file tap action. + +A tap that starts reception registers the file. On completion the tap action of the received file is performed: + +``` +image full screen view +voice playback +video playback; locally encrypted: decrypted first; chat list preview: full screen player +file Markdown: viewer + other: share sheet (iOS), open/save menu (Android), save dialog (desktop) +``` + +Kotlin, chat model: + +``` +fileToOpen file ID of the last tapped file + inChatList: tap in a chat list preview +openWhenReceived(file, inChatList) sets fileToOpen +receivedFileToOpen(file, inChatList) false unless fileToOpen has file.fileId and inChatList, and the file is loaded + then fileToOpen cleared + chat view: true + chat list preview: true on desktop, or when no chat is open +``` + +Item views: `OpenWhenReceivedEffect(file, inChatList, open)` (`CIFileView.kt`), on a change of `file.loaded`; in `CIImageView.kt`, `CIVoiceView.kt`, `CIVideoView.kt`, `CIFileView.kt`. + +iOS, chat model: + +``` +fileToOpen file ID of the last tapped file + chat ID at the tap; nil in the chat list + open callback of the tapped view +openWhenReceived(file, open) sets fileToOpen +receivedFileToOpen(file) fileToOpen has file.fileId, and the open chat is the chat at the tap +openReceivedFile(file) receivedFileToOpen rechecked; fileToOpen cleared; open(file) +``` + +Event `rcvFileComplete` (`SimpleXAPI.swift`): for the active user, when `receivedFileToOpen(file)`: `openReceivedFile(file)` on the main actor. + +`fileToOpen` is replaced by a later tap. + +## 4. Reading in memory + +The file is read and decoded off the main thread, before the viewer is shown. + +``` +Kotlin withLongRunningApi + encrypted: readCryptoFile(path, cryptoArgs) (CryptoFile.kt:36) + plain: File(path).readBytes() + text: UTF-8 CharsetDecoder, REPLACE with "?" + parsed: parseMarkdownFlow (renderer), once, before the viewer is shown +iOS Task + getFileData(path, cryptoArgs) (SimpleXChat/ImageUtils.swift:41) + text: Unicode.UTF8.ForwardParser, "?" for each invalid byte + html: MarkdownParser with an .html modifier, raw HTML escaped +``` + +Text decoding, as the core safeDecodeUtf8 (simplexmq Util.hs:383-386): + +``` +invalid UTF-8 "?" for each invalid byte +C0 controls U+0000-U+001F replaced with "?", except tab, line feed, carriage return +line endings CRLF and CR are line breaks: Kotlin text.lines(); iOS: replaced with LF before parsing +``` + +Limits, checked before rendering; over a limit: text view only, the switch hidden: + +``` +line length 15 000 characters +nesting Kotlin: 32 levels of the parsed tree; iOS: 16 levels estimated from line prefixes +link nesting iOS: 4 open square brackets, counted until an empty line +table rows 200 +parse error Kotlin: text view only +``` + +Text view: lines longer than 15 000 characters are split. + +- `readCryptoFile`: core `chatReadFile` (`Mobile/File.hs:87`); the file is decrypted by `CF.readFile` into memory. +- Bytes, text and HTML are held only in memory, in the viewer's state. +- Read error: alert with the error text; the viewer is not shown. +- File absent: "File not found" alert (Kotlin); no action (iOS), as for other files. + +## 5. Viewer + +``` +presentation Android: full screen, like the image viewer + desktop: over the conversation + iOS: sheet +toolbar back (Kotlin) + switch between the formatted and the text view + save +title file name +preference view chosen last; default formatted +``` + +### 5.1. Kotlin + +Renderer: `com.mikepenz:multiplatform-markdown-renderer` and `-m2`, 0.27.0 → 0.34.0 (`common/build.gradle.kts:80-81`). + +``` +0.34.0 dependencies kotlin-stdlib 2.1.20, org.jetbrains:markdown 0.7.3, kotlinx-collections-immutable 0.3.8 +compose compileOnly; built with Compose 1.8.0, the app uses 1.8.2 +app Kotlin 2.1.20, Compose Multiplatform 1.8.2 +added table component, LazyMarkdownSuccess, success lambda of Markdown() +``` + +New file `views/chat/item/MarkdownFileView.kt`: + +``` +isMarkdownFile(file) +showMarkdownFile(file) loadRemoteFile if needed, read, ModalManager.center.showModal +MarkdownFileView formatted or text view by appPrefs.markdownFileTextView +``` + +Formatted view: + +``` +renderer m2 Markdown(state), GFM flavour, the parsed state from showMarkdownFile +styles documentMarkdownStyle(h1) (OperatorView.kt), shared with the operator conditions + h1: typography h2, 26 sp, bold +lazy success lambda: LazyColumnWithScrollBar, title item, one item per top-level node: MarkdownElement in a Column +links LocalUriHandler: markdownFileUriHandler +code spans annotator: source text between the backticks; inlineCode style, inlineCodeBackground +images data:image URIs: DataImageTransformer, decoded off the main thread by decodeBase64Image + other image links: the annotator appends the source text; nothing is fetched +tables default table component +``` + +Links, classified by the core markdown parser (`parseToMarkdown`), as message links: + +``` +SimplexLink openVerifiedSimplexUri +Uri, http or https openBrowserAlert +other showInvalidLinkAlert +``` + +Displayed link targets (`displayedUri`, Kotlin and iOS; also in message link alerts and the external-link alert): + +``` +format characters (Cf) [U+XXXX] +non-ASCII in the authority [U+XXXX] +HTML not parsed (parseHtml = false) +``` + +Images: + +``` +decode decodeBase64Image(base64, window width): power-of-two subsampling, both sides kept at or above the window width + Android: decodeSampledBitmapFromByteArray, 4320 px per side + desktop: decodeBoundedBufferedImage, 16384 px per side, 4320 × 4320 decoded pixels +budget 2160 × 4320 decoded pixels per document; images over the budget are not shown +cache per document, in the transformer; the desktop base64BitmapCache is not used +``` + +Text view: `LazyColumnWithScrollBar`, title item, one monospace `Text` per line, inside `SelectionContainer`; format characters shown as `[U+XXXX]` markers. + +Bidi controls (U+061C, U+200E, U+200F, U+202A-U+202E, U+2066-U+2069): warning under the title, both views. + +Toolbar end buttons: + +``` +switch ic_code in the formatted view, ic_article in the text view; sets appPrefs.markdownFileTextView; hidden over the limits +save ic_download; rememberFileChooserLauncher; copyBytesToFile(ByteArrayInputStream(bytes)) (Files.kt:65) +``` + +`OperatorView.kt`: + +``` +markdownColor(linkText = primary) markdownColor(); link colour moved to the link text style (linkText deprecated in 0.34.0) +ConditionsMarkdown styles moved to documentMarkdownStyle(h1): DocumentMarkdownStyle (colors, typography, components, padding) + h1: body1 by default, as in the conditions +``` + +`SimpleXAPI.kt`: `markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)`. + +Centre placement on desktop: modals are drawn over the open conversation. + +``` +ModalView.kt showCustomModal: chatId is not changed +App.kt CenterPartOfScreen: ModalManager.center.showInView() after ChatView +ChatListNavLinkView.kt "Set contact name": back button on desktop +``` + +`moko-resources/base/strings.xml`: + +``` +markdown_formatted_view Formatted view +markdown_text_view Text view +markdown_file_bidi_warning This file has text direction characters. The text can be shown in a different order than it is written. +``` + +### 5.2. iOS + +New file `Shared/Views/Chat/ChatItem/MarkdownFileView.swift`, added to the SimpleX target in `project.pbxproj`. + +``` +isMarkdownFile(file) +showMarkdownFile(file) read, parse, showAppSheet +MarkdownFileView NavigationView; toolbar: switch, save +``` + +``` +formatted ConditionsWebView(html:openLink:), Ink 0.6.0, raw HTML escaped by an .html modifier +text ScrollView, LazyVStack, one monospace Text per line, text selection, format characters as [U+XXXX] +switch @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW); hidden over the limits +save saveCryptoFile(fileSource) (CIFileView.swift) +close swipe down +links openMarkdownFileLink: parseSimpleXMarkdown; simplexLink: appOpenUrl; uri, http or https: openBrowserAlert; other: showInvalidLinkAlert +``` + +`SettingsView.swift`: `DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"`, default false. + +`ConditionsWebView.swift`, shared with the operator conditions: + +``` +configuration allowsContentJavaScript = false; websiteDataStore = .nonPersistent() +head Content-Security-Policy: default-src 'none'; style-src 'nonce-'; img-src data:; base-uri 'none'; form-action 'none' +style the app's style block with the nonce; table, th, td borders; pre overflow-x auto; img max-width 100% +navigation linkActivated: openLink (openExternalLink by default) + other navigation: allowed for about: URLs only +``` + +- Ink 0.6.0 applies `.html` modifiers to block and inline HTML fragments (`HTMLConvertible.swift`); the viewer's modifier escapes `&`, `<`, `>`; code spans and blocks are escaped by Ink itself. + +Images, an `.images` modifier (`imagesWithinBudget`): + +``` +data:image, base64 shown as , rebuilt from the source only + 4320 px per side; 2160 × 4320 pixels per document; size read by UIImage(data:) +over a limit not shown +other data URIs not shown +other image links the source text; nothing is fetched +``` + +## 6. Size limit + +1 MB. +4 MB: formatted views render lazily (Kotlin: one lazy item per top-level node; iOS: WKWebView); text views render one lazy item per line. +A single top-level block (one table, code block or paragraph) is still laid out at once. + +## 7. Checks + +1. Plain and encrypted files: formatted and text views on Android, desktop and iOS. +2. Desktop linked to a mobile: file loaded from the mobile, then shown. +3. File over 1 MB: tap action as before. +4. File not received: one tap; viewer shown on completion in the same chat; not shown after leaving the chat. +5. Same for images, voice messages, video and other files, each with its tap action. +6. Tap in a chat list preview: opened on completion while the chat list is shown; Android, iOS: not opened after a chat is opened; desktop: opened while a chat is open. +7. Not opened after a reception error, cancel or abort. Kotlin: not opened after the item scrolled out of view. +8. Invalid UTF-8 and control characters: shown as "?". +9. Lines over 15 000 characters, 33 tree levels (Kotlin), 17 nesting levels (iOS), 5 nested square brackets (iOS), 201 table rows: text view only, long lines split. +10. Bidi controls: warning; format characters as [U+XXXX] in the text view. +11. Data images: decoded to the window width (Kotlin); images over 4320 px per side (Android, iOS) or beyond 2160 × 4320 pixels per document not shown. +12. Links: SimpleX links to the connect flow; http(s) links to the "Open web link?" alert; other schemes and relative links: "Invalid link"; targets with invisible or non-ASCII host characters shown with [U+XXXX]. +13. View switch kept after closing the viewer and after a restart. +14. Save: the file at the chosen destination equals the original. +15. Link inside backticks: shown as code. +16. Remote image: not loaded, the source text shown; data URI image: shown. +17. iOS inline HTML with ` +{% if not noEvents %} + +{% endif %} + + + Invest in SimpleX Chat @@ -18,7 +30,7 @@ eleventyExcludeFromCollections: true - + From c07d28681f41c9c52509617a58c9861f2ba82e64 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 14 Oct 2026 21:42:51 +0100 Subject: [PATCH 28/84] website: update cf links and page (#7722) * website: update cf links and page * noopener --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- ...5.8-private-message-routing-chat-themes.md | 2 +- blog/20260919-simplex-supporter-badges.md | 4 +- website/langs/ar.json | 16 ++-- website/langs/cs.json | 16 ++-- website/langs/de.json | 16 ++-- website/langs/en.json | 18 ++--- website/langs/es.json | 16 ++-- website/langs/fa.json | 16 ++-- website/langs/fi.json | 16 ++-- website/langs/fr.json | 16 ++-- website/langs/he.json | 16 ++-- website/langs/hu.json | 16 ++-- website/langs/id.json | 16 ++-- website/langs/it.json | 16 ++-- website/langs/ja.json | 16 ++-- website/langs/nl.json | 16 ++-- website/langs/pl.json | 16 ++-- website/langs/pt_BR.json | 16 ++-- website/langs/ru.json | 16 ++-- website/langs/tr.json | 16 ++-- website/langs/uk.json | 16 ++-- website/langs/zh_Hans.json | 16 ++-- website/langs/zh_Hant.json | 12 +-- .../src/_includes/blog_previews/20221108.html | 2 +- .../src/_includes/blog_previews/20221206.html | 8 +- website/src/_includes/contact_page.html | 12 +-- website/src/_includes/footer.html | 38 ++++----- website/src/_includes/hero.html | 20 ++--- website/src/_includes/layouts/token.html | 6 +- website/src/_includes/navbar.html | 12 +-- .../src/_includes/sections/join_simplex.html | 8 +- website/src/crowdfunding.html | 2 +- website/src/directory.html | 2 +- website/src/fdroid.html | 2 +- website/src/file.html | 8 +- website/src/index.html | 78 +++++++++---------- website/src/js/channel-preview.jsc | 10 +-- website/src/js/directory.jsc | 1 + website/src/js/simplex-lib.jsc | 4 +- website/src/livestream.html | 2 +- website/src/messaging.html | 2 +- 41 files changed, 277 insertions(+), 280 deletions(-) diff --git a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md index e06f7c2084..9bd342a146 100644 --- a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md +++ b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md @@ -110,7 +110,7 @@ We [wrote previously](https://github.com/simplex-chat/simplexmq/blob/master/rfcs While we cannot require the operators of self-hosted and private servers to disclose any information about them (apart from which server code they use - this is the requirement of the AGPLv3 license to share this information with users connecting to the server), as we add other server operators to the app, it is important for the users to have all important information about these operators and servers location. -This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app. +This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app. The updated server guide also includes [the instruction](../docs/SERVER.md#) about how to host this page for your server. It is generated as a static page when the server starts. We recommend using Caddy webserver to serve it. diff --git a/blog/20260919-simplex-supporter-badges.md b/blog/20260919-simplex-supporter-badges.md index 7aa2b2e4ac..50e2ebcb4b 100644 --- a/blog/20260919-simplex-supporter-badges.md +++ b/blog/20260919-simplex-supporter-badges.md @@ -11,7 +11,7 @@ permalink: "/blog/20260919-simplex-supporter-badges.html" **Published:** Sep 19, 2026 -You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. +You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. ## A paid feature that cannot identify you @@ -28,7 +28,7 @@ Read more about badges in the [whitepaper](https://github.com/simplex-chat/simpl ## How to get a badge - + Buy a code on [simplex.chat/badges](https://simplex.chat/badges/), paying by card, Bitcoin or Monero, and redeem it in the app: open Settings, tap **Supporter perks**, and enter the code. The badge appears on your profile. The badge does not renew by itself, and no account is created. diff --git a/website/langs/ar.json b/website/langs/ar.json index 3f5c220f19..39d72effc2 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -82,7 +82,7 @@ "hero-overlay-card-1-p-5": "الأجهزة العميلة فقط هي التي تخزن ملفات تعريف المستخدمين، جهات الاتصال والمجموعات؛ يتم إرسال الرسائل بتعمية ثنائية الطبقة بين الطرفين.", "hero-overlay-card-2-p-2": "يمكنهم بعد ذلك ربط هذه المعلومات بالشبكات الاجتماعية العامة الحالية، وتحديد بعض الهويات الحقيقية.", "simplex-network-overlay-card-1-li-3": "P2P لا يحل مشكلة هجوم الوسيط (MITM)، ومعظم التطبيقات الحالية لا تستخدم خارج النطاق رسائل للتبادل الأولي للمفاتيح. يستخدم SimpleX رسائل خارج النطاق أو، في بعض الحالات، اتصالات آمنة وموثوق بها موجودة مسبقًا لتبادل المفاتيح الأولي.", - "privacy-matters-overlay-card-1-p-3": "تستخدم بعض الشركات المالية والتأمين رسوماً بيانية اجتماعية لتحديد أسعار الفائدة وأقساط التأمين. عادةً ما تجعل الأشخاص ذوي الدخل المنخفض يدفعون أكثر — أو كما يسمى \"علاوة الفقر\".", + "privacy-matters-overlay-card-1-p-3": "تستخدم بعض الشركات المالية والتأمين رسوماً بيانية اجتماعية لتحديد أسعار الفائدة وأقساط التأمين. عادةً ما تجعل الأشخاص ذوي الدخل المنخفض يدفعون أكثر — أو كما يسمى \"علاوة الفقر\".", "privacy-matters-overlay-card-1-p-2": "يعرف تجار التجزئة عبر الإنترنت أن الأشخاص ذوي الدخل المنخفض هم أكثر عرضة لإجراء عمليات شراء عاجلة، لذلك قد يفرضون أسعارًا أعلى أو يزيلون الخصومات.", "simplex-private-6-title": "تبديل
خارج النطاق", "simplex-private-9-title": "قوائم انتظار
أحادية الاتجاه", @@ -90,7 +90,7 @@ "simplex-private-card-1-point-1": "بروتوكول Double-ratchet —
رسائل OTR مع السرية المستمرة واستعادة الاختراق.", "simplex-private-card-1-point-2": "NaCL cryptobox في كل قائمة انتظار لمنع ارتباط حركة مرور البيانات بين قوائم انتظار الرسائل في حالة اختراق TLS.", "simplex-private-card-2-point-1": "طبقة إضافية من تعمية الخادم للتسليم إلى المُستلم، لمنع الارتباط بين حركة مرور بيانات الخادم المُستلمة والمُرسلة في حالة اختراق TLS.", - "simplex-private-card-4-point-2": "لاستخدام SimpleX عبر تور، يُرجى تثبيت تطبيق Orbot وتمكّين وكيل SOCKS5 (أو VPN على iOS ).", + "simplex-private-card-4-point-2": "لاستخدام SimpleX عبر تور، يُرجى تثبيت تطبيق Orbot وتمكّين وكيل SOCKS5 (أو VPN على iOS ).", "simplex-private-card-5-point-2": "يجعل الرسائل ذات الأحجام المختلفة تبدو متشابهة للخوادم ومراقبي الشبكة.", "simplex-private-card-6-point-1": "العديد من شبكات التواصل عرضة لهجمات الوسيط (MITM) من قِبل الخوادم أو موفري الشبكات.", "simplex-private-card-7-point-1": "لضمان سلامة الرسائل يتم ترقيمها بالتسلسل وتضمين تجزئة الرسالة السابقة.", @@ -103,7 +103,7 @@ "hero-overlay-card-1-p-2": "لتسليم الرسائل، بدلاً من معرّفات المُستخدم المُستخدمة من قِبل جميع الشبكات الأخرى، يستخدم SimpleX معرّفات زوجية مجهولة مؤقتة لقوائم الرسائل، منفصلة لكل اتصال من اتصالاتك — ولا توجد معرّفات طويلة الأجل.", "simplex-network-overlay-card-1-p-1": "بروتوكولات المُراسلة والتطبيقات P2P بها مشاكل مختلفة تجعلها أقل موثوقية من SimpleX وأكثر تعقيدًا في التحليل و عرضة لعدة أنواع من الهجمات.", "hero-overlay-card-2-p-1": "عندما يكون لدى المستخدمين هويات ثابتة، حتى لو كان هذا مجرد رقم عشوائي، مثل معرّف الجلسة، فهناك خطر يتمثل في أن الموفر أو المهاجم يمكنه مراقبة كيفية اتصال المستخدمين وعدد الرسائل التي يرسلونها.", - "hero-overlay-card-1-p-6": "اقرأ المزيد في SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "اقرأ المزيد في SimpleX whitepaper.", "hero-overlay-card-2-p-3": "حتى مع معظم التطبيقات الخاصة التي تستخدم خدمات Tor v3، إذا تحدثت إلى جهتي اتصال مختلفتين عبر نفس الملف الشخصي، فيمكنهما إثبات أنهما متصلان بنفس الشخص.", "hero-overlay-card-2-p-4": "يحمي SimpleX من هذه الهجمات من خلال عدم وجود أي معرّفات مستخدم في تصميمه. وإذا كنت تستخدم وضع التخفي، فسيكون لديك اسم عرض مختلف لكل جهة اتصال، مع تجنب أي بيانات مشتركة بينهما.", "simplex-network-overlay-card-1-li-6": "قد تكون شبكات P2P عرضة DRDoS attack، عندما يتمكّن العملاء من إعادة بث حركة مرور البيانات وتضخيمها، مما يؤدي إلى رفض الخدمة على مستوى الشبكة. يقوم عملاء SimpleX فقط بترحيل حركة مرور البيانات من اتصال معروف ولا يمكن للمهاجم استخدامها لتضخيم حركة مرور البيانات في الشبكة بالكامل.", @@ -119,17 +119,17 @@ "simplex-unique-overlay-card-3-p-2": "يتم الاحتفاظ بالرسائل المُعمَّاة بين الطرفين مؤقتًا على خوادم ترحيل SimpleX حتى يتم استلامها، ثُمَّ تُحذف نهائيًا.", "simplex-unique-overlay-card-3-p-4": "لا توجد معرفّات أو نص مُعَمَّى مشترك بين حركة مرور بيانات الخادم المُرسلة والمُستلمة — إذا كان أي شخص يراقب ذلك، فلن يتمكّن بسهولة من تحديد من يتواصل مع من، حتى لو اختُرق TLS.", "simplex-unique-card-1-p-1": "يحمي SimpleX خصوصية ملف تعريفك، جهات اتصالك والبيانات الوصفية، ويخفيه عن خوادم شبكة SimpleX وأي مراقبين.", - "privacy-matters-overlay-card-2-p-1": "منذ وقت ليس ببعيد، لاحظنا أن الانتخابات الرئيسية يتم التلاعب بها بواسطة شركة استشارية ذات سمعة طيبة التي استخدمت الرسوم البيانية الاجتماعية لتشويه نظرتنا للعالم الحقيقي والتلاعب بأصواتنا.", + "privacy-matters-overlay-card-2-p-1": "منذ وقت ليس ببعيد، لاحظنا أن الانتخابات الرئيسية يتم التلاعب بها بواسطة شركة استشارية ذات سمعة طيبة التي استخدمت الرسوم البيانية الاجتماعية لتشويه نظرتنا للعالم الحقيقي والتلاعب بأصواتنا.", "privacy-matters-overlay-card-2-p-2": "لكي تكون موضوعيًا وتتخذ قرارات مستقلة، عليك التحكم في مساحة معلوماتك. لا يمكن تحقيق ذلك إلا باستخدام شبكة اتصال خاصة لا يمكنها الوصول إلى حسابك على مواقع التواصل الاجتماعي.", "privacy-matters-overlay-card-2-p-3": "SimpleX هي أول شبكة لا تحتوي على أي معرّفات مستخدم من حيث التصميم، وبهذه الطريقة تحمي مخطط اتصالاتك بشكل أفضل من أي بديل معروف.", - "privacy-matters-overlay-card-3-p-2": "واحدة من أكثر القصص إثارة للصدمة هي تجربة محمدو ولد صلاحي الموصوفة في مذكراته والموضحة في فيلم موريتاني. تم وضعه في معتقل غوانتانامو بدون محاكمة، وتعرض للتعذيب هناك لمدة 15 عامًا بعد مكالمة هاتفية مع قريبه في أفغانستان، للاشتباه في تورطه في هجمات 11 سبتمبر، على الرغم من أنه عاش في ألمانيا طوال السنوات العشر الماضية.", - "privacy-matters-overlay-card-3-p-3": "يتم القبض على الأشخاص العاديين بسبب ما يشاركونه عبر الإنترنت، حتى عبر حساباتهم \"المجهولة\"، وحتى في البلدان الديمقراطية.", + "privacy-matters-overlay-card-3-p-2": "واحدة من أكثر القصص إثارة للصدمة هي تجربة محمدو ولد صلاحي الموصوفة في مذكراته والموضحة في فيلم موريتاني. تم وضعه في معتقل غوانتانامو بدون محاكمة، وتعرض للتعذيب هناك لمدة 15 عامًا بعد مكالمة هاتفية مع قريبه في أفغانستان، للاشتباه في تورطه في هجمات 11 سبتمبر، على الرغم من أنه عاش في ألمانيا طوال السنوات العشر الماضية.", + "privacy-matters-overlay-card-3-p-3": "يتم القبض على الأشخاص العاديين بسبب ما يشاركونه عبر الإنترنت، حتى عبر حساباتهم \"المجهولة\"، وحتى في البلدان الديمقراطية.", "simplex-unique-overlay-card-1-p-1": "على عكس شبكات المُراسلة الأخرى، لا يحتوي SimpleX على معرّفات مخصصة للمستخدمين. لا يعتمد على أرقام الهواتف أو العناوين المستندة إلى النطاقات (مثل البريد الإلكتروني أو XMPP)، أسماء المستخدمين، المفاتيح العامة أو حتى الأرقام العشوائية لتحديد مستخدميها — مُشغلي خادم SimpleX لا يعرفون عدد الأشخاص الذين يستخدمون خوادمهم.", "simplex-unique-overlay-card-1-p-2": "لتسليم الرسائل، يستخدم العناوين المزدوجة المجهولة لقوائم انتظار الرسائل أحادية الاتجاه، منفصلة عن الرسائل المُستلمة والمُرسلة، عادةً عبر خوادم مختلفة.", "simplex-unique-overlay-card-3-p-1": "يخزن SimpleX Chat جميع بيانات المستخدم على أجهزة العميل فقط باستخدام تنسيق قاعدة بيانات محمولة مُعمَّاة يمكّن تصديرها ونقلها إلى أي جهاز مدعوم.", "simplex-unique-overlay-card-3-p-3": "على عكس خوادم الشبكات الاتحادية (البريد الإلكتروني أو XMPP أو Matrix)، لا تقوم خوادم SimpleX بتخزين حسابات المستخدمين، فهي تقوم فقط بترحيل الرسائل، مما يحمي خصوصية كلا الطرفين.", "simplex-unique-overlay-card-4-p-1": "يمكنك استخدام SimpleX مع خوادمك والاستمرار في التواصل مع الأشخاص الذين يستخدمون الخوادم المُهيأة مسبقًا في التطبيقات.", - "simplex-unique-overlay-card-4-p-3": "إذا كنت تفكر في التطوير شبكة SimpleX، على سبيل المثال، بوت الدردشة لمستخدمي تطبيق SimpleX، أو دمج مكتبة SimpleX Chat في تطبيقات الأجهزة المحمولة، من فضلك تواصل معي لأي نصيحة والدعم.", + "simplex-unique-overlay-card-4-p-3": "إذا كنت تفكر في التطوير شبكة SimpleX، على سبيل المثال، بوت الدردشة لمستخدمي تطبيق SimpleX، أو دمج مكتبة SimpleX Chat في تطبيقات الأجهزة المحمولة، من فضلك تواصل معي لأي نصيحة والدعم.", "donate-here-to-help-us": "تبرّع هنا لمساعدتنا", "sign-up-to-receive-our-updates": "اشترك للحصول على آخر مستجداتنا", "enter-your-email-address": "أدخل عنوان بريدك الإلكتروني", @@ -209,7 +209,7 @@ "simplex-network-2-desc": "لا تقوم خوادم الترحيل SimpleX بتخزين ملفات تعريف المستخدمين وجهات الاتصال والرسائل التي تم تسليمها، ولا تتصل ببعضها البعض، ولا يوجد دليل خوادم.", "comparison-point-1-text": "يتطلب هوية عالمية", "protocol-3-text": "بروتوكولات P2P", - "simplex-unique-overlay-card-4-p-2": "تستخدم شبكة بروتوكول مفتوح وتوفرSDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي ستبنيها.", + "simplex-unique-overlay-card-4-p-2": "تستخدم شبكة بروتوكول مفتوح وتوفرSDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي ستبنيها.", "guide-dropdown-1": "بداية سريعة", "guide-dropdown-2": "إرسال الرسائل", "guide-dropdown-3": "مجموعات سرية", diff --git a/website/langs/cs.json b/website/langs/cs.json index 90bad228aa..0df72eeab9 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -1,7 +1,7 @@ { "simplex-private-card-10-point-2": "Umožňuje doručovat zprávy bez identifikátoru uživatelských profilů, což poskytuje lepší soukromí metadat než alternativy.", "simplex-unique-4-overlay-1-title": "Plně decentralizované — uživatelé vlastní síť SimpleX", - "hero-overlay-card-1-p-6": "Více v SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Více v SimpleX whitepaper.", "hero-overlay-card-1-p-2": "K doručování zpráv používá SimpleX namísto ID uživatelů používaných všemi ostatními sítěmi, dočasné anonymní párové identifikátory front zpráv, oddělené pro každé z vašich připojení — neexistují žádné dlouhodobé identifikátory.", "hero-overlay-card-1-p-3": "Definujete, které servery se mají používat k přijímání zpráv, vašich kontaktů — servery, které používáte k odesílání zpráv. Každá konverzace bude pravděpodobně používat dva různé servery.", "hero-overlay-card-2-p-3": "I v těch nejsoukromějších aplikacích, které používají služby Tor v3, pokud mluvíte se dvěma různými kontakty prostřednictvím stejného profilu, může být prokázáno, že jsou spojeni se stejnou osobou.", @@ -89,11 +89,11 @@ "simplex-network-overlay-card-1-li-3": "P2P neřeší problém MITM útoku a většina existujících implementací nepoužívá out-of-band zprávy pro počáteční výměnu klíčů. SimpleX používá out-of-band zprávy nebo v některých případech již existující zabezpečená a důvěryhodná připojení pro počáteční výměnu klíčů.", "simplex-network-overlay-card-1-li-4": "Implementace P2P mohou být některými poskytovateli internetu blokovány (například BitTorrent). SimpleX je transportně nezávislý — může pracovat přes standardní webové protokoly, např. WebSockety.", "privacy-matters-overlay-card-1-p-1": "Mnoho velkých společností používá informace o tom, s kým jste ve spojení, k odhadu vašich příjmů, prodeji produktů, které ve skutečnosti nepotřebujete, a ke stanovení cen.", - "privacy-matters-overlay-card-1-p-3": "Některé finanční a pojišťovací společnosti používají sociální grafy k určení úrokových sazeb a pojistného. Často přiměje lidi s nižšími příjmy platit více —, je to známé jako \"prémie za chudobu\".", - "privacy-matters-overlay-card-2-p-1": "Není to tak dávno, co jsme pozorovali, jak velké volby zmanipulovala renomovaná poradenská společnost, která používala naše sociální grafy ke zkreslení našeho pohledu na skutečný svět a manipulovala s našimi hlasy.", + "privacy-matters-overlay-card-1-p-3": "Některé finanční a pojišťovací společnosti používají sociální grafy k určení úrokových sazeb a pojistného. Často přiměje lidi s nižšími příjmy platit více —, je to známé jako \"prémie za chudobu\".", + "privacy-matters-overlay-card-2-p-1": "Není to tak dávno, co jsme pozorovali, jak velké volby zmanipulovala renomovaná poradenská společnost, která používala naše sociální grafy ke zkreslení našeho pohledu na skutečný svět a manipulovala s našimi hlasy.", "privacy-matters-overlay-card-2-p-3": "SimpleX je první síť, která nemá žádné uživatelské identifikátory záměrně, a tímto způsobem chrání váš graf připojení lépe než jakákoli známá alternativa.", "privacy-matters-overlay-card-3-p-1": "Každý by se měl starat o soukromí a bezpečnost své komunikace — neškodné rozhovory vás mohou vystavit nebezpečí, i když nemáte co skrývat.", - "privacy-matters-overlay-card-3-p-3": "Obyčejní lidé jsou zatčeni za to, co sdílejí online, dokonce i prostřednictvím svých \"anonymních\" účtů, i v demokratických zemích.", + "privacy-matters-overlay-card-3-p-3": "Obyčejní lidé jsou zatčeni za to, co sdílejí online, dokonce i prostřednictvím svých \"anonymních\" účtů, i v demokratických zemích.", "privacy-matters-overlay-card-3-p-4": "Nestačí používat end-to-end šifrovaný messenger, všichni bychom měli používat messengery, které chrání soukromí našich osobních sítí — s kým jsme spojeni.", "simplex-unique-overlay-card-1-p-3": "Tento design chrání soukromí těch, s kým komunikujete, skrývá ho před servery sítě SimpleX a před jakýmikoli pozorovateli. Pro skrytí své IP adresy před servery, můžete se připojit k serverům SimpleX přes Tor.", "simplex-unique-overlay-card-2-p-1": "Protože na síti SimpleX nemáte žádný identifikátor, nikdo vás nemůže kontaktovat pokud nenasdílíte jednorázovou nebo dočasnou uživatelskou adresu, jako QR kód nebo odkaz.", @@ -101,7 +101,7 @@ "simplex-unique-overlay-card-3-p-2": "End-to-end šifrované zprávy jsou dočasně uchovávány na přenosových serverech SimpleX, dokud nejsou přijaty, poté jsou trvale odstraněny.", "simplex-unique-overlay-card-3-p-3": "Na rozdíl od serverů federovaných sítí (e-mail, XMPP nebo Matrix) servery SimpleX neukládají uživatelské účty, pouze předávají zprávy, čímž chrání soukromí obou stran.", "simplex-unique-overlay-card-4-p-1": "Můžete použít SimpleX se svými vlastními servery a přesto komunikovat s lidmi, kteří používají přednastavené servery v aplikacích.", - "simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím buďte ve spojení pro jakoukoli radu a podporu.", + "simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím buďte ve spojení pro jakoukoli radu a podporu.", "simplex-unique-card-1-p-1": "SimpleX chrání soukromí vašeho profilu, kontaktů a metadat a skrývá je před servery SimpleX sítě a jakýmikoli pozorovateli.", "simplex-unique-card-1-p-2": "Na rozdíl od jakékoli jiné existující síti pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům — ani náhodná čísla.", "simplex-unique-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí přenosného šifrovaného databázového formátu, který lze exportovat a přenést na jakékoli podporované zařízení.", @@ -181,14 +181,14 @@ "privacy-matters-overlay-card-1-p-4": "SimpleX síť chrání soukromí vašich připojení lépe než jakákoli jiná alternativa a plně zabraňuje tomu, aby byl váš sociální graf dostupný všem společnostem nebo organizacím. I když lidé používají servery přednastavené v aplikaci SimpleX Chat, operátoři serverů neznají počet uživatelů ani jejich připojení.", "privacy-matters-overlay-card-2-p-2": "Chcete-li být objektivní a činit nezávislá rozhodnutí, musíte mít svůj informační prostor pod kontrolou. Je to možné pouze v případě, že používáte soukromou komunikační síť, která nemá přístup k vašemu sociálnímu grafu.", "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery.", - "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", + "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", "simplex-unique-overlay-card-1-p-1": "Na rozdíl od jiných sítí pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům. Nespoléhá na telefonní čísla, adresy založené na doméně (jako e-mail nebo XMPP), uživatelská jména, veřejné klíče nebo dokonce náhodná čísla k identifikaci svých uživatelů — Operátoři SimpleX serverů neví, kolik lidí používá jejich servery.", "invitation-hero-header": "Byl vám zaslán odkaz pro připojení na SimpleX Chat", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí přenosného šifrovaného databázového formátu, který lze exportovat a přenést na jakékoli podporované zařízení.", "contact-hero-p-1": "Veřejné klíče a adresa fronty zpráv v tomto odkazu NEJSOU při zobrazení této stránky odesílány přes síť — jsou obsaženy ve fragmentu kontrolního součtu adresy URL odkazu.", "simplex-unique-overlay-card-3-p-4": "Mezi odeslaným a přijatým provozem serveru nejsou žádné společné identifikátory ani šifrovaný text — pokud to někdo pozoruje, nemůže snadno určit, kdo s kým komunikuje, i když je TLS kompromitován.", "simplex-unique-card-2-p-1": "Protože na SimpleX síti nemáte žádný identifikátor nebo pevnou adresu, nikdo vás nemůže kontaktovat pokud nenasdílíte jednorázovou nebo dočasnou uživatelskou adresu, jako QR kód nebo odkaz.", - "simplex-unique-overlay-card-4-p-2": "SimpleX síť používá otevřený protokol a poskytuje SDK k vytváření chat botů, což umožní implementaci služeb, díky nimž boudou moci uživatelé komunikovat prostřednictvím aplikací SimpleX Chat — Opravdu se těšíme, jaké služby SimpleX vytvoříte.", + "simplex-unique-overlay-card-4-p-2": "SimpleX síť používá otevřený protokol a poskytuje SDK k vytváření chat botů, což umožní implementaci služeb, díky nimž boudou moci uživatelé komunikovat prostřednictvím aplikací SimpleX Chat — Opravdu se těšíme, jaké služby SimpleX vytvoříte.", "simplex-network": "SimpleX síť", "simplex-explained-tab-2-p-1": "Pro každé připojení používáte dvě samostatné fronty zasílání zpráv k odesílání a přijímání zpráv prostřednictvím různých serverů.", "simplex-explained-tab-1-p-1": "Můžete vytvářet kontakty a skupiny a vést obousměrné konverzace, stejně jako v jakémkoli jiném messengeru.", @@ -201,7 +201,7 @@ "simplex-private-8-title": "Smíchání zpráv
ke snížení korelace", "simplex-unique-1-overlay-1-title": "Úplné soukromí vaší identity, profilu, kontaktů a metadat", "simplex-private-card-2-point-1": "Další vrstva šifrování serveru pro doručení příjemci, aby se zabránilo korelaci mezi přijatým a odeslaným serverovým provozem, pokud je protokol TLS ohrožen.", - "simplex-private-card-4-point-2": "Pro použití SimpleX přes Tor prosím nainstalujte Orbot aplikaci a povolte SOCKS5 proxy (nebo VPN na iOS).", + "simplex-private-card-4-point-2": "Pro použití SimpleX přes Tor prosím nainstalujte Orbot aplikaci a povolte SOCKS5 proxy (nebo VPN na iOS).", "simplex-private-card-5-point-1": "SimpleX používá odsazení obsahu pro každou šifrovací vrstvu, aby zmařil útoky na velikost zprávy.", "simplex-private-card-6-point-2": "Aby se tomu zabránilo, aplikace SimpleX předávají jednorázové klíče mimo pásmo, když sdílíte adresu jako odkaz nebo QR kód.", "simplex-private-card-8-point-1": "Servery SimpleX fungují jako smíšené uzly s nízkou latencí — příchozí a odchozí zprávy mají různá pořadí.", diff --git a/website/langs/de.json b/website/langs/de.json index ac35879b3a..34cecb8e4d 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -62,7 +62,7 @@ "simplex-private-card-3-point-2": "Server-Fingerabdrücke und Kanalbindung verhindern MITM- und Replay-Angriffe.", "simplex-private-card-3-point-3": "Die Wiederaufnahme von Verbindungen ist deaktiviert, um Sitzungs-Angriffe zu verhindern.", "simplex-private-card-4-point-1": "Um Ihre IP-Adresse zu schützen, können Sie per Tor oder irgendeinem anderen Transportschichten-Netzwerk auf Server zugreifen.", - "simplex-private-card-4-point-2": "Um SimpleX per Tor zu nutzen, installieren Sie unter Android bitte die Orbot-App und aktivieren Sie den SOCKS5-Proxy oder unter iOS per VPN.", + "simplex-private-card-4-point-2": "Um SimpleX per Tor zu nutzen, installieren Sie unter Android bitte die Orbot-App und aktivieren Sie den SOCKS5-Proxy oder unter iOS per VPN.", "simplex-private-card-5-point-1": "SimpleX nutzt Inhalteauffüllung für jede Verschlüsselungs-Schicht, um Angriffe auf die Nachrichtengröße zu vereiteln.", "simplex-private-card-5-point-2": "Erzeugt Nachrichten mit unterschiedlichen Größen, die für Server und Netzwerk-Beobachter identisch aussehen.", "simplex-private-card-6-point-1": "Viele Kommunikations-Netzwerke sind für MITM-Angriffe durch Server oder Netzwerk-Anbieter anfällig.", @@ -93,7 +93,7 @@ "simplex-private-card-8-point-1": "Die SimpleX-Server arbeiten als Mix-Knoten mit geringer Verzögerung — eingehende und ausgehende Nachrichten haben eine unterschiedliche Reihenfolge.", "hero-overlay-card-1-p-3": "Sie definieren, welche(n) Server Sie für den Empfang von Nachrichten nutzen. Ihre Kontakte — nutzen diese Server, um ihnen Nachrichten darüber zu senden. Jede Konversation nutzt üblicherweise also zwei unterschiedliche Server.", "hero-overlay-card-1-p-5": "Die Benutzer-Profile, Kontakte und Gruppen werden nur auf den Endgerät des Nutzers gespeichert. Die Nachrichten werden mit einer 2-Schichten-Ende-zu-Ende-Verschlüsselung versendet.", - "hero-overlay-card-1-p-6": "Lesen Sie mehr darüber im SimpleX Whitepaper.", + "hero-overlay-card-1-p-6": "Lesen Sie mehr darüber im SimpleX Whitepaper.", "hero-overlay-card-2-p-2": "Sie können diese Informationen mit bestehenden öffentlichen sozialen Netzwerken korrelieren und damit wahre Identitäten herausfinden.", "hero-overlay-card-2-p-3": "Wenn Sie sich mit zwei unterschiedlichen Kontakten über dasselbe Profil unterhalten, können sie, selbst bei sehr auf Privatsphäre bedachten Apps, die Tor-v3-Dienste nutzen, feststellen, dass diese Kontakte mit derselben Person verbunden sind.", "hero-overlay-card-1-p-2": "Um Nachrichten auszuliefern, nutzt SimpleX statt Benutzerkennungen wie alle anderen Netzwerke nur temporäre, anonyme und paarweise Kennungen für Nachrichten-Warteschlangen, die für jede Ihrer Verbindungen unterschiedlich sind — es gibt keinerlei Langzeit-Kennungen.", @@ -106,7 +106,7 @@ "simplex-network-overlay-card-1-li-6": "P2P-Netzwerke können anfällig für DRDoS-Angriffe sein, wenn die Clients den Datenverkehr erneut senden und verstärken können, was zu einem netzwerkweiten Denial-of-Service führt. SimpleX-Clients leiten nur Datenverkehr von bekannten Verbindungen weiter und können von einem Angreifer nicht dazu verwendet werden, den Datenverkehr im gesamten Netzwerk zu verstärken.", "privacy-matters-overlay-card-1-p-1": "Viele große Unternehmen nutzen Informationen, mit wem Sie in Verbindung stehen, um Ihr Einkommen zu schätzen, Ihnen Produkte zu verkaufen, die Sie nicht wirklich benötigen und um die Preise zu bestimmen.", "privacy-matters-overlay-card-1-p-2": "Online-Händler wissen, dass Menschen mit geringerem Einkommen eher dringende Einkäufe tätigen, sodass sie möglicherweise höhere Preise verlangen können oder Rabatte streichen.", - "privacy-matters-overlay-card-1-p-3": "Einige Finanz- und Versicherungsunternehmen verwenden soziale Graphen, um Zinssätze und Prämien zu ermitteln. Menschen mit niedrigerem Einkommen zahlen so häufig mehr — dies ist als \"Armutsprämie\" bekannt.", + "privacy-matters-overlay-card-1-p-3": "Einige Finanz- und Versicherungsunternehmen verwenden soziale Graphen, um Zinssätze und Prämien zu ermitteln. Menschen mit niedrigerem Einkommen zahlen so häufig mehr — dies ist als \"Armutsprämie\" bekannt.", "privacy-matters-overlay-card-2-p-2": "Um objektiv zu sein und unabhängige Entscheidungen treffen zu können, müssen Sie die Kontrolle über Ihren Informationsraum haben. Dies ist nur möglich, wenn Sie ein privates Kommunikations-Netzwerk verwenden, welches keinen Zugriff auf Ihren sozialen Graphen hat.", "privacy-matters-overlay-card-2-p-3": "SimpleX ist das erste Netzwerk, das konzeptionell ohne Benutzerkennungen auskommt und dadurch Ihren Verbindungsgraphen besser schützt als jede bekannte Alternative.", "privacy-matters-overlay-card-3-p-1": "Jede Person sollte sich um ihre Privatsphäre und die Sicherheit ihrer Kommunikation kümmern — Harmlose Gespräche könnten Sie in Gefahr bringen, selbst wenn Sie nichts zu verbergen haben.", @@ -176,8 +176,8 @@ "comparison-section-list-point-7": "P2P-Netzwerke haben entweder eine zentrale Verwaltung oder das gesamte Netzwerk kann kompromittiert werden", "see-here": "Siehe hier", "simplex-unique-card-4-p-1": "Das SimpleX-Netzwerk ist vollständig dezentralisiert und unabhängig von Kryptowährungen oder anderen Netzwerken außer dem Internet.", - "privacy-matters-overlay-card-2-p-1": "Vor nicht allzu langer Zeit beobachteten wir, wie große Wahlen von einem angesehenen Beratungsunternehmen manipuliert wurden, welches unsere sozialen Graphen nutzte, um unsere Sicht auf die reale Welt zu verzerren und unsere Stimmen zu manipulieren.", - "privacy-matters-overlay-card-3-p-2": "Eine der schockierendsten Geschichten ist die Erfahrung von Mohamedou Ould Salahi, welche in seinen Memoiren beschrieben und im Film „The Mauritanian“ gezeigt wird. Er kam nach einem Anruf bei seinen Verwandten in Afghanistan und ohne Gerichtsverfahren in das Guantanamo-Lager und wurde dort einige Jahre lang gefoltert, weil er verdächtigt wurde, an den 9/11-Angriffen beteiligt gewesen zu sein, obwohl er die vorhergehenden 10 Jahre in Deutschland gelebt hatte.", + "privacy-matters-overlay-card-2-p-1": "Vor nicht allzu langer Zeit beobachteten wir, wie große Wahlen von einem angesehenen Beratungsunternehmen manipuliert wurden, welches unsere sozialen Graphen nutzte, um unsere Sicht auf die reale Welt zu verzerren und unsere Stimmen zu manipulieren.", + "privacy-matters-overlay-card-3-p-2": "Eine der schockierendsten Geschichten ist die Erfahrung von Mohamedou Ould Salahi, welche in seinen Memoiren beschrieben und im Film „The Mauritanian“ gezeigt wird. Er kam nach einem Anruf bei seinen Verwandten in Afghanistan und ohne Gerichtsverfahren in das Guantanamo-Lager und wurde dort einige Jahre lang gefoltert, weil er verdächtigt wurde, an den 9/11-Angriffen beteiligt gewesen zu sein, obwohl er die vorhergehenden 10 Jahre in Deutschland gelebt hatte.", "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Netzwerken weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder Zufallszahlen, um seine Benutzer zu identifizieren — selbst SimpleX Server-Betreiber wissen nicht, wie viele Personen deren Server verwenden.", "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden.", "simplex-network-overlay-card-1-li-5": "Alle bekannten P2P-Netzwerke können anfällig für Sybil-Angriffe sein, da jeder Knoten ermittelbar ist und das Netzwerk als Ganzes funktioniert. Bekannte Maßnahmen zur Verhinderung erfordern entweder eine zentralisierte Komponente oder einen teuren Ausführungsnachweis. Das SimpleX-Netzwerk bietet keine Ermittlung der Server, ist fragmentiert und arbeitet mit mehreren isolierten Subnetzwerken, wodurch netzwerkweite Angriffe unmöglich werden.", @@ -197,14 +197,14 @@ "comparison-section-list-point-5": "Die Privatsphäre-Metadaten des Nutzers werden nicht geschützt", "simplex-network-overlay-card-1-li-1": "P2P-Netzwerke vertrauen auf Varianten von DHT, um Nachrichten zu routen. DHT-Designs müssen zwischen Zustellungsgarantie und Latenz ausgleichen. Verglichen mit P2P bietet SimpleX sowohl eine bessere Zustellungsgarantie als auch eine niedrigere Latenz, weil eine Nachricht redundant und parallel über mehrere Server gesendet werden kann, wobei die durch den Empfänger ausgewählten Server genutzt werden. In P2P-Netzwerken werden Nachrichten sequentiell über O(log N)-Knoten gesendet, wobei die Knoten durch einen Algorithmus ausgewählt werden.", "simplex-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und dem empfangenen Serververkehr gibt es keine gemeinsamen Kennungen oder Chiffriertexte — sodass ein Beobachter nicht ohne weiteres feststellen kann, wer mit wem kommuniziert, selbst wenn TLS kompromittiert wurde.", - "simplex-unique-overlay-card-4-p-3": "Falls Sie Interesse daran haben, aktiv bei der Entwicklung des SimpleX-Netzwerks mitzuhelfen, z.B. einen Chatbot für SimpleX App-Nutzer zu entwickeln oder die Integration der SimpleX Chat-Bibliothek in mobile Apps voranzutreiben, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", + "simplex-unique-overlay-card-4-p-3": "Falls Sie Interesse daran haben, aktiv bei der Entwicklung des SimpleX-Netzwerks mitzuhelfen, z.B. einen Chatbot für SimpleX App-Nutzer zu entwickeln oder die Integration der SimpleX Chat-Bibliothek in mobile Apps voranzutreiben, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", "privacy-matters-overlay-card-1-p-4": "Das SimpleX-Netzwerk schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen einsehbar wird. Selbst wenn Anwender die in der SimpleX Chat-App vorkonfigurierten Server verwenden, kennen die Server-Betreiber die Anzahl der Benutzer oder deren Verbindungen nicht.", "contact-hero-header": "Sie haben eine Adresse zur Verbindung mit SimpleX Chat erhalten", "invitation-hero-header": "Sie haben einen Einmal-Link zur Verbindung mit SimpleX Chat erhalten", - "privacy-matters-overlay-card-3-p-3": "Selbst in demokratischen Ländern werden normale Menschen, auch unter Nutzung ihrer „anonymen“ Benutzerkonten, für das, was sie online teilen, verhaftet.", + "privacy-matters-overlay-card-3-p-3": "Selbst in demokratischen Ländern werden normale Menschen, auch unter Nutzung ihrer „anonymen“ Benutzerkonten, für das, was sie online teilen, verhaftet.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat speichert alle Benutzerdaten ausschließlich auf den Endgeräten in einem portablen und verschlüsselten Datenbankformat, welches exportiert und auf jedes unterstützte Gerät übertragen werden kann.", "simplex-unique-overlay-card-2-p-2": "Auch wenn die optionale Benutzeradresse zum Versenden von Spam-Kontaktanfragen verwendet werden kann, können Sie sie ändern oder ganz löschen, ohne dass Ihre Verbindungen verloren gehen.", - "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX Chat-Apps interagieren können — wir freuen uns sehr darauf zu sehen, welche SimpleX-Dienste Sie entwickeln werden.", + "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX Chat-Apps interagieren können — wir freuen uns sehr darauf zu sehen, welche SimpleX-Dienste Sie entwickeln werden.", "simplex-unique-card-4-p-2": "Sie können SimpleX mit eigenen Servern oder mit den von uns zur Verfügung gestellten Servern verwenden — und sich trotzdem mit jedem Benutzer verbinden.", "why-simplex-is-unique": "Warum ist SimpleX einmalig", "contact-hero-p-1": "Die öffentlichen Schlüssel und die Adresse der Nachrichtenwarteschlange in diesem Link werden NICHT über das Netzwerk gesendet, wenn Sie diese Seite aufrufen — sie sind in dem Hash-Fragment der Link-URL enthalten.", diff --git a/website/langs/en.json b/website/langs/en.json index 24dde29b39..6138b34c8c 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -66,7 +66,7 @@ "simplex-private-card-3-point-2": "Server fingerprint and channel binding prevent MITM and replay attacks.", "simplex-private-card-3-point-3": "Connection resumption is disabled to prevent session attacks.", "simplex-private-card-4-point-1": "To protect your IP address you can access the servers via Tor or some other transport overlay network.", - "simplex-private-card-4-point-2": "To use SimpleX via Tor please install Orbot app and enable SOCKS5 proxy (or VPN on iOS).", + "simplex-private-card-4-point-2": "To use SimpleX via Tor please install Orbot app and enable SOCKS5 proxy (or VPN on iOS).", "simplex-private-card-5-point-1": "SimpleX uses content padding for each encryption layer to frustrate message size attacks.", "simplex-private-card-5-point-2": "It makes messages of different sizes look the same to the servers and network observers.", "simplex-private-card-6-point-1": "Many communication networks are vulnerable to MITM attacks by servers or network providers.", @@ -100,7 +100,7 @@ "hero-overlay-card-1-p-3": "You define which server(s) to use to receive the messages, your contacts — the servers you use to send the messages to them. Every conversation is likely to use two different servers.", "hero-overlay-card-1-p-4": "This design prevents leaking any users' metadata on the application level. To further improve privacy and protect your IP address you can connect to messaging servers via Tor.", "hero-overlay-card-1-p-5": "Only client devices store user profiles, contacts and groups; the messages are sent with 2-layer end-to-end encryption.", - "hero-overlay-card-1-p-6": "Read more in SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Read more in SimpleX whitepaper.", "hero-overlay-card-2-p-1": "When users have persistent identities, even if this is just a random number, like a Session ID, there is a risk that the provider or an attacker can observe how the users are connected and how many messages they send.", "hero-overlay-card-2-p-2": "They could then correlate this information with the existing public social networks, and determine some real identities.", "hero-overlay-card-2-p-3": "Even with the most private apps that use Tor v3 services, if you talk to two different contacts via the same profile they can prove that they are connected to the same person.", @@ -117,14 +117,14 @@ "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connections and cannot be used by an attacker to amplify the traffic in the whole network.", "privacy-matters-overlay-card-1-p-1": "Many large companies use information about who you are connected with to estimate your income, sell you the products you don't really need, and to determine the prices.", "privacy-matters-overlay-card-1-p-2": "Online retailers know that people with lower incomes are more likely to make urgent purchases, so they may charge higher prices or remove discounts.", - "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as \"poverty premium\".", + "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as \"poverty premium\".", "privacy-matters-overlay-card-1-p-4": "SimpleX network protects the privacy of your connections better than any alternative, fully preventing your social graph from becoming available to any companies or organizations. Even when people use servers preconfigured in SimpleX Chat apps, server operators do not know the number of users or their connections.", - "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", + "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use a private communication network that does not have access to your social graph.", "privacy-matters-overlay-card-2-p-3": "SimpleX is the first network that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", "privacy-matters-overlay-card-3-p-1": "Everyone should care about privacy and security of their communications — harmless conversations can put you in danger, even if you have nothing to hide.", - "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", - "privacy-matters-overlay-card-3-p-3": "Ordinary people get arrested for what they share online, even via their \"anonymous\" accounts, even in democratic countries.", + "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", + "privacy-matters-overlay-card-3-p-3": "Ordinary people get arrested for what they share online, even via their \"anonymous\" accounts, even in democratic countries.", "privacy-matters-overlay-card-3-p-4": "It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks — who we are connected with.", "simplex-unique-overlay-card-1-p-1": "Unlike other messaging networks, SimpleX has no identifiers assigned to the users. It does not rely on phone numbers, domain-based addresses (like email or XMPP), usernames, public keys or even random numbers to identify its users — SimpleX server operators don't know how many people use their servers.", "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers.", @@ -136,8 +136,8 @@ "simplex-unique-overlay-card-3-p-3": "Unlike federated networks servers (email, XMPP or Matrix), SimpleX servers don't store user accounts, they only relay messages, protecting the privacy of both parties.", "simplex-unique-overlay-card-3-p-4": "There are no identifiers or ciphertext in common between sent and received server traffic — if anybody is observing it, they cannot easily determine who communicates with whom, even if TLS is compromised.", "simplex-unique-overlay-card-4-p-1": "You can use SimpleX with your own servers and still communicate with people who use the servers preconfigured in the apps.", - "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to seeing what SimpleX services you will build.", - "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", + "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to seeing what SimpleX services you will build.", + "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", "simplex-unique-card-1-p-1": "SimpleX protects the privacy of your profile, contacts and metadata, hiding it from SimpleX network servers and any observers.", "simplex-unique-card-1-p-2": "Unlike any other existing messaging network, SimpleX has no identifiers assigned to the users — not even random numbers.", "simplex-unique-card-2-p-1": "Because you have no identifier or fixed address on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", @@ -291,7 +291,7 @@ "index-token-p2": "This will cover infrastructure, software development and network governance.", "index-token-cta": "Learn more about Community Credits", "index-token-p2-cf": "And the users can now invest in SimpleX Chat — the equity crowdfunding is live!", - "index-token-cta-cf": "Get a stake in SimpleX Chat", + "index-token-cta-cf": "Invest in SimpleX Chat", "index-roadmap-h2": "SimpleX Roadmap to Free Internet", "index-roadmap-now": "Now", "index-roadmap-1": "2026", diff --git a/website/langs/es.json b/website/langs/es.json index c5d4c5291e..22b69cfbeb 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -32,7 +32,7 @@ "hero-p-1": "Las demás aplicaciones usan ID de usuario: Signal, Matrix, Session, Briar, Jami, Cwtch, etc.
SimpleX no los tiene, ni siquiera usa números aleatorios.
Esto mejora radicalmente tu privacidad.", "hero-2-header-desc": "El vídeo muestra cómo te conectas con tus amistades a través del código QR de un solo uso, en persona o a través de videollamada. También puedes conectarte compartiendo un enlace de invitación.", "feature-7-title": "Almacenamiento portable y cifrado, podrás transferir tu perfil a otro dispositivo", - "simplex-private-card-4-point-2": "Para usar SimpleX a través de Tor, instala la aplicación Orbot y activa el proxy SOCKS5 (o VPN en iOS).", + "simplex-private-card-4-point-2": "Para usar SimpleX a través de Tor, instala la aplicación Orbot y activa el proxy SOCKS5 (o VPN en iOS).", "simplex-private-card-3-point-1": "Para las conexiones cliente servidor se usan exclusivamente el protocolo TLS 1.2/1.3 con algoritmos robustos.", "simplex-private-card-4-point-1": "Para proteger tu dirección IP, puedes acceder a los servidores a través Tor u otra red superpuesta de transporte.", "simplex-private-card-5-point-2": "Esto hace que mensajes de distintos tamaños parezcan iguales desde el punto de vista de servidores y observadores de red.", @@ -84,7 +84,7 @@ "simplex-unique-4-title": "La red SimpleX te pertenece", "hero-overlay-card-1-p-1": "Muchos usuarios se preguntan: si SimpleX no tiene identificadores de usuario, ¿cómo puede saber dónde entregar los mensajes?", "hero-overlay-card-1-p-4": "Este diseño evita que se filtren metadatos de usuario a nivel de aplicación. Para mejorar aún más la privacidad y proteger tu dirección IP, puedes conectarte a los servidores de mensajería a través de la red Tor.", - "hero-overlay-card-1-p-6": "Encontrarás más información en el SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Encontrarás más información en el SimpleX whitepaper.", "hero-overlay-card-2-p-1": "Cuando los usuarios asumen identidades persistentes, aunque sólo se trate de un número aleatorio como un identificador de sesión, existe el riesgo de que el proveedor de red o un atacante puedan observar cómo se conectan y la cantidad de mensajes que envían.", "hero-overlay-card-2-p-2": "A continuación podrían correlacionar esta información con las redes sociales públicas existentes y averiguar las identidades reales de los usuarios.", "hero-overlay-card-2-p-3": "Incluso con las aplicaciones más privadas que usan los servicios de Tor v3, si hablas con dos contactos a través de un mismo perfil se puede probar que estos están conectados con la misma persona.", @@ -105,7 +105,7 @@ "simplex-network-overlay-card-1-li-3": "P2P no resuelve el problema del ataque MITM , y la mayoría de implementaciones existentes no usan mensajes fuera de banda para el intercambio de claves inicial. SimpleX usa mensajes fuera de banda, o en algunos casos conexiones seguras y de confianza preexistentes para el intercambio inicial de claves.", "simplex-network-overlay-card-1-li-4": "Algunos proveedores de Internet pueden bloquear las aplicaciones P2P (como BitTorrent). SimpleX es independiente del transporte. Puede trabajar con protocolos web estándar, por ejemplo WebSockets.", "privacy-matters-overlay-card-1-p-4": "La red SimpleX protege la privacidad de tus conexiones mejor que cualquier alternativa, impidiendo por completo que tu gráfica social esté disponible para empresas u organizaciones. Incluso cuando se emplean los servidores configurados en SimpleX Chat, los operadores de servidores desconocen el número de usuarios o sus conexiones.", - "privacy-matters-overlay-card-2-p-1": "Hace poco vimos cómo las principales elecciones eran manipuladas por una reputada compañía consultora que usaba nuestras gráficas sociales para distorsionar nuestra visión del mundo real y manipular nuestros votos.", + "privacy-matters-overlay-card-2-p-1": "Hace poco vimos cómo las principales elecciones eran manipuladas por una reputada compañía consultora que usaba nuestras gráficas sociales para distorsionar nuestra visión del mundo real y manipular nuestros votos.", "privacy-matters-overlay-card-2-p-2": "Para ser objetivo y tomar decisiones independientes necesitas tener el controlar de tu espacio de información. Sólo es posible si usas una red de comunicaciónes privada que no tenga acceso a tu gráfica social.", "privacy-matters-overlay-card-2-p-3": "SimpleX es la primera red que por diseño no tiene identificadores de usuario, protegiendo así tu gráfica de conexiones mejor que cualquier alternativa conocida.", "privacy-matters-overlay-card-3-p-1": "Todos deberían preocuparse por la privacidad y la seguridad de sus comunicaciones. Las conversaciones inofensivas pueden ponerte en peligro incluso aunque no tengas nada que ocultar.", @@ -119,10 +119,10 @@ "simplex-network-overlay-card-1-li-6": "Las redes P2P pueden ser vulnerables al ataque DRDoS, en el que los clientes retransmiten y amplifican el tráfico provocando el bloqueo del servicio en la red. Los clientes SimpleX sólo transmiten el tráfico desde conexiones conocidas y no pueden ser usados por un atacante para amplificar el tráfico en toda la red.", "privacy-matters-overlay-card-1-p-1": "Las grandes empresas usan la información relativa a tus contactos para calcular tus ingresos, venderte productos que realmente no necesitas y para determinar sus precios.", "privacy-matters-overlay-card-1-p-2": "Los minoristas en línea saben que las personas con menos ingresos son más propensas a hacer compras urgentes, por lo que pueden cobrar precios más altos o eliminar descuentos.", - "privacy-matters-overlay-card-1-p-3": "Algunas compañías financieras y de seguros usan las gráficas sociales para determinar primas y tipos de interés. Eso genera a menudo un mayor desembolso a personas con menores ingresos, conocido como \"prima de pobreza\".", - "privacy-matters-overlay-card-3-p-2": "Una de las historias más estremecedoras es la experiencia de Mohamedou Ould Salahi descrita en sus memorias y plasmada en la película The Mauritanian. Fue internado en la prisión militar de Guantánamo sin juicio previo, donde fue torturado durante 15 años tras una llamada telefónica a su pariente en Afganistán, bajo la sospecha de estar implicado en los atentados del 11-S a pesar de que vivía en Alemania desde hacía 10 años.", + "privacy-matters-overlay-card-1-p-3": "Algunas compañías financieras y de seguros usan las gráficas sociales para determinar primas y tipos de interés. Eso genera a menudo un mayor desembolso a personas con menores ingresos, conocido como \"prima de pobreza\".", + "privacy-matters-overlay-card-3-p-2": "Una de las historias más estremecedoras es la experiencia de Mohamedou Ould Salahi descrita en sus memorias y plasmada en la película The Mauritanian. Fue internado en la prisión militar de Guantánamo sin juicio previo, donde fue torturado durante 15 años tras una llamada telefónica a su pariente en Afganistán, bajo la sospecha de estar implicado en los atentados del 11-S a pesar de que vivía en Alemania desde hacía 10 años.", "privacy-matters-overlay-card-3-p-4": "No basta con usar mensajería cifrada de extremo a extremo, todos deberíamos usar las aplicaciones de mensajería que protejan la privacidad de nuestras redes personales y con quién nos comunicamos.", - "privacy-matters-overlay-card-3-p-3": "Personas corrientes son detenidas por lo que comparten en Internet, incluso a través de sus cuentas \"anónimas\" e incluso en países democráticos.", + "privacy-matters-overlay-card-3-p-3": "Personas corrientes son detenidas por lo que comparten en Internet, incluso a través de sus cuentas \"anónimas\" e incluso en países democráticos.", "simplex-unique-overlay-card-1-p-2": "Para la entrega de mensajes, SimpleX usa direcciones anónimas por pares de colas de mensajes unidireccionales separadas para mensajes salientes y entrantes a través de servidores diferentes.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat almacena todos los datos de usuario únicamente en los dispositivos cliente usando un formato cifrado y portable de la base de datos, la cual puede ser exportada y transferida a cualquier dispositivo compatible.", "simplex-unique-overlay-card-3-p-3": "A diferencia de los servidores de redes federadas (correo electrónico, XMPP o Matrix), los servidores SimpleX no almacenan cuentas de usuario, sólo retransmiten mensajes, protegiendo así la privacidad de ambas partes.", @@ -185,8 +185,8 @@ "comparison-section-list-point-6": "A pesar de que las redes P2P son distribuidas, no son federadas. Funcionan como una única red", "comparison-section-list-point-7": "Las redes P2P, o bien disponen de una autoridad central, o bien la red completa podría verse comprometida", "see-here": "ver aquí", - "simplex-unique-overlay-card-4-p-2": "La red SimpleX usa un protocolo abierto y proporciona el SDK para crear chatbots, permitiendo implementar servicios con los que los usuarios interactúen con las aplicaciones SimpleX Chat — esperamos ver con interés los servicios SimpleX que creará usted.", - "simplex-unique-overlay-card-4-p-3": "Si estás considerando desarrollar para la red SimpleX, por ejemplo un chatbot para los usuarios de SimpleX o la integración de las librerías SimpleX Chat en tus aplicaciones móviles, por favor ponte en contacto para consejos y soporte.", + "simplex-unique-overlay-card-4-p-2": "La red SimpleX usa un protocolo abierto y proporciona el SDK para crear chatbots, permitiendo implementar servicios con los que los usuarios interactúen con las aplicaciones SimpleX Chat — esperamos ver con interés los servicios SimpleX que creará usted.", + "simplex-unique-overlay-card-4-p-3": "Si estás considerando desarrollar para la red SimpleX, por ejemplo un chatbot para los usuarios de SimpleX o la integración de las librerías SimpleX Chat en tus aplicaciones móviles, por favor ponte en contacto para consejos y soporte.", "simplex-unique-card-1-p-2": "A diferencia de cualquier otra red de mensajería existente, SimpleX no tiene identificadores asignados a los usuarios, ni siquiera números aleatorios.", "simplex-unique-card-2-p-1": "Al no tener identificadores o dirección permanente en la red SimpleX, nadie puede ponerse en contacto contigo salvo que compartas una dirección de usuario de un solo uso o una dirección temporal en forma de enlace o código QR.", "simplex-unique-card-4-p-1": "La red SimpleX está completamente descentralizada y es independiente de cualquier criptodivisa o de cualquier red salvo Internet.", diff --git a/website/langs/fa.json b/website/langs/fa.json index 0afd6a8f83..5a369c86e2 100644 --- a/website/langs/fa.json +++ b/website/langs/fa.json @@ -63,14 +63,14 @@ "simplex-network-overlay-card-1-li-6": "شبکه‌های P2P ممکن است در معرض حمله DRDoS قرار بگیرند، زمانی که کلاینت‌ها می‌توانند ترافیک را دوباره پخش و تقویت کنند که منجر به عدم دسترسی به خدمات در سطح شبکه می‌شود. کلاینت‌های SimpleX تنها ترافیک را از اتصالات شناخته‌شده منتقل می‌کنند و نمی‌توانند توسط یک مهاجم برای تقویت ترافیک در کل شبکه استفاده شوند.", "privacy-matters-overlay-card-1-p-1": "بسیاری از شرکت‌های بزرگ از اطلاعات مربوط به اینکه با چه کسانی ارتباط دارید، برای تخمین درآمد شما، فروش محصولات غیرضروری به شما و تعیین قیمت‌ها استفاده می‌کنند.", "privacy-matters-overlay-card-1-p-2": "فروشگاه‌های آنلاین می‌دانند که افراد با درآمد پایین‌تر بیشتر احتمال دارد خریدهای فوری انجام دهند، بنابراین ممکن است قیمت‌های بالاتری تعیین کنند یا تخفیف‌ها را حذف کنند.", - "privacy-matters-overlay-card-1-p-3": "برخی از شرکت‌های مالی و بیمه از نمودارهای اجتماعی برای تعیین نرخ‌های بهره و حق بیمه‌ها استفاده می‌کنند. این اغلب باعث می‌شود که افراد با درآمد پایین‌تر بیشتر پرداخت کنند — که به آن 'حق بیمه فقر' گفته می‌شود.", + "privacy-matters-overlay-card-1-p-3": "برخی از شرکت‌های مالی و بیمه از نمودارهای اجتماعی برای تعیین نرخ‌های بهره و حق بیمه‌ها استفاده می‌کنند. این اغلب باعث می‌شود که افراد با درآمد پایین‌تر بیشتر پرداخت کنند — که به آن 'حق بیمه فقر' گفته می‌شود.", "privacy-matters-overlay-card-1-p-4": "شبکه SimpleX حریم خصوصی ارتباطات شما را بهتر از هر گزینه دیگری محافظت می‌کند و به‌طور کامل از در دسترس قرار گرفتن نمودار اجتماعی شما برای هر شرکت یا سازمانی جلوگیری می‌کند. حتی زمانی که افراد از سرورهای پیش‌پیکربندی‌شده در برنامه‌های چت SimpleX استفاده می‌کنند، اپراتورهای سرور از تعداد کاربران یا ارتباطات آن‌ها اطلاعی ندارند.", - "privacy-matters-overlay-card-2-p-1": "مدتی پیش، ما شاهد دستکاری در انتخابات بزرگ توسط یک شرکت مشاوره معتبر بودیم که از نمودارهای اجتماعی ما برای تحریف دیدگاه‌مان نسبت به دنیای واقعی و دستکاری در رأی‌گیری‌مان استفاده کرد.", + "privacy-matters-overlay-card-2-p-1": "مدتی پیش، ما شاهد دستکاری در انتخابات بزرگ توسط یک شرکت مشاوره معتبر بودیم که از نمودارهای اجتماعی ما برای تحریف دیدگاه‌مان نسبت به دنیای واقعی و دستکاری در رأی‌گیری‌مان استفاده کرد.", "privacy-matters-overlay-card-2-p-2": "برای اینکه عینی باشید و تصمیمات مستقل بگیرید، باید کنترل فضای اطلاعات خود را در دست داشته باشید. این تنها در صورتی ممکن است که از یک شبکه ارتباطی خصوصی استفاده کنید که به نمودار اجتماعی شما دسترسی نداشته باشد.", "privacy-matters-overlay-card-2-p-3": "SimpleX اولین شبکه‌ای است که به‌طور ذاتی هیچ شناسه کاربری ندارد و به این ترتیب نمودار ارتباطات شما را بهتر از هر گزینه شناخته‌شده دیگری محافظت می‌کند.", "privacy-matters-overlay-card-3-p-1": "همه باید به حریم خصوصی و امنیت ارتباطات خود اهمیت دهند — گفتگوهای بی‌ضرر می‌توانند شما را در خطر قرار دهند، حتی اگر چیزی برای پنهان کردن نداشته باشید.", - "privacy-matters-overlay-card-3-p-2": "یکی از شگفت‌انگیزترین داستان‌ها، تجربه محمدو ولد صلاحی است که در یادداشت‌هایش توصیف شده و در فیلم \"موریتانی\" به نمایش درآمده است. او بدون محاکمه به اردوگاه گوانتانامو منتقل شد و به مدت ۱۵ سال تحت شکنجه قرار گرفت. این اتفاق پس از یک تماس تلفنی با یکی از بستگانش در افغانستان رخ داد. او به ظن ارتباط با حملات ۱۱ سپتامبر، بدون محاکمه به اردوگاه گوانتانامو منتقل شد.", - "privacy-matters-overlay-card-3-p-3": "افراد عادی به خاطر آنچه که به‌صورت آنلاین به اشتراک می‌گذارند، حتی از طریق حساب‌های 'ناشناس' خود، حتی در کشورهای دموکراتیک دستگیر می‌شوند.", + "privacy-matters-overlay-card-3-p-2": "یکی از شگفت‌انگیزترین داستان‌ها، تجربه محمدو ولد صلاحی است که در یادداشت‌هایش توصیف شده و در فیلم \"موریتانی\" به نمایش درآمده است. او بدون محاکمه به اردوگاه گوانتانامو منتقل شد و به مدت ۱۵ سال تحت شکنجه قرار گرفت. این اتفاق پس از یک تماس تلفنی با یکی از بستگانش در افغانستان رخ داد. او به ظن ارتباط با حملات ۱۱ سپتامبر، بدون محاکمه به اردوگاه گوانتانامو منتقل شد.", + "privacy-matters-overlay-card-3-p-3": "افراد عادی به خاطر آنچه که به‌صورت آنلاین به اشتراک می‌گذارند، حتی از طریق حساب‌های 'ناشناس' خود، حتی در کشورهای دموکراتیک دستگیر می‌شوند.", "privacy-matters-overlay-card-3-p-4": "استفاده از یک پیام‌رسان رمزنگاری شده انتها به انتها کافی نیست؛ همه ما باید از پیام‌رسان‌هایی استفاده کنیم که حریم خصوصی شبکه‌های شخصی ما را محافظت می‌کنند — اینکه با چه کسانی ارتباط داریم.", "simplex-unique-overlay-card-1-p-1": "برخلاف سایر شبکه‌های پیام‌رسان، SimpleX هیچ شناسه‌ای به کاربران اختصاص نمی‌دهد. این شبکه به شماره‌های تلفن، آدرس‌های مبتنی بر دامنه (مانند ایمیل یا XMPP)، نام‌های کاربری، کلیدهای عمومی یا حتی شماره‌های تصادفی برای شناسایی کاربران خود تکیه نمی‌کند — اپراتورهای سرور SimpleX نمی‌دانند چند نفر از سرورهای آن‌ها استفاده می‌کنند.", "simplex-unique-overlay-card-1-p-2": "برای ارسال پیام‌ها، SimpleX از آدرس‌های ناشناس جفتی در صف‌های پیام یک‌طرفه استفاده می‌کند که برای پیام‌های دریافتی و ارسال‌شده جداگانه هستند و معمولاً از طریق سرورهای مختلف انجام می‌شود.", @@ -82,8 +82,8 @@ "simplex-unique-overlay-card-3-p-3": "برخلاف سرورهای شبکه‌های فدرال (ایمیل، XMPP یا Matrix)، سرورهای SimpleX حساب‌های کاربری را ذخیره نمی‌کنند و تنها پیام‌ها را منتقل می‌کنند، که حریم خصوصی هر دو طرف را محافظت می‌کند.", "simplex-unique-overlay-card-3-p-4": "هیچ شناسه یا متن رمز مشترکی بین ترافیک سرور ارسال‌شده و دریافتی وجود ندارد — اگر کسی در حال نظارت باشد، نمی‌تواند به‌راحتی تشخیص دهد که چه کسی با چه کسی ارتباط برقرار می‌کند، حتی اگر TLS به خطر بیفتد.", "simplex-unique-overlay-card-4-p-1": "شما می‌توانید از SimpleX با سرورهای خود استفاده کنید و همچنان با افرادی که از سرورهای پیش‌پیکربندی‌شده در برنامه‌ها استفاده می‌کنند، ارتباط برقرار کنید.", - "simplex-unique-overlay-card-4-p-2": "شبکه SimpleX از یک پروتکل باز استفاده می‌کند و SDK برای ایجاد ربات‌های چت ارائه می‌دهد، که امکان پیاده‌سازی خدماتی را فراهم می‌کند که کاربران می‌توانند از طریق برنامه‌های چت SimpleX با آن‌ها تعامل داشته باشند — ما واقعاً منتظر هستیم ببینیم چه خدماتی را با SimpleX ایجاد خواهید کرد.", - "simplex-unique-overlay-card-4-p-3": "اگر به توسعه برای شبکه SimpleX فکر می‌کنید، مثلاً ربات چت برای کاربران برنامه SimpleX یا ادغام کتابخانه چت SimpleX در برنامه‌های موبایل خود، لطفاً برای هرگونه مشاوره و پشتیبانی با ما تماس بگیرید.", + "simplex-unique-overlay-card-4-p-2": "شبکه SimpleX از یک پروتکل باز استفاده می‌کند و SDK برای ایجاد ربات‌های چت ارائه می‌دهد، که امکان پیاده‌سازی خدماتی را فراهم می‌کند که کاربران می‌توانند از طریق برنامه‌های چت SimpleX با آن‌ها تعامل داشته باشند — ما واقعاً منتظر هستیم ببینیم چه خدماتی را با SimpleX ایجاد خواهید کرد.", + "simplex-unique-overlay-card-4-p-3": "اگر به توسعه برای شبکه SimpleX فکر می‌کنید، مثلاً ربات چت برای کاربران برنامه SimpleX یا ادغام کتابخانه چت SimpleX در برنامه‌های موبایل خود، لطفاً برای هرگونه مشاوره و پشتیبانی با ما تماس بگیرید.", "simplex-unique-card-1-p-1": "SimpleX حریم خصوصی پروفایل، مخاطبین و فراداده‌های شما را محافظت می‌کند و آن‌ها را از سرورهای شبکه SimpleX و هر ناظر دیگری پنهان می‌سازد.", "simplex-unique-card-1-p-2": "برخلاف هر شبکه پیام‌رسان دیگری، SimpleX هیچ شناسه‌ای به کاربران اختصاص نمی‌دهد — حتی شماره‌های تصادفی نیز وجود ندارد.", "simplex-unique-card-2-p-1": "از آنجا که شما هیچ شناسه یا آدرس ثابتی در شبکه SimpleX ندارید، هیچ‌کس نمی‌تواند با شما تماس بگیرد مگر اینکه یک آدرس کاربری یک‌بار مصرف یا موقتی را به‌عنوان کد QR یا لینک به اشتراک بگذارید.", @@ -214,7 +214,7 @@ "simplex-private-card-3-point-2": "اثر انگشت سرور و اتصال کانال از حملات MITM و حملات replay جلوگیری می‌کنند.", "simplex-private-card-3-point-3": "برای جلوگیری از حملات نشست، از سرگیری اتصال غیرفعال شده است.", "simplex-private-card-4-point-1": "برای محافظت از آدرس IP خود، می‌توانید از طریق Tor یا برخی دیگر از شبکه‌های حمل و نقل پوششی به سرورها دسترسی پیدا کنید.", - "simplex-private-card-4-point-2": "برای استفاده از SimpleX از طریق Tor، لطفاً برنامه Orbot را نصب کرده و پروکسی SOCKS5 (یا VPN در iOS) را فعال کنید.", + "simplex-private-card-4-point-2": "برای استفاده از SimpleX از طریق Tor، لطفاً برنامه Orbot را نصب کرده و پروکسی SOCKS5 (یا VPN در iOS) را فعال کنید.", "simplex-private-card-5-point-1": "SimpleX از padding محتوا برای هر لایه رمزنگاری استفاده می‌کند تا از حملات اندازه پیام جلوگیری کند.", "simplex-private-card-5-point-2": "این کار باعث می‌شود که پیام‌های با اندازه‌های مختلف برای سرورها و ناظران شبکه یکسان به نظر برسند.", "simplex-private-card-6-point-1": "بسیاری از شبکه‌های ارتباطی در برابر حملات MITM توسط سرورها یا ارائه‌دهندگان شبکه آسیب‌پذیر هستند.", @@ -248,7 +248,7 @@ "hero-overlay-card-1-p-3": "شما سرورهایی را که برای ارسال پیام‌ها به مخاطبین خود استفاده می‌کنید مشخص می‌کنید و همچنین از کدام سرور(ها) برای دریافت پیام‌ها بهره می‌برید. هر مکالمه احتمالاً از دو سرور مختلف استفاده می‌کند.", "hero-overlay-card-1-p-4": "این طراحی از نشت هرگونه فراداده کاربران در سطح برنامه جلوگیری می‌کند. برای بهبود بیشتر حریم خصوصی و محافظت از آدرس IP خود، می‌توانید از طریق Tor به سرورهای پیام‌رسانی متصل شوید.", "hero-overlay-card-1-p-5": "فقط دستگاه‌های کلاینت پروفایل‌های کاربری، مخاطبین و گروه‌ها را ذخیره می‌کنند؛ پیام‌ها با رمزنگاری انتها به انتهای دو لایه ارسال می‌شوند.", - "hero-overlay-card-1-p-6": "برای اطلاعات بیشتر به وایت پیپر SimpleX مراجعه کنید.", + "hero-overlay-card-1-p-6": "برای اطلاعات بیشتر به وایت پیپر SimpleX مراجعه کنید.", "hero-overlay-card-2-p-1": "زمانی که کاربران هویت‌های پایدار دارند، حتی اگر این فقط یک شماره تصادفی مانند شناسه جلسه باشد، خطر این وجود دارد که ارائه‌دهنده یا یک مهاجم بتوانند مشاهده کنند که کاربران چگونه متصل شده‌اند و چند پیام ارسال می‌کنند.", "hero-overlay-card-2-p-2": "سپس آن‌ها می‌توانند این اطلاعات را با شبکه‌های اجتماعی عمومی موجود همبسته کرده و برخی هویت‌های واقعی را شناسایی کنند.", "hero-overlay-card-2-p-3": "حتی با خصوصی‌ترین برنامه‌ها که از خدمات Tor v3 استفاده می‌کنند، اگر شما با دو مخاطب مختلف از طریق یک پروفایل یکسان صحبت کنید، آن‌ها می‌توانند ثابت کنند که به یک شخص واحد متصل هستند.", diff --git a/website/langs/fi.json b/website/langs/fi.json index 3a138aa7c4..363697b8fb 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -37,7 +37,7 @@ "simplex-private-9-title": "Yksisuuntaisia
viestijonoja", "simplex-private-10-title": "Väliaikaiset nimettömät parittaiset tunnisteet", "simplex-private-card-3-point-1": "Vain TLS 1.2/1.3 vahvoilla algoritmeilla käytetään asiakas-palvelin-yhteyksiin.", - "simplex-private-card-4-point-2": "Käyttääksesi SimpleX:ää Torin kautta, asenna Orbot-sovellus ja ota käyttöön SOCKS5-välityspalvelin (tai VPN iOS:lla).", + "simplex-private-card-4-point-2": "Käyttääksesi SimpleX:ää Torin kautta, asenna Orbot-sovellus ja ota käyttöön SOCKS5-välityspalvelin (tai VPN iOS:lla).", "simplex-private-card-7-point-1": "Viestien eheyden varmistamiseksi ne numeroituvat peräkkäin ja sisältävät edellisen viestin tiivisteen.", "simplex-private-card-7-point-2": "Jos viestiä lisätään, poistetaan tai muutetaan, vastaanottaja saa ilmoituksen.", "simplex-private-card-8-point-1": "SimpleX-palvelimet toimivat matalan viiveen sekoitussolmuina — saapuvilla ja lähtevillä viesteillä on erilainen järjestys.", @@ -152,7 +152,7 @@ "simplex-unique-4-overlay-1-title": "Täysin hajautettu — käyttäjät omistavat SimpleX-verkon", "hero-overlay-card-1-p-1": "Monet käyttäjät ovat kysyneet: jos SimpleX ei käytä käyttäjätunnisteita, miten se tietää minne viestit toimitetaan?", "hero-overlay-card-1-p-3": "Määrität, minkä palvelimen(t) valitset viestien vastaanottamiseen, sekä kontaktisi — palvelimet, joita käytät viestien lähettämiseen heille. Jokainen keskustelu käyttää todennäköisesti kahta eri palvelinta.", - "hero-overlay-card-1-p-6": "Lue lisää SimpleX whitepaperista.", + "hero-overlay-card-1-p-6": "Lue lisää SimpleX whitepaperista.", "hero-overlay-card-1-p-2": "Viestien toimittamiseen SimpleX ei käytä muiden alustojen käyttäjätunnuksia, vaan sen sijaan se käyttää väliaikaisia nimettömiä parittaisia tunnisteita viestijonoille, jotka ovat erillisiä jokaiselle yhteydelle — pitkäaikaisia tunnisteita ei ole.", "hero-overlay-card-2-p-3": "Jopa yksityisimmillä sovelluksilla, jotka käyttävät Tor v3 -palveluja, jos puhut kahdelle eri yhteyshenkilölle saman profiilin kautta, he voivat todistaa olevansa yhteydessä samaan henkilöön.", "hero-overlay-card-2-p-4": "SimpleX suojaa näitä hyökkäyksiä vastaan, sillä siinä ei ole käyttäjätunnuksia toteutuksessaan. Ja jos käytät Incognito-tilaa, sinulla on eri näyttönimi jokaiselle yhteyshenkilölle, mikä estää yhteisten tietojen jakamisen heidän välillään.", @@ -163,14 +163,14 @@ "simplex-network-overlay-card-1-li-3": "P2P ei ratkaise välikäden hyökkäys -ongelmaa, ja useimmat olemassa olevat toteutukset eivät käytä kanavan ulkopuolisia viestejä alkuperäiseen avaimenvaihtoon. SimpleX käyttää kanavan ulkopuolisia viestejä tai, joissakin tapauksissa, jo valmiiksi turvallisia ja luotettuja yhteyksiä alkuperäiseen avaimenvaihtoon.", "simplex-network-overlay-card-1-li-4": "P2P-toteutukset voivat estyä joidenkin Internet-palveluntarjoajien (kuten BitTorrent) toimesta. SimpleX on kuljetusprotokollasta riippumaton - se voi toimia yleisten verkkoprotokollien kautta, kuten esimerkiksi WebSockets.", "simplex-network-overlay-card-1-li-5": "Kaikki tunnetut P2P-verkot saattavat olla alttiita Sybil-hyökkäykselle, koska jokainen solmu on löydettävissä, ja verkko toimii kokonaisuutena. Tunnetut keinot sen lieventämiseksi vaativat joko keskitetyn komponentin tai kalliin työn todistuksen. SimpleX-verkolla ei ole palvelimen löydettävyyttä, se on fragmentoitunut ja toimii useina eristettyinä aliverkkoina, mikä tekee verkko-laajuisista hyökkäyksistä mahdottomia.", - "privacy-matters-overlay-card-1-p-3": "Jotkut rahoitus- ja vakuutusyhtiöt käyttävät sosiaalisia verkostoja määrittääkseen korkoja ja vakuutusmaksuja. Se tekee usein alhaisempiin tuloihin kuuluvien ihmisten maksavan enemmän — sitä kutsutaan 'köyhyyslisäksi'.", + "privacy-matters-overlay-card-1-p-3": "Jotkut rahoitus- ja vakuutusyhtiöt käyttävät sosiaalisia verkostoja määrittääkseen korkoja ja vakuutusmaksuja. Se tekee usein alhaisempiin tuloihin kuuluvien ihmisten maksavan enemmän — sitä kutsutaan 'köyhyyslisäksi'.", "privacy-matters-overlay-card-1-p-4": "SimpleX-alusta suojaa yhteyksiesi yksityisyyttä paremmin kuin mikään vaihtoehto, estäen täysin yhteysverkkosi tulemisen saataville mille tahansa yrityksille tai organisaatioille. Vaikka ihmiset käyttävät SimpleX Chatin tarjoamia palvelimia, emme tiedä käyttäjien määrää tai heidän yhteyksiään.", "privacy-matters-overlay-card-2-p-3": "SimpleX on ensimmäinen alusta, jolla ei ole mitään käyttäjätunnisteita suunnittelussaan, suojaten siten yhteyksesi verkkoa paremmin kuin mikään tunnettu vaihtoehto.", - "privacy-matters-overlay-card-2-p-1": "Ei niin kauan sitten huomasimme merkittävien vaalien olevan manipuloitavissa kunnioitetun konsulttiyrityksen toimesta, joka käytti sosiaalisia verkostoja vääristämään käsitystämme todellisesta maailmasta ja manipuloimaan ääniämme.", + "privacy-matters-overlay-card-2-p-1": "Ei niin kauan sitten huomasimme merkittävien vaalien olevan manipuloitavissa kunnioitetun konsulttiyrityksen toimesta, joka käytti sosiaalisia verkostoja vääristämään käsitystämme todellisesta maailmasta ja manipuloimaan ääniämme.", "privacy-matters-overlay-card-3-p-1": "Kaikkien pitäisi välittää viestinnän yksityisyydestä ja turvallisuudesta — harmittomat keskustelut voivat asettaa sinut vaaraan, vaikka sinulla ei olisi mitään piilotettavaa.", "privacy-matters-overlay-card-2-p-2": "Ollaksesi objektiivinen ja tehdäksesi itsenäisiä päätöksiä, sinun on hallittava tietotilaasi. Se on mahdollista vain, jos käytät yksityistä viestintäalustaa, jolla ei ole pääsyä sosiaaliseen verkostoosi.", - "privacy-matters-overlay-card-3-p-2": "Yksi järkyttävimmistä tarinoista on Mohamedou Ould Salahi'n kokemus, joka on kuvattu hänen muistelmissaan ja esitetty The Mauritanian -elokuvassa. Hänet laitettiin Guantanamo-leirille ilman oikeudenkäyntiä ja häntä kidutettiin siellä 15 vuotta puhelun jälkeen sukulaiselleen Afganistanissa, epäiltynä osallisuudesta 9/11-iskuihin, vaikka hän oli asunut Saksassa edelliset 10 vuotta.", - "privacy-matters-overlay-card-3-p-3": "Tavalliset ihmiset pidätetään siitä, mitä he jakavat verkossa, jopa 'anonyymien' tiliensä kautta, jopa demokraattisissa maissa.", + "privacy-matters-overlay-card-3-p-2": "Yksi järkyttävimmistä tarinoista on Mohamedou Ould Salahi'n kokemus, joka on kuvattu hänen muistelmissaan ja esitetty The Mauritanian -elokuvassa. Hänet laitettiin Guantanamo-leirille ilman oikeudenkäyntiä ja häntä kidutettiin siellä 15 vuotta puhelun jälkeen sukulaiselleen Afganistanissa, epäiltynä osallisuudesta 9/11-iskuihin, vaikka hän oli asunut Saksassa edelliset 10 vuotta.", + "privacy-matters-overlay-card-3-p-3": "Tavalliset ihmiset pidätetään siitä, mitä he jakavat verkossa, jopa 'anonyymien' tiliensä kautta, jopa demokraattisissa maissa.", "privacy-matters-overlay-card-3-p-4": "Ei riitä, että käytät päästä päähän salattua viestintäsovellusta, meidän kaikkien pitäisi käyttää viestintäsovelluksia, jotka suojelevat henkilökohtaisten verkostojemme yksityisyyttä — keiden kanssa olemme yhteydessä.", "simplex-unique-overlay-card-1-p-1": "Toisin kuin muut viestintäalustat, SimpleX:llä ei ole mitään tunnisteita käyttäjille. Se ei luota puhelinnumeroihin, verkkotunnuksiin perustuviin osoitteisiin (kuten sähköposti tai XMPP), käyttäjänimiin, julkisiin avaimiin tai edes satunnaisiin numeroihin tunnistaakseen käyttäjänsä — emme tiedä kuinka monta ihmistä käyttää SimpleX-palvelimiamme.", "simplex-unique-overlay-card-1-p-2": "Viestien toimittamiseksi SimpleX käyttää parittaisia nimettömiä osoitteita kaksisuuntaisille viestijonoille, jotka ovat erilliset vastaanotetuille ja lähetetyille viesteille, yleensä eri palvelimien kautta.", @@ -179,9 +179,9 @@ "simplex-unique-overlay-card-2-p-2": "Jopa valinnaisen käyttäjäosoitteen kanssa, vaikka sitä voitaisiin käyttää roskapostiyhteyspyyntöjen lähettämiseen, voit vaihtaa sen tai poistaa sen kokonaan menettämättä mitään yhteyksiäsi.", "simplex-unique-overlay-card-3-p-3": "Toisin kuin liitettyjen verkkojen palvelimet (sähköposti, XMPP tai Matrix), SimpleX-palvelimet eivät tallenna käyttäjätilejä, ne vain välittävät viestejä, suojaten molempien osapuolien yksityisyyttä.", "simplex-unique-overlay-card-3-p-4": "Lähetetyssä ja vastaanotetussa palvelimen liikenteessä ei ole yhteisiä tunnisteita tai salaustekstiä — jos joku havaitsee sen, he eivät voi helposti selvittää kuka kommunikoi kenen kanssa, vaikka TLS olisi vaarantunut.", - "simplex-unique-overlay-card-4-p-3": "Jos harkitset kehittämistä SimpleX-alustalle, esimerkiksi chat-botin luomista SimpleX-sovelluksen käyttäjille tai SimpleX Chat -kirjaston integrointia mobiilisovelluksiisi, ole hyvä ja ota yhteyttä saadaksesi neuvoja ja tukea.", + "simplex-unique-overlay-card-4-p-3": "Jos harkitset kehittämistä SimpleX-alustalle, esimerkiksi chat-botin luomista SimpleX-sovelluksen käyttäjille tai SimpleX Chat -kirjaston integrointia mobiilisovelluksiisi, ole hyvä ja ota yhteyttä saadaksesi neuvoja ja tukea.", "simplex-unique-overlay-card-4-p-1": "Voit käyttää SimpleX:ää omien palvelimiesi kanssa ja silti kommunikoida ihmisten kanssa, jotka käyttävät meille tarjottuja valmiiksi määritettyjä palvelimia.", - "simplex-unique-overlay-card-4-p-2": "SimpleX-alusta käyttää avoimeen protokollaan ja tarjoaa SDK:n chatbotien luomiseen, mahdollistaen palvelujen toteuttamisen, joiden kanssa käyttäjät voivat olla vuorovaikutuksessa SimpleX Chat -sovellusten kautta — odotamme innolla nähdä, millaisia SimpleX-palveluja voit rakentaa.", + "simplex-unique-overlay-card-4-p-2": "SimpleX-alusta käyttää avoimeen protokollaan ja tarjoaa SDK:n chatbotien luomiseen, mahdollistaen palvelujen toteuttamisen, joiden kanssa käyttäjät voivat olla vuorovaikutuksessa SimpleX Chat -sovellusten kautta — odotamme innolla nähdä, millaisia SimpleX-palveluja voit rakentaa.", "simplex-unique-card-3-p-1": "SimpleX tallentaa kaikki käyttäjätiedot asiakaslaitteille siirrettävässä salatussa tietokannan muodossa — se voidaan siirtää toiseen laitteeseen.", "simplex-unique-card-3-p-2": "Päästä päähän salatut viestit säilytetään väliaikaisesti SimpleX-releay-palvelimilla, kunnes ne vastaanotetaan, minkä jälkeen ne poistetaan pysyvästi.", "we-invite-you-to-join-the-conversation": "Kutsumme sinut mukaan keskusteluun", diff --git a/website/langs/fr.json b/website/langs/fr.json index bd91c36e22..124c7ddc67 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -61,7 +61,7 @@ "simplex-private-card-3-point-2": "L'empreinte du serveur et la fixation des canaux empêchent les attaques MITM et les attaques par rejeu.", "simplex-private-card-3-point-3": "La reprise de connexion est désactivée pour empêcher les attaques de session.", "simplex-private-card-4-point-1": "Pour protéger votre adresse IP, vous pouvez accéder aux serveurs via Tor ou un autre réseau de transport superposé.", - "simplex-private-card-4-point-2": "Pour utiliser SimpleX via Tor, veuillez installer l'application Orbot et activez le proxy SOCKS5 (ou le mode VPN sur iOS).", + "simplex-private-card-4-point-2": "Pour utiliser SimpleX via Tor, veuillez installer l'application Orbot et activez le proxy SOCKS5 (ou le mode VPN sur iOS).", "simplex-private-card-5-point-1": "SimpleX utilise une couverture de contenu pour chaque couche de chiffrement afin de résister aux attaques ciblant la taille des messages.", "simplex-private-card-5-point-2": "Cela rend les messages de différentes tailles identiques pour les serveurs et les observateurs du réseau.", "simplex-private-card-6-point-1": "De nombreux réseaux de communication sont vulnérables aux attaques MITM par des serveurs ou des fournisseurs de réseau.", @@ -95,7 +95,7 @@ "hero-overlay-card-1-p-3": "Vous définissez le ou les serveurs que vous souhaitez utiliser pour recevoir les messages et vos contacts — les serveurs que vous utilisez pour leur envoyer des messages. Chaque conversation est susceptible d'utiliser deux serveurs différents.", "hero-overlay-card-1-p-4": "Cette méthode empêche la fuite de métadonnées des utilisateurs au niveau de l'application. Pour améliorer encore la protection de votre vie privée et protéger votre adresse IP, vous pouvez vous connecter aux serveurs de messagerie via Tor.", "hero-overlay-card-1-p-5": "Seuls les appareils clients stockent les profils des utilisateurs, les contacts et les groupes ; les messages sont envoyés avec un chiffrement de bout en bout à deux couches.", - "hero-overlay-card-1-p-6": "En savoir plus sur le livre blanc de SimpleX.", + "hero-overlay-card-1-p-6": "En savoir plus sur le livre blanc de SimpleX.", "hero-overlay-card-2-p-1": "Lorsque les utilisateurs ont des identités persistantes, même s'il ne s'agit que d'un nombre aléatoire, comme un ID de session, il y a un risque que le fournisseur ou un attaquant puisse observer comment les utilisateurs sont connectés et combien de messages ils envoient.", "hero-overlay-card-2-p-2": "Ils pourraient ensuite corréler ces informations avec les réseaux sociaux publics existants, et déterminer de véritables identités.", "hero-overlay-card-2-p-3": "Même avec les applications les plus privées qui utilisent les services Tor v3, si vous parlez à deux contacts différents via le même profil, ils peuvent prouver qu'ils sont connectés à la même personne.", @@ -109,14 +109,14 @@ "simplex-network-overlay-card-1-li-6": "Les réseaux P2P peuvent être vulnérables aux attaques DRDoS, lorsque les clients peuvent retransmettre et amplifier le trafic, entraînant un déni de service à l’échelle du réseau. Les clients SimpleX ne relaient le trafic que depuis des connexions connues et ne peuvent pas être utilisés par un attaquant pour amplifier le trafic sur l’ensemble du réseau.", "privacy-matters-overlay-card-1-p-1": "De nombreuses grandes entreprises utilisent les informations sur vos relations pour estimer vos revenus, vous vendre des produits dont vous n’avez pas vraiment besoin et déterminer les prix.", "privacy-matters-overlay-card-1-p-2": "Les vendeurs en ligne savent que les personnes à faible revenu sont plus susceptibles d'effectuer des achats urgents. Ils peuvent donc pratiquer des prix plus élevés ou supprimer des remises.", - "privacy-matters-overlay-card-1-p-3": "Certaines entreprises financières et d’assurance utilisent les graphes sociaux pour déterminer les taux d’intérêt et les primes d’assurance. Cela conduit souvent les personnes aux revenus modestes à payer davantage — on parle de « prime de pauvreté ».", + "privacy-matters-overlay-card-1-p-3": "Certaines entreprises financières et d’assurance utilisent les graphes sociaux pour déterminer les taux d’intérêt et les primes d’assurance. Cela conduit souvent les personnes aux revenus modestes à payer davantage — on parle de « prime de pauvreté ».", "privacy-matters-overlay-card-1-p-4": "Le réseau SimpleX protège mieux la confidentialité de vos connexions que toute autre solution, empêchant totalement que votre graphe social soit accessible à des entreprises ou des organisations. Même lorsque les utilisateurs utilisent les serveurs préconfigurés dans les applications SimpleX Chat, les opérateurs des serveurs ne connaissent ni le nombre d’utilisateurs ni leurs connexions.", - "privacy-matters-overlay-card-2-p-1": "Il n’y a pas si longtemps, nous avons vu des élections majeures être manipulées par une société de conseil réputée qui a utilisé nos graphes sociaux pour fausser notre perception du monde réel et manipuler nos votes.", + "privacy-matters-overlay-card-2-p-1": "Il n’y a pas si longtemps, nous avons vu des élections majeures être manipulées par une société de conseil réputée qui a utilisé nos graphes sociaux pour fausser notre perception du monde réel et manipuler nos votes.", "privacy-matters-overlay-card-2-p-2": "Pour être objectif et prendre des décisions indépendantes, vous devez garder le contrôle de votre espace informationnel. Cela n’est possible que si vous utilisez un réseau de communication privé qui n’a pas accès à votre graphe social.", "privacy-matters-overlay-card-2-p-3": "SimpleX est le premier réseau qui ne possède aucun identifiant utilisateur par conception, protégeant ainsi votre graphe de connexions mieux que toute autre solution connue.", "privacy-matters-overlay-card-3-p-1": "Tout le monde devrait se soucier de la confidentialité et de la sécurité de ses communications — des conversations lambda peuvent vous mettre en danger, même si vous n'avez rien à cacher.", - "privacy-matters-overlay-card-3-p-2": "L’une des histoires les plus choquantes est celle de Mohamedou Ould Salahi, relatée dans ses mémoires et dans le film Désigné coupable (The Mauritanian). Il a été détenu au camp de Guantánamo, sans procès, et y a été torturé pendant 15 ans après un appel téléphonique à un proche en Afghanistan, étant soupçonné d’être impliqué dans les attentats du 11 septembre, alors qu’il vivait en Allemagne depuis 10 ans.", - "privacy-matters-overlay-card-3-p-3": "Des personnes ordinaires sont arrêtées pour ce qu’elles partagent en ligne, même via leurs comptes « anonymes », même dans les pays démocratiques.", + "privacy-matters-overlay-card-3-p-2": "L’une des histoires les plus choquantes est celle de Mohamedou Ould Salahi, relatée dans ses mémoires et dans le film Désigné coupable (The Mauritanian). Il a été détenu au camp de Guantánamo, sans procès, et y a été torturé pendant 15 ans après un appel téléphonique à un proche en Afghanistan, étant soupçonné d’être impliqué dans les attentats du 11 septembre, alors qu’il vivait en Allemagne depuis 10 ans.", + "privacy-matters-overlay-card-3-p-3": "Des personnes ordinaires sont arrêtées pour ce qu’elles partagent en ligne, même via leurs comptes « anonymes », même dans les pays démocratiques.", "privacy-matters-overlay-card-3-p-4": "Il ne suffit pas d'utiliser une messagerie chiffrée de bout en bout, nous devrions tous utiliser des messageries qui protègent la vie privée de nos réseaux personnels — les personnes avec lesquelles nous sommes connectés.", "simplex-unique-overlay-card-1-p-1": "Contrairement aux autres réseaux de messagerie, SimpleX n’attribue aucun identifiant aux utilisateurs. Il ne repose ni sur les numéros de téléphone, ni sur les adresses basées sur des domaines (comme les adresses e-mail ou XMPP), ni sur les noms d’utilisateur, les clés publiques ou même les nombres aléatoires pour identifier ses utilisateurs — les opérateurs des serveurs SimpleX ne savent pas combien de personnes utilisent leurs serveurs.", "simplex-unique-overlay-card-1-p-2": "Pour acheminer les messages, SimpleX utilise des adresses anonymes distinctes pour chaque interlocuteur de files de messages unidirectionnelles, séparées pour les messages reçus et envoyés, généralement via des serveurs différents.", @@ -128,8 +128,8 @@ "simplex-unique-overlay-card-3-p-3": "Contrairement aux serveurs de réseaux fédérés (e-mail, XMPP ou Matrix), les serveurs SimpleX ne stockent pas les comptes d'utilisateurs, ils ne font que relayer les messages, protégeant ainsi la vie privée des deux parties.", "simplex-unique-overlay-card-3-p-4": "Il n'y a pas d'identifiant ou de texte chiffré en commun entre le trafic serveur envoyé et reçu - si quelqu'un l'observe, il ne peut pas facilement déterminer qui communique avec qui, même si le TLS est compromis.", "simplex-unique-overlay-card-4-p-1": "Vous pouvez utiliser SimpleX avec vos propres serveurs et continuer à communiquer avec les personnes qui utilisent les serveurs préconfigurés dans les applications.", - "simplex-unique-overlay-card-4-p-2": "Le réseau SimpleX utilise un protocole ouvert et fournit un SDK pour créer des chatbots, permettant de mettre en œuvre des services avec lesquels les utilisateurs peuvent interagir via les applications SimpleX Chat — nous avons hâte de voir quels services SimpleX vous allez créer.", - "simplex-unique-overlay-card-4-p-3": "Si vous envisagez de développer pour le réseau SimpleX, par exemple un chatbot pour les utilisateurs de l’application SimpleX ou d’intégrer la bibliothèque SimpleX Chat à vos applications mobiles, n’hésitez pas à nous contacter pour obtenir des conseils et de l’aide.", + "simplex-unique-overlay-card-4-p-2": "Le réseau SimpleX utilise un protocole ouvert et fournit un SDK pour créer des chatbots, permettant de mettre en œuvre des services avec lesquels les utilisateurs peuvent interagir via les applications SimpleX Chat — nous avons hâte de voir quels services SimpleX vous allez créer.", + "simplex-unique-overlay-card-4-p-3": "Si vous envisagez de développer pour le réseau SimpleX, par exemple un chatbot pour les utilisateurs de l’application SimpleX ou d’intégrer la bibliothèque SimpleX Chat à vos applications mobiles, n’hésitez pas à nous contacter pour obtenir des conseils et de l’aide.", "simplex-unique-card-1-p-1": "SimpleX protège la confidentialité de votre profil, de vos contacts et de vos métadonnées, en les cachant des serveurs du réseau SimpleX et de tout observateur.", "simplex-unique-card-1-p-2": "Contrairement à tout autre réseau de messagerie existant, SimpleX n'a aucun identifiant attribué aux utilisateurs — pas même de nombres aléatoires.", "simplex-unique-card-2-p-1": "Parce que vous n'avez pas d'identifiant ou d'adresse fixe sur le réseau SimpleX, personne ne peut vous contacter sauf si vous partagez une adresse d'utilisateur unique ou temporaire, comme un code QR ou un lien.", diff --git a/website/langs/he.json b/website/langs/he.json index 11dc4a0b4d..8e1fd0aaf5 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -73,7 +73,7 @@ "simplex-private-5-title": "שכבות מרובות של
ריפוד תוכן", "simplex-private-card-3-point-3": "חידוש החיבור מושבת כדי למנוע התקפות הפעלה.", "simplex-private-card-4-point-1": "כדי להגן על כתובת ה-IP שלכם, אתם יכולים לגשת לשרתים דרך Tor או רשת שכבת-על אחרת של תעבורה.", - "simplex-private-card-4-point-2": "כדי להשתמש ב-SimpleX דרך Tor, התקן את אפליקציית Orbot והפעל את SOCKS5 proxy (או VPN ב-iOS).", + "simplex-private-card-4-point-2": "כדי להשתמש ב-SimpleX דרך Tor, התקן את אפליקציית Orbot והפעל את SOCKS5 proxy (או VPN ב-iOS).", "simplex-private-card-7-point-2": "אם הודעה כלשהי תתווסף, תוסר או תשתנה, הנמען יקבל התראה.", "simplex-private-card-9-point-1": "כל תור הודעות מעביר הודעות בכיוון אחד, עם כתובות השליחה והקבלה השונות.", "privacy-matters-1-overlay-1-title": "פרטיות חוסכת לכם כסף", @@ -99,13 +99,13 @@ "privacy-matters-overlay-card-1-p-2": "קמעונאים מקוונים יודעים שאנשים עם הכנסה נמוכה יותר נוטים יותר לבצע רכישות דחופות, ולכן הם עשויים לגבות מחירים גבוהים יותר או להסיר הנחות.", "hero-overlay-card-1-p-2": "כדי להעביר הודעות, במקום מזהי משתמש המשמשים את כל הרשתות האחרות, SimpleX משתמש במזהים זמניים אנונימיים של תורי הודעות, נפרדים לכל אחד מהחיבורים שלך - אין מזהים ארוכי טווח.", "hero-overlay-card-2-p-2": "לאחר מכן הם יוכלו לקשר מידע זה עם הרשתות החברתיות הציבוריות הקיימות, ולקבוע כמה זהויות אמיתיות.", - "privacy-matters-overlay-card-1-p-3": "חלק מחברות פיננסיות וביטוח משתמשות בגרפים חברתיים כדי לקבוע שיעורי ריבית ופרמיות. לעתים קרובות זה גורם לאנשים עם הכנסה נמוכה יותר לשלם יותר — זה ידוע בתור 'פרמיית עוני'.", + "privacy-matters-overlay-card-1-p-3": "חלק מחברות פיננסיות וביטוח משתמשות בגרפים חברתיים כדי לקבוע שיעורי ריבית ופרמיות. לעתים קרובות זה גורם לאנשים עם הכנסה נמוכה יותר לשלם יותר — זה ידוע בתור 'פרמיית עוני'.", "hero-overlay-card-1-p-4": "עיצוב זה מונע דליפה כלשהי של משתמשים' מטא נתונים ברמת האפליקציה. כדי לשפר עוד יותר את הפרטיות ולהגן על כתובת ה-IP שלך, תוכל להתחבר לשרתי הודעות באמצעות Tor.", "hero-overlay-card-2-p-3": "אפילו עם האפליקציות הפרטיות ביותר המשתמשות בשירותי Tor v3, אם אתה מדבר עם שני אנשי קשר שונים דרך אותו פרופיל הם יכולים להוכיח שהם מחוברים לאותו אדם.", "hero-overlay-card-3-p-1": "Trail of Bits היא חברת ייעוץ מובילה בתחום אבטחה וטכנולוגיה שלקוחותיה כוללים ביג טק, סוכנויות ממשלתיות ופרויקטי בלוקצ'יין גדולים.", "hero-overlay-card-1-p-1": "משתמשים רבים שאלו: אם ל-SimpleX אין מזהי משתמש, איך הוא יכול לדעת לאן להעביר הודעות?", "simplex-network-overlay-card-1-li-2": "לעיצוב של SimpleX, בניגוד לרוב רשתות ה-P2P, אין מזהי משתמש גלובלי מכל סוג שהוא, אפילו זמני, והוא משתמש רק במזהים זמניים זוגיים, המספקים אנונימיות טובה יותר והגנה על מטא נתונים.", - "hero-overlay-card-1-p-6": "קרא עוד בסקירה הטכנית של SimpleX.", + "hero-overlay-card-1-p-6": "קרא עוד בסקירה הטכנית של SimpleX.", "simplex-network-overlay-card-1-p-1": "לפרוטוקולי הודעות ואפליקציות P2P יש בעיות שונות שהופכות אותן לפחות אמינות מ-SimpleX, מורכבות יותר לניתוח, ופגיעות למספר סוגי התקפות.", "hero-overlay-card-1-p-3": "אתה מגדיר באילו שרתים להשתמש כדי לקבל את ההודעות, אנשי הקשר שלך — השרתים שבהם אתה משתמש כדי לשלוח את ההודעות אליהם. סביר שכל שיחה תהשתמש בשני שרתים שונים.", "copy-the-command-below-text": "העתיקו את הפקודה למטה והשתמש בה בצ'אט:", @@ -113,7 +113,7 @@ "scan-the-qr-code-with-the-simplex-chat-app": "סרקו את קוד ה-QR באפליקציית SimpleX Chat", "simplex-unique-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", "simplex-unique-overlay-card-3-p-3": "שלא כמו שרתי רשת מאוחדים (דוא\"ל, XMPP או Matrix), שרתי SimpleX אינם מאחסנים חשבונות משתמשים, הם רק מעבירים הודעות, ומגנים על הפרטיות של שני הצדדים.", - "privacy-matters-overlay-card-3-p-2": "אחד הסיפורים המזעזעים ביותר הוא החוויה של מוחמד אולד סלאחי המתוארת בספר הזיכרונות שלו ומוצגת בסרט \"המאוריטני\". הוא הוכנס למתקן המעצר בגואנטנמו, ללא משפט, ועונה שם במשך 15 שנים לאחר שיחת טלפון לקרוב משפחתו באפגניסטן, בחשד שהיה מעורב בפיגועים ב-11 בספטמבר, למרות שחי בגרמניה בעשר השנים הקודמות.", + "privacy-matters-overlay-card-3-p-2": "אחד הסיפורים המזעזעים ביותר הוא החוויה של מוחמד אולד סלאחי המתוארת בספר הזיכרונות שלו ומוצגת בסרט \"המאוריטני\". הוא הוכנס למתקן המעצר בגואנטנמו, ללא משפט, ועונה שם במשך 15 שנים לאחר שיחת טלפון לקרוב משפחתו באפגניסטן, בחשד שהיה מעורב בפיגועים ב-11 בספטמבר, למרות שחי בגרמניה בעשר השנים הקודמות.", "simplex-network-2-desc": "שרתי ממסר SimpleX אינם מאחסנים פרופילי משתמש, אנשי קשר והודעות שנמסרו, אינם מתחברים זה לזה, ואין ספריית שרתים.", "installing-simplex-chat-to-terminal": "התקנת SimpleX Chat למסוף שורת הפקודה", "use-this-command": "השתמשו בפקודה הזו:", @@ -160,13 +160,13 @@ "simplex-unique-overlay-card-3-p-4": "אין מזהים או טקסט מוצפן משותף בין תעבורת שרת שנשלחה ומתקבלת — אם מישהו צופה בזה, הם לא יכולים לקבוע בקלות מי מתקשר עם מי, גם אם ה-TLS נפרץ.", "get-simplex": "הורידו את אפליקציית שולחן העבודה SimpleX", "privacy-matters-overlay-card-3-p-1": "לכולם צריך להיות אכפת מהפרטיות והאבטחה של התקשורת שלהם — שיחות לא מזיקות עלולות להעמיד אתכם בסכנה, גם אם אין לכם מה להסתיר.", - "simplex-unique-overlay-card-4-p-3": "אם אתם שוקלים לפתח עבור רשת SimpleX, למשל, את הצ'אט בוט עבור משתמשי אפליקציית SimpleX, או שילוב של ספריית SimpleX Chat באפליקציות לנייד שלכם, אנא צרו קשר לכל עצה ותמיכה.", + "simplex-unique-overlay-card-4-p-3": "אם אתם שוקלים לפתח עבור רשת SimpleX, למשל, את הצ'אט בוט עבור משתמשי אפליקציית SimpleX, או שילוב של ספריית SimpleX Chat באפליקציות לנייד שלכם, אנא צרו קשר לכל עצה ותמיכה.", "simplex-network-3-header": "רשת SimpleX", "simplex-unique-card-2-p-1": "מכיוון שאין לך מזהה או כתובת קבועה ברשת SimpleX, אף אחד לא יכול ליצור איתך קשר אלא אם כן אתה משתף כתובת משתמש חד-פעמית או זמנית, כקוד QR או קישור.", "join-the-REDDIT-community": "הצטרפו לקהילת REDDIT", - "privacy-matters-overlay-card-3-p-3": "אנשים רגילים נעצרים על מה שהם משתפים באינטרנט, אפילו דרך החשבונות ה'אנונימיים' שלהם, אפילו במדינות דמוקרטיות.", + "privacy-matters-overlay-card-3-p-3": "אנשים רגילים נעצרים על מה שהם משתפים באינטרנט, אפילו דרך החשבונות ה'אנונימיים' שלהם, אפילו במדינות דמוקרטיות.", "simplex-unique-overlay-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", - "simplex-unique-overlay-card-4-p-2": "רשת SimpleX משתמשת בפרוטוקול פתוח ומספקת SDK ליצירת צ'אט בוטים, המאפשר הטמעה של שירותים שמשתמשים יכולים לתקשר איתם באמצעות אפליקציות SimpleX Chat — אנחנו ממש מצפים לראות אילו שירותי SimpleX אתם יכולים לבנות.", + "simplex-unique-overlay-card-4-p-2": "רשת SimpleX משתמשת בפרוטוקול פתוח ומספקת SDK ליצירת צ'אט בוטים, המאפשר הטמעה של שירותים שמשתמשים יכולים לתקשר איתם באמצעות אפליקציות SimpleX Chat — אנחנו ממש מצפים לראות אילו שירותי SimpleX אתם יכולים לבנות.", "contact-hero-p-2": "עדיין לא הורדתם את ה-SimpleX Chat?", "why-simplex-is-unique": "מדוע SimpleX הוא ייחודי", "simplex-network-section-header": "רשת SimpleX", @@ -174,7 +174,7 @@ "simplex-network-1-overlay-linktext": "בעיות של רשתות P2P", "protocol-2-text": "XMPP, Matrix", "simplex-network-overlay-card-1-li-4": "יישומי P2P עלולים להיחסם על ידי ספקי אינטרנט מסוימים (כמו BitTorrent). SimpleX אינו תלוי בתעבורה - הוא יכול לעבוד על פרוטוקולי אינטרנט סטנדרטיים, למשל WebSockets.", - "privacy-matters-overlay-card-2-p-1": "לפני זמן לא רב ראינו את הבחירות הראשיות שעברו מניפולציות על ידי חברת ייעוץ מכובדת שהשתמשה בגרפים החברתיים שלנו כדי לעוות את השקפתנו על העולם האמיתי ולתמרן את ההצבעות שלנו.", + "privacy-matters-overlay-card-2-p-1": "לפני זמן לא רב ראינו את הבחירות הראשיות שעברו מניפולציות על ידי חברת ייעוץ מכובדת שהשתמשה בגרפים החברתיים שלנו כדי לעוות את השקפתנו על העולם האמיתי ולתמרן את ההצבעות שלנו.", "privacy-matters-overlay-card-2-p-3": "SimpleX היא הרשת הראשונה שאין לה מזהי משתמש מטבעה, ובכך מגינה על גרף החיבורים שלך טוב יותר מכל חלופה מוכרת.", "learn-more": "למדו עוד", "scan-qr-code-from-mobile-app": "סרקו קוד QR באפליקציה לנייד", diff --git a/website/langs/hu.json b/website/langs/hu.json index 80a5c614e1..1b3a4f5869 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -90,7 +90,7 @@ "hero-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez az összes többi hálózat által használt felhasználói azonosítók helyett a SimpleX az üzenetek várólistába rendezéséhez ideiglenes, névtelen, páros azonosítókat használ, külön-külön minden egyes kapcsolathoz — nincsenek hosszú távú azonosítók.", "hero-overlay-card-1-p-4": "Ez a kialakítás megakadályozza a felhasználók metaadatainak kiszivárgását az alkalmazás szintjén. Az adatvédelem további javítása és az IP-cím védelme érdekében az üzenetküldő kiszolgálókhoz Tor hálózaton keresztül is kapcsolódhat.", "hero-overlay-card-1-p-5": "Csak a kliensek tárolják a felhasználói profilokat, partnereket és csoportokat; az üzenetek küldése kétrétegű végpontok közötti titkosítással történik.", - "hero-overlay-card-1-p-6": "További leírást a SimpleX ismertetőben olvashat.", + "hero-overlay-card-1-p-6": "További leírást a SimpleX ismertetőben olvashat.", "hero-overlay-card-2-p-1": "Ha a felhasználók állandó azonosítóval rendelkeznek, még akkor is, ha ez csak egy véletlenszerű szám, például egy munkamenet-azonosító, fennáll annak a veszélye, hogy a szolgáltató vagy egy támadó megfigyelheti, azt hogy hogyan kapcsolódnak a felhasználók egymáshoz, és hány üzenetet küldenek egymásnak.", "hero-overlay-card-2-p-2": "Ezt az információt aztán összefüggésbe hozhatják a meglévő nyilvános közösségi hálózatokkal, és meghatározhatnak néhány valódi személyazonosságot.", "hero-overlay-card-2-p-3": "Még a Tor v3 szolgáltatásokat használó, legprivátabb alkalmazások esetében is, ha két különböző partnerével beszél ugyanazon a profilon keresztül, bizonyítani tudják, hogy ugyanahhoz a személyhez kapcsolódnak.", @@ -104,14 +104,14 @@ "simplex-network-overlay-card-1-li-5": "Minden ismert P2P-hálózat sebezhető Sybil támadással, mert minden egyes csomópont felderíthető, és a hálózat egészként működik. A támadások enyhítésére szolgáló ismert intézkedés lehet egy központi kiszolgáló (például: tracker), vagy egy drága tanúsítvány. A SimpleX hálózat nem ismeri fel a kiszolgálókat, töredezett és több elszigetelt alhálózatként működik, ami lehetetlenné teszi az egész hálózatra kiterjedő támadásokat.", "privacy-matters-overlay-card-1-p-1": "Sok nagyvállalat arra használja fel a felhasználóival kapcsolatban álló személyek adatait, hogy megbecsülje a jövedelmi helyzetüket és olyan termékeket kínáljon fel, amelyekre valójában nincs is szükségük, valamint hogy meghatározza az árakat.", "privacy-matters-overlay-card-1-p-2": "Az online kiskereskedők tudják, hogy az alacsonyabb jövedelműek nagyobb valószínűséggel vásárolnak azonnal, ezért magasabb árakat számíthatnak fel, vagy eltörölhetik a kedvezményeket.", - "privacy-matters-overlay-card-1-p-3": "Egyes pénzügyi és biztosítótársaságok szociális grafikonokat használnak a kamatlábak és a díjak meghatározásához. Ez gyakran arra készteti az alacsonyabb jövedelmű embereket, hogy többet fizessenek — ez az úgynevezett „szegénységi prémium”.", + "privacy-matters-overlay-card-1-p-3": "Egyes pénzügyi és biztosítótársaságok szociális grafikonokat használnak a kamatlábak és a díjak meghatározásához. Ez gyakran arra készteti az alacsonyabb jövedelmű embereket, hogy többet fizessenek — ez az úgynevezett „szegénységi prémium”.", "privacy-matters-overlay-card-1-p-4": "A SimpleX hálózat minden alternatívánál jobban védi a kapcsolatai adatait, teljes mértékben megakadályozva, hogy az ismeretségi-hálója bármilyen vállalat vagy szervezet számára elérhetővé váljon. Még ha az emberek a SimpleX Chat által előre beállított kiszolgálókat is használják, sem az alkalmazások, sem a kiszolgálók üzemeltetői nem ismerik, sem felhasználók számát, sem a kapcsolataikat.", - "privacy-matters-overlay-card-2-p-1": "Nem is olyan régen megfigyelhettük, hogy a nagy választásokat manipulálta egy neves tanácsadó cég, amely az ismeretségi-háló segítségével eltorzította a valós világról alkotott képünket, és manipulálta a szavazatainkat.", + "privacy-matters-overlay-card-2-p-1": "Nem is olyan régen megfigyelhettük, hogy a nagy választásokat manipulálta egy neves tanácsadó cég, amely az ismeretségi-háló segítségével eltorzította a valós világról alkotott képünket, és manipulálta a szavazatainkat.", "privacy-matters-overlay-card-2-p-2": "Ahhoz, hogy objektív legyen és független döntéseket tudjon hozni, az információs terét is kézben kell tartania. Ez csak akkor lehetséges, ha egy privát kommunikációs hálózatot használ, amely nem fér hozzá az ismeretségi hálójához.", "privacy-matters-overlay-card-2-p-3": "A SimpleX az első olyan hálózat, amely eleve nem rendelkezik felhasználói azonosítókkal, így jobban védi az ismeretségi-hálóját, mint bármely ismert alternatíva.", "privacy-matters-overlay-card-3-p-1": "Mindenkinek törődnie kell a magánélet és a kommunikáció biztonságával — az ártalmatlan beszélgetések veszélybe sodorhatják, még akkor is, ha nincs semmi rejtegetnivalója.", - "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", - "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", + "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", + "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", "privacy-matters-overlay-card-3-p-4": "Nem elég csak egy végpontok között titkosított üzenetváltó alkalmazást használnunk, mindannyiunknak olyan üzenetváltó alkalmazásokat kell használnunk, amelyek védik a személyes partnereink magánéletét — akikkel kapcsolatban állunk.", "simplex-unique-overlay-card-1-p-1": "Más üzenetküldő hálózatoktól eltérően a SimpleX nem rendel azonosítókat a felhasználókhoz. Nem támaszkodik telefonszámokra, tartomány-alapú címekre (mint az e-mail, XMPP vagy a Matrix), felhasználónevekre, nyilvános kulcsokra vagy akár véletlenszerű számokra a felhasználók azonosításához — a SimpleX kiszolgálók üzemeltetői nem tudják, hogy hányan használják a kiszolgálóikat.", "simplex-unique-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez a SimpleX egyirányú várólistákat használ az üzenetekhez, páronkénti, névtelen címekkel, külön a fogadott és külön az elküldött üzenetekhez, általában különböző kiszolgálókon keresztül.", @@ -123,8 +123,8 @@ "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", "simplex-unique-overlay-card-3-p-4": "A küldött és a fogadott kiszolgálóforgalom között nincsenek közös azonosítók vagy titkosított szövegek — ha bárki megfigyeli, nem tudja könnyen megállapítani, hogy ki kivel kommunikál, még akkor sem, ha a TLS-t kompromittálják.", "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az előre beállított kiszolgálókat használják az alkalmazásban.", - "simplex-unique-overlay-card-4-p-2": "A SimpleX hálózat nyitott protokollt használ és SDK-t biztosít a csevegési botok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", - "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX hálózatra való fejlesztést fontolgatja, például a SimpleX alkalmazások felhasználóinak szánt csevegési botot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", + "simplex-unique-overlay-card-4-p-2": "A SimpleX hálózat nyitott protokollt használ és SDK-t biztosít a csevegési botok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", + "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX hálózatra való fejlesztést fontolgatja, például a SimpleX alkalmazások felhasználóinak szánt csevegési botot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", "simplex-unique-card-1-p-1": "A SimpleX megvédi a profilhoz tartozó partnereket és azok metaadatait is, elrejtve azokat a SimpleX hálózat kiszolgálói és a megfigyelők elől.", "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő hálózattól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", "simplex-unique-card-2-p-1": "Mivel a SimpleX hálózaton senkinek sincs azonosítója vagy állandó címe, ezért senki sem tud kapcsolatba lépni a felhasználókkal, hacsak nem osztanak meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", @@ -224,7 +224,7 @@ "contact-hero-header": "Ez egy meghívó a SimpleX Chaten való beszélgetéshez", "invitation-hero-header": "Kapott egy egyszer használható meghívót a SimpleX Chaten való beszélgetéshez", "simplex-network-overlay-card-1-li-4": "A P2P-megvalósításokat egyes internetszolgáltatók blokkolhatják (mint például a BitTorrent). A SimpleX átvitel-független — a szabványos webes protokollokon, például WebSocketsen keresztül is működik.", - "simplex-private-card-4-point-2": "A SimpleX, Tor hálózaton keresztüli használatához telepítse az Orbot alkalmazást és engedélyezze a SOCKS5 proxyt (vagy a VPN-t az iOS-ban).", + "simplex-private-card-4-point-2": "A SimpleX, Tor hálózaton keresztüli használatához telepítse az Orbot alkalmazást és engedélyezze a SOCKS5 proxyt (vagy a VPN-t az iOS-ban).", "simplex-private-card-5-point-1": "A SimpleX minden titkosítási réteghez tartalomkitöltést használ az üzenetméretre irányuló támadások meghiúsítása érdekében.", "simplex-private-card-5-point-2": "A kiszolgálók és a hálózatot megfigyelők számára a különböző méretű üzenetek egyformának tűnnek.", "privacy-matters-1-title": "Hirdetés és árdiszkrimináció", diff --git a/website/langs/id.json b/website/langs/id.json index 2811873b46..b1134bcaff 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -126,7 +126,7 @@ "simplex-private-card-3-point-1": "Hanya TLS 1.2/1.3 dengan algoritma kuat yang digunakan untuk koneksi client-server.", "simplex-private-card-3-point-2": "Sidikjari server dan pengikatan saluran mencegah serangan MITM secara berulang.", "simplex-private-card-3-point-3": "Pemulihan koneksi dinonaktifkan untuk mencegah serangan sesi.", - "simplex-private-card-4-point-2": "Untuk menggunakan SimpleX melalui Tor, silakan instal aplikasi Orbot dan aktifkan proksi SOCKS5 (atau VPN di iOS).", + "simplex-private-card-4-point-2": "Untuk menggunakan SimpleX melalui Tor, silakan instal aplikasi Orbot dan aktifkan proksi SOCKS5 (atau VPN di iOS).", "simplex-private-card-5-point-1": "SimpleX menggunakan pengunci konten untuk setiap lapisan enkripsi guna menggagalkan serangan pesan.", "simplex-private-card-5-point-2": "Ini membuat pesan dengan ukuran berbeda terlihat sama bagi server dan pengamat jaringan.", "simplex-private-card-6-point-1": "Banyak jaringan komunikasi yang rentan terhadap serangan MITM oleh server atau penyedia jaringan.", @@ -145,7 +145,7 @@ "simplex-private-card-1-point-2": "Kotak kripto NaCL di setiap antrean untuk mencegah korelasi lalu lintas antara antrean pesan jika TLS disusupi.", "simplex-private-card-2-point-1": "Lapisan enkripsi server tambahan untuk pengiriman ke penerima, untuk mencegah korelasi antara lalu lintas server yang diterima dan dikirim jika TLS disusupi.", "hero-overlay-card-1-p-5": "Hanya perangkat klien yang menyimpan profil pengguna, kontak, dan grup; pesan dikirim dengan enkripsi end-to-end 2 lapis.", - "hero-overlay-card-1-p-6": "Selengkapnya di whitepaper SimpleX.", + "hero-overlay-card-1-p-6": "Selengkapnya di whitepaper SimpleX.", "hero-overlay-card-2-p-1": "Bila pengguna memiliki identitas persisten, meskipun ini hanya angka acak, seperti ID Sesi, ada risiko bahwa penyedia atau penyerang dapat mengamati bagaimana pengguna terhubung dan berapa banyak pesan yang mereka kirim.", "hero-overlay-card-2-p-2": "Mereka kemudian dapat menghubungkan informasi ini dengan jaringan sosial publik yang ada, dan menentukan beberapa identitas sebenarnya.", "hero-overlay-card-2-p-3": "Bahkan dengan aplikasi paling pribadi yang menggunakan layanan Tor v3, jika Anda berbicara dengan dua kontak berbeda melalui profil yang sama, mereka dapat membuktikan bahwa mereka terhubung dengan orang yang sama.", @@ -236,14 +236,14 @@ "simplex-network-overlay-card-1-li-6": "Jaringan P2P mungkin rentan terhadap serangan DRDoS, ketika klien dapat menyiarkan ulang dan memperkuat lalu lintas, yang mengakibatkan penolakan layanan di seluruh jaringan. Klien SimpleX hanya meneruskan lalu lintas dari koneksi yang diketahui dan tidak dapat digunakan oleh penyerang untuk memperkuat lalu lintas di seluruh jaringan.", "privacy-matters-overlay-card-1-p-1": "Banyak perusahaan besar menggunakan informasi mengenai siapa yang terhubung dengan Anda untuk memperkirakan pendapatan Anda, menjual produk yang sebenarnya tidak Anda butuhkan, dan menentukan harga.", "privacy-matters-overlay-card-1-p-2": "Pengecer daring mengetahui bahwa orang-orang berpenghasilan rendah cenderung melakukan pembelian mendesak, sehingga mereka mungkin mengenakan harga lebih tinggi atau menghapus diskon.", - "privacy-matters-overlay-card-1-p-3": "Beberapa perusahaan keuangan dan asuransi menggunakan grafik sosial untuk menentukan suku bunga dan premium. Hal ini seringkali membuat orang berpenghasilan rendah membayar lebih banyak — dikenal sebagai 'premium kemelaratan'.", + "privacy-matters-overlay-card-1-p-3": "Beberapa perusahaan keuangan dan asuransi menggunakan grafik sosial untuk menentukan suku bunga dan premium. Hal ini seringkali membuat orang berpenghasilan rendah membayar lebih banyak — dikenal sebagai 'premium kemelaratan'.", "privacy-matters-overlay-card-1-p-4": "Jaringan SimpleX melindungi privasi koneksi Anda lebih baik daripada alternatif lainnya, sepenuhnya mencegah grafik sosial Anda diakses oleh perusahaan atau organisasi mana pun. Bahkan ketika orang menggunakan server yang telah dikonfigurasi sebelumnya di aplikasi SimpleX Chat, operator server tidak mengetahui jumlah pengguna atau koneksi mereka.", - "privacy-matters-overlay-card-2-p-1": "Belum lama ini kita menyaksikan pemilu besar dimanipulasi oleh sebuah perusahaan konsultan terkemuka yang menggunakan grafik sosial kita untuk mendistorsi pandangan kita terhadap dunia nyata dan memanipulasi suara kita.", + "privacy-matters-overlay-card-2-p-1": "Belum lama ini kita menyaksikan pemilu besar dimanipulasi oleh sebuah perusahaan konsultan terkemuka yang menggunakan grafik sosial kita untuk mendistorsi pandangan kita terhadap dunia nyata dan memanipulasi suara kita.", "privacy-matters-overlay-card-2-p-2": "Agar objektif dan dapat membuat keputusan yang independen, Anda perlu mengendalikan ruang informasi Anda. Hal ini hanya mungkin jika Anda menggunakan jaringan komunikasi pribadi yang tidak memiliki akses ke grafik sosial Anda.", "privacy-matters-overlay-card-2-p-3": "SimpleX adalah jaringan pertama yang tidak memiliki ID pengguna apa pun secara desain, dengan cara ini melindungi grafik koneksi Anda lebih baik daripada alternatif yang diketahui.", "privacy-matters-overlay-card-3-p-1": "Setiap orang harus peduli dengan privasi dan keamanan komunikasi mereka — percakapan yang tidak berbahaya dapat membahayakan Anda, bahkan jika Anda tidak menyembunyikan apa pun.", - "privacy-matters-overlay-card-3-p-2": "Salah satu kisah paling mengejutkan adalah pengalaman Mohamedou Ould Salahi yang diceritakan dalam memoarnya dan ditampilkan dalam film The Mauritanian. Ia dimasukkan ke kamp Guantanamo tanpa diadili, dan disiksa di sana selama 15 tahun setelah menelepon kerabatnya di Afghanistan, karena dicurigai terlibat dalam serangan 9/11, meskipun ia pernah tinggal di Jerman selama 10 tahun sebelumnya.", - "privacy-matters-overlay-card-3-p-3": "Orang biasa ditangkap karena apa yang mereka bagikan secara daring, bahkan melalui akun 'anonim' mereka, bahkan di negara demokrasi.", + "privacy-matters-overlay-card-3-p-2": "Salah satu kisah paling mengejutkan adalah pengalaman Mohamedou Ould Salahi yang diceritakan dalam memoarnya dan ditampilkan dalam film The Mauritanian. Ia dimasukkan ke kamp Guantanamo tanpa diadili, dan disiksa di sana selama 15 tahun setelah menelepon kerabatnya di Afghanistan, karena dicurigai terlibat dalam serangan 9/11, meskipun ia pernah tinggal di Jerman selama 10 tahun sebelumnya.", + "privacy-matters-overlay-card-3-p-3": "Orang biasa ditangkap karena apa yang mereka bagikan secara daring, bahkan melalui akun 'anonim' mereka, bahkan di negara demokrasi.", "privacy-matters-overlay-card-3-p-4": "Tidaklah cukup jika hanya menggunakan layanan pesan terenkripsi end-to-end, kita semua harus menggunakan layanan pesan yang melindungi privasi jaringan pribadi kita — dengan siapa kita terhubung.", "simplex-unique-overlay-card-1-p-1": "Berbeda dengan jaringan perpesanan lainnya, SimpleX tidak memiliki ID tetap kepada pengguna. SimpleX tidak bergantung pada nomor telepon, alamat berbasis domain (seperti email atau XMPP), nama pengguna, kunci publik, atau bahkan angka acak untuk mengidentifikasi penggunanya — operator server SimpleX tidak tahu berapa banyak orang yang menggunakan server mereka.", "simplex-unique-overlay-card-1-p-2": "Untuk menyampaikan pesan, SimpleX menggunakan alamat anonim penghubung dari antrean pesan searah, terpisah untuk pesan yang diterima dan dikirim, biasanya melalui server yang berbeda.", @@ -255,8 +255,8 @@ "simplex-unique-overlay-card-3-p-3": "Tidak seperti server jaringan terfederasi (email, XMPP atau Matrix), server SimpleX tidak menyimpan akun pengguna, mereka hanya menyampaikan pesan, melindungi privasi kedua belah pihak.", "simplex-unique-overlay-card-3-p-4": "Tidak ada pengenal atau teks sandi yang sama antara lalu lintas server yang dikirim dan diterima — jika ada orang yang mengamatinya, mereka tidak dapat dengan mudah menentukan siapa yang berkomunikasi dengan siapa, bahkan jika TLS disusupi.", "simplex-unique-overlay-card-4-p-1": "Anda dapat menggunakan SimpleX dengan server Anda sendiri dan tetap berkomunikasi dengan orang yang menggunakan server yang dikonfigurasikan sebelumnya dalam aplikasi.", - "simplex-unique-overlay-card-4-p-2": "Jaringan SimpleX menggunakan protokol terbuka dan menyediakan SDK untuk membuat bot obrolan, yang memungkinkan penerapan layanan yang dapat berinteraksi dengan pengguna melalui aplikasi SimpleX Chat — kami benar-benar menantikan untuk melihat layanan SimpleX apa yang akan Anda buat.", - "simplex-unique-overlay-card-4-p-3": "Jika Anda mempertimbangkan pengembangan untuk jaringan SimpleX, misalnya, bot obrolan untuk pengguna aplikasi SimpleX, atau integrasi pustaka Obrolan SimpleX ke dalam aplikasi seluler Anda, silakan hubungi kami untuk saran dan dukungan apa pun.", + "simplex-unique-overlay-card-4-p-2": "Jaringan SimpleX menggunakan protokol terbuka dan menyediakan SDK untuk membuat bot obrolan, yang memungkinkan penerapan layanan yang dapat berinteraksi dengan pengguna melalui aplikasi SimpleX Chat — kami benar-benar menantikan untuk melihat layanan SimpleX apa yang akan Anda buat.", + "simplex-unique-overlay-card-4-p-3": "Jika Anda mempertimbangkan pengembangan untuk jaringan SimpleX, misalnya, bot obrolan untuk pengguna aplikasi SimpleX, atau integrasi pustaka Obrolan SimpleX ke dalam aplikasi seluler Anda, silakan hubungi kami untuk saran dan dukungan apa pun.", "simplex-unique-card-1-p-1": "SimpleX melindungi privasi profil, kontak, dan metadata Anda, menyembunyikannya dari server jaringan SimpleX dan pengamat mana pun.", "get-simplex": "Dapatkan aplikasi desktop SimpleX", "index-hero-h1": "
Bebaslah
", diff --git a/website/langs/it.json b/website/langs/it.json index a482d23c03..649637b8e2 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -57,7 +57,7 @@ "hero-overlay-card-1-p-1": "Molti utenti hanno chiesto: se SimpleX non ha identificatori utente, come può sapere dove recapitare i messaggi? ", "hero-overlay-card-1-p-3": "Sei tu a definire quale/i server usare per ricevere i messaggi, i tuoi contatti i server che usi per inviare loro i messaggi. È probabile che ogni conversazione utilizzi due server diversi.", "hero-overlay-card-1-p-5": "Solo i dispositivi client conservano profili utente, contatti e gruppi; i messaggi vengono inviati con crittografia end-to-end a 2 livelli.", - "hero-overlay-card-1-p-6": "Maggiori informazioni nel libro bianco di SimpleX.", + "hero-overlay-card-1-p-6": "Maggiori informazioni nel libro bianco di SimpleX.", "hero-overlay-card-2-p-2": "Potrebbero quindi correlare queste informazioni con i social network pubblici esistenti e determinare alcune identità reali.", "hero-overlay-card-2-p-4": "SimpleX protegge da questi attacchi non avendo alcun ID utente per design. Inoltre, se usi la modalità di modalità in incognito, il tuo nome mostrato sarà diverso per ogni contatto, evitando dati condivisi tra di loro.", "simplex-network-overlay-card-1-li-2": "Il design di SimpleX, a differenza della maggior parte delle reti P2P, non ha identificatori utente globali di alcun tipo, nemmeno temporanei, e usa solo identificatori temporanei a coppie, garantendo una maggiore protezione dell'anonimato e dei metadati.", @@ -67,14 +67,14 @@ "privacy-matters-overlay-card-1-p-4": "La rete di SimpleX protegge la privacy delle tue connessioni meglio di qualsiasi alternativa, impedendo completamente che il tuo grafico sociale diventi disponibile a qualsiasi azienda o organizzazione. Anche quando le persone usano i server preconfigurati in SimpleX Chat, gli operatori dei server non conoscono il numero di utenti o le loro connessioni.", "privacy-matters-overlay-card-2-p-3": "SimpleX è la prima rete che non ha alcun identificatore utente per design, proteggendo così il tuo grafico delle connessioni meglio di qualsiasi alternativa conosciuta.", "privacy-matters-overlay-card-3-p-1": "Tutti dovrebbero preoccuparsi della privacy e della sicurezza delle proprie comunicazioni — conversazioni innocue possono metterti in pericolo, anche se non hai nulla da nascondere.", - "privacy-matters-overlay-card-3-p-3": "Le persone comuni vengono arrestate per ciò che condividono online, anche tramite i loro account \"anonimi\", anche nei Paesi democratici.", + "privacy-matters-overlay-card-3-p-3": "Le persone comuni vengono arrestate per ciò che condividono online, anche tramite i loro account \"anonimi\", anche nei Paesi democratici.", "simplex-unique-overlay-card-1-p-3": "Questo design protegge la privacy di chi stai comunicando, nascondendola ai server della rete SimpleX e a qualsiasi osservatore. Per nascondere il tuo indirizzo IP ai server, puoi connetterti ai server SimpleX tramite Tor.", "simplex-unique-overlay-card-2-p-1": "Poiché non hai alcun identificatore sulla rete SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come un codice QR o un link.", "simplex-unique-overlay-card-2-p-2": "Anche l'indirizzo utente opzionale, che può essere usato per inviare richieste di contatto spam, è possibile modificarlo o eliminarlo completamente senza perdere alcuna connessione.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat conserva tutti i dati utente solo sui dispositivi client usando un formato trasferibile di database crittografato che può essere esportato e trasferito su qualsiasi dispositivo supportato.", "simplex-unique-overlay-card-3-p-2": "I messaggi crittografati end-to-end vengono conservati temporaneamente sui server di inoltro SimpleX fino alla ricezione, quindi vengono eliminati definitivamente.", "simplex-unique-overlay-card-4-p-1": "Puoi usare SimpleX con i tuoi server personali e continuare a comunicare con le persone che usano i server preconfigurati nelle app.", - "simplex-unique-overlay-card-4-p-3": "Se stai pensando di sviluppare per la rete SimpleX, ad esempio, il chat bot per gli utenti dell'app SimpleX o l'integrazione della libreria SimpleX Chat nelle tue app mobili, contattaci per qualsiasi consiglio e supporto.", + "simplex-unique-overlay-card-4-p-3": "Se stai pensando di sviluppare per la rete SimpleX, ad esempio, il chat bot per gli utenti dell'app SimpleX o l'integrazione della libreria SimpleX Chat nelle tue app mobili, contattaci per qualsiasi consiglio e supporto.", "simplex-unique-card-1-p-1": "SimpleX protegge la privacy del tuo profilo, contatti e metadati, nascondendoli ai server della rete SimpleX e ad eventuali osservatori.", "simplex-unique-card-1-p-2": "A differenza di qualsiasi altra rete di messaggistica esistente, SimpleX non ha identificatori assegnati agli utenti — nemmeno numeri casuali.", "simplex-unique-card-2-p-1": "Poiché non hai un identificatore o un indirizzo fisso sulla rete SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come codice un QR o un link.", @@ -155,7 +155,7 @@ "developers": "Sviluppatori", "simplex-explained-tab-1-p-1": "Puoi creare contatti e gruppi, ed avere conversazioni bidirezionali, come in qualsiasi altro messenger.", "feature-2-title": "Immagini, video e file
crittografati E2E", - "simplex-private-card-4-point-2": "Per usare SimpleX tramite Tor, installa l'app Orbot e attiva il proxy SOCKS5 (o VPN su iOS).", + "simplex-private-card-4-point-2": "Per usare SimpleX tramite Tor, installa l'app Orbot e attiva il proxy SOCKS5 (o VPN su iOS).", "reference": "Riferimenti", "simplex-explained-tab-3-text": "3. Cosa vedono i server", "chat-bot-example": "Esempio di chat bot", @@ -180,10 +180,10 @@ "privacy-matters-overlay-card-1-p-1": "Molte grandi aziende usano le informazioni sulle persone con cui sei connesso per stimare il tuo reddito, venderti i prodotti di cui non hai realmente bisogno e determinare i prezzi.", "simplex-network-overlay-card-1-li-5": "Tutte le reti P2P conosciute possono essere vulnerabili all'attacco di Sybil, perché ogni nodo è rilevabile e la rete opera come un tutt'uno. Le misure note per mitigarlo richiedono un componente centralizzato o proof-of-work costosi. La rete di SimpleX non ha la possibilità di scoprire i server, è frammentata e opera come più sottoreti isolate, rendendo impossibili gli attacchi a livello di rete.", "privacy-matters-overlay-card-1-p-2": "I rivenditori online sanno che le persone con redditi più bassi sono più propense a fare acquisti urgenti, quindi possono applicare prezzi più alti o rimuovere sconti.", - "privacy-matters-overlay-card-1-p-3": "Alcune società finanziarie e assicurative usano grafici sociali per determinare i tassi di interesse e i premi. Spesso ciò fa pagare di più le persone con redditi più bassi — è noto come \"premio di povertà\".", - "privacy-matters-overlay-card-2-p-1": "Non molto tempo fa abbiamo assistito alla manipolazione delle principali elezioni da una rispettabile società di consulenza che ha usato i nostri grafici sociali per distorcere la nostra visione del mondo reale e manipolare i nostri voti.", + "privacy-matters-overlay-card-1-p-3": "Alcune società finanziarie e assicurative usano grafici sociali per determinare i tassi di interesse e i premi. Spesso ciò fa pagare di più le persone con redditi più bassi — è noto come \"premio di povertà\".", + "privacy-matters-overlay-card-2-p-1": "Non molto tempo fa abbiamo assistito alla manipolazione delle principali elezioni da una rispettabile società di consulenza che ha usato i nostri grafici sociali per distorcere la nostra visione del mondo reale e manipolare i nostri voti.", "privacy-matters-overlay-card-2-p-2": "Per essere obiettivi e prendere decisioni indipendenti devi avere il controllo del tuo spazio informativo. È possibile solo se usi una rete di comunicazione privata che non ha accesso al tuo grafico sociale.", - "privacy-matters-overlay-card-3-p-2": "Una delle storie più scioccanti è l'esperienza di Mohamedou Ould Salahi descritta nel suo libro di memorie e mostrata nel film The Mauritanian. È stato rinchiuso nel campo di Guantánamo, senza processo, e lì è stato torturato per 15 anni dopo una telefonata a un suo parente in Afghanistan, sospettato di essere coinvolto negli attacchi dell'11/9, nonostante avesse vissuto in Germania per i precedenti 10 anni.", + "privacy-matters-overlay-card-3-p-2": "Una delle storie più scioccanti è l'esperienza di Mohamedou Ould Salahi descritta nel suo libro di memorie e mostrata nel film The Mauritanian. È stato rinchiuso nel campo di Guantánamo, senza processo, e lì è stato torturato per 15 anni dopo una telefonata a un suo parente in Afghanistan, sospettato di essere coinvolto negli attacchi dell'11/9, nonostante avesse vissuto in Germania per i precedenti 10 anni.", "join-us-on-GitHub": "Unisciti a noi su GitHub", "simplex-chat-for-the-terminal": "SimpleX Chat per il terminale", "privacy-matters-overlay-card-3-p-4": "Non è sufficiente usare un messenger crittografato end-to-end, tutti dovremmo usare i messenger che proteggono la privacy delle nostre reti personali — con chi siamo connessi.", @@ -195,7 +195,7 @@ "join-the-REDDIT-community": "Unisciti alla comunità di REDDIT", "sign-up-to-receive-our-updates": "Iscriviti per ricevere i nostri aggiornamenti", "simplex-unique-overlay-card-3-p-4": "Non ci sono identificatori o testi cifrati in comune tra il traffico del server inviato e quello ricevuto — se qualcuno lo osserva, non può determinare facilmente chi comunica con chi, anche se il TLS è compromesso.", - "simplex-unique-overlay-card-4-p-2": "La rete di SimpleX usa un protocollo aperto e fornisce un SDK per creare chat bot, consentendo l'implementazione di servizi con cui gli utenti possono interagire tramite le app SimpleX Chat — siamo impazienti di vedere quali servizi SimpleX creerai.", + "simplex-unique-overlay-card-4-p-2": "La rete di SimpleX usa un protocollo aperto e fornisce un SDK per creare chat bot, consentendo l'implementazione di servizi con cui gli utenti possono interagire tramite le app SimpleX Chat — siamo impazienti di vedere quali servizi SimpleX creerai.", "simplex-unique-card-4-p-1": "La rete SimpleX è completamente decentralizzata e indipendente da qualsiasi criptovaluta o altra rete, ad eccezione di internet.", "donate-here-to-help-us": "Dona qui per aiutarci", "to-make-a-connection": "Per stabilire una connessione:", diff --git a/website/langs/ja.json b/website/langs/ja.json index 087a18ac6a..4c850853e4 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -24,8 +24,8 @@ "guide-dropdown-8": "アプリ設定", "menu": "メニュー", "simplex-unique-card-1-p-1": "SimpleXは、SimpleXネットワークのサーバやその他の観察者から隠すことで、あなたのプロフィール、連絡先やメタデータのプライバシーを守ります。", - "simplex-unique-overlay-card-4-p-3": "例えば、SimpleXアプリユーザへのチャットボットやSimpleX Chatライブラリーの携帯アプリへの統合など、SimpleXネットワークに関する開発を検討してくださっているようでしたら、どのようなアドバイスや支援のことでもご連絡ください 。", - "simplex-unique-overlay-card-4-p-2": "SimpleXネットワークは、SimpleX Chatアプリを介してユーザが交流するサービスを実装させつつオープンプロトコルを使い、チャットボットを作成するためにSDKを提供します—私たちはあなた達がどのようなSimpleXのサービスを築くか本当に楽しみです。", + "simplex-unique-overlay-card-4-p-3": "例えば、SimpleXアプリユーザへのチャットボットやSimpleX Chatライブラリーの携帯アプリへの統合など、SimpleXネットワークに関する開発を検討してくださっているようでしたら、どのようなアドバイスや支援のことでもご連絡ください 。", + "simplex-unique-overlay-card-4-p-2": "SimpleXネットワークは、SimpleX Chatアプリを介してユーザが交流するサービスを実装させつつオープンプロトコルを使い、チャットボットを作成するためにSDKを提供します—私たちはあなた達がどのようなSimpleXのサービスを築くか本当に楽しみです。", "simplex-unique-overlay-card-4-p-1": "あなたが、自分自身のサーバでSimpleXを使っても、アプリで予め設定されたサーバを使う方々と連絡を取ることができます。", "reference": "リファレンス", "simplex-explained-tab-1-text": "1. ユーザーが経験すること", @@ -64,7 +64,7 @@ "feature-7-title": "ポータブルな暗号化データベース — プロファイルを別のデバイスに移動する", "no-federated": "いいえ - 連合型", "simplex-unique-overlay-card-3-p-3": "電子メール、XMPP、Matrixなどの連携ネットワークサーバーとは異なり、SimpleXサーバーはユーザーアカウントを保存せず、メッセージの中継のみを行い、双方のプライバシーを保護します。", - "privacy-matters-overlay-card-3-p-2": "最も衝撃的な話の 1 つは、Mohamedou Ould Salahiの経験であり、彼の回顧録に記述され、『モーリタニア映画』で紹介されています。彼はアフガニスタンの親戚に電話をかけた後、アメリカ同時多発テロへの関与の疑いで拘束されました。 彼は裁判も受けずにグアンタナモ収容所に入れられ、そこで15年間拷問を受けました。", + "privacy-matters-overlay-card-3-p-2": "最も衝撃的な話の 1 つは、Mohamedou Ould Salahiの経験であり、彼の回顧録に記述され、『モーリタニア映画』で紹介されています。彼はアフガニスタンの親戚に電話をかけた後、アメリカ同時多発テロへの関与の疑いで拘束されました。 彼は裁判も受けずにグアンタナモ収容所に入れられ、そこで15年間拷問を受けました。", "signing-key-fingerprint": "署名キーのフィンガープリント (SHA-256)", "simplex-network-2-desc": "SimpleX リレー サーバーは、ユーザー プロファイル、連絡先、配信されたメッセージを保存せず、相互に接続せず、サーバー ディレクトリもありません。", "docs-dropdown-5": "ホストXFTPサーバー", @@ -109,7 +109,7 @@ "no": "いいえ", "contact-hero-header": "SimpleX Chatで接続するためのアドレスを受信しました", "feature-8-title": "シークレット モード —
SimpleX Chat に固有の", - "simplex-private-card-4-point-2": "Tor経由でSimpleXを使用するには、Orbotアプリをインストールし、SOCKS5プロキシを有効にしてください(iOSの場合はVPN)。", + "simplex-private-card-4-point-2": "Tor経由でSimpleXを使用するには、Orbotアプリをインストールし、SOCKS5プロキシを有効にしてください(iOSの場合はVPN)。", "contact-hero-subheader": "スマホ・タブレットのSimpleX ChatアプリでQRコードを読み取ってください。", "simplex-unique-2-overlay-1-title": "スパムと悪用からの最高の保護", "simplex-private-6-title": "帯域外の
鍵交換", @@ -128,7 +128,7 @@ "comparison-point-1-text": "グローバル ID が必要", "comparison-section-list-point-5": "ユーザーのメタデータのプライバシーを保護しない", "hero-overlay-card-2-p-2": "その後、この情報を既存の公開ソーシャル ネットワークと関連付けて、本当の身元を特定することができます。", - "privacy-matters-overlay-card-1-p-3": "一部の金融会社や保険会社は、ソーシャル グラフを使用して金利や保険料を決定しています。 多くの場合、収入の低い人にはより多くの料金を支払わなければなりません。 —これは、\"「貧困プレミアム」\" として知られています。", + "privacy-matters-overlay-card-1-p-3": "一部の金融会社や保険会社は、ソーシャル グラフを使用して金利や保険料を決定しています。 多くの場合、収入の低い人にはより多くの料金を支払わなければなりません。 —これは、\"「貧困プレミアム」\" として知られています。", "comparison-point-3-text": "DNS への依存", "yes": "はい", "docs-dropdown-6": "WebRTC サーバー", @@ -190,7 +190,7 @@ "feature-6-title": "E2E暗号化された
音声通話とビデオ通話", "simplex-network-overlay-card-1-li-2": "SimpleX 設計は、ほとんどの P2P ネットワークとは異なり、一時的であってもいかなる種類のグローバル ユーザー識別子も持たず、一時的なペアごとの識別子のみを使用するため、より優れた匿名性とメタデータ保護が提供されます。", "simplex-unique-4-title": "SimpleX ネットワークを所有", - "privacy-matters-overlay-card-3-p-3": "一般の人が、たとえ「匿名」アカウント経由であっても、オンラインで共有した内容で逮捕されます。それは、たとえ民主主義の国であったとしても同じです。", + "privacy-matters-overlay-card-3-p-3": "一般の人が、たとえ「匿名」アカウント経由であっても、オンラインで共有した内容で逮捕されます。それは、たとえ民主主義の国であったとしても同じです。", "simplex-unique-overlay-card-3-p-2": "エンドツーエンドで暗号化されたメッセージは、SimpleXのリレーサーバーで受信するまで一時的に保持され、その後永久に削除されます。", "simplex-private-card-7-point-1": "整合性を保証するために、メッセージには連続した番号が付けられ、前のメッセージのハッシュが含まれます。", "contact-hero-p-2": "SimpleX Chat をまだダウンロードしていませんか?", @@ -209,7 +209,7 @@ "hero-overlay-2-title": "ユーザー ID がプライバシーに悪影響を与えるのはなぜですか?", "docs-dropdown-4": "ホストSMPサーバー", "feature-4-title": "E2E暗号化された音声メッセージ", - "privacy-matters-overlay-card-2-p-1": "つい最近まで、主要な選挙が 有名なコンサルティング会社によって操作されていました。 ソーシャルグラフは私たちの現実世界の見方を歪め、投票を操作しています。", + "privacy-matters-overlay-card-2-p-1": "つい最近まで、主要な選挙が 有名なコンサルティング会社によって操作されていました。 ソーシャルグラフは私たちの現実世界の見方を歪め、投票を操作しています。", "privacy-matters-overlay-card-2-p-3": "SimpleX は、設計上ユーザー識別子を持たない最初のネットワークであり、この方法で既知の代替手段よりも接続グラフを保護します。", "learn-more": "さらに詳しく", "simplex-private-8-title": "メッセージのミキシング
相関性を減らす", @@ -233,7 +233,7 @@ "simplex-unique-overlay-card-1-p-3": "この設計により、通信相手のプライバシーが保護され、SimpleX ネットワークサーバや監視者からプライバシーが隠されます。 IP アドレスをサーバから隠すには、Tor 経由で SimpleX サーバーに接続します。", "simplex-private-7-title": "メッセージの整合性
検証", "privacy-matters-overlay-card-1-p-4": "SimpleXネットワークは、他のどのプラットフォームよりも接続のプライバシーを保護し、ソーシャル グラフが企業や組織に利用されることを完全に防ぎます。 SimpleX Chatアプリに予め設定されたサーバを利用している場合でも、サーバオペレータはユーザーの数や接続数を知ることはできません。", - "hero-overlay-card-1-p-6": "詳細については、SimpleX ホワイトペーパーをご覧ください。", + "hero-overlay-card-1-p-6": "詳細については、SimpleX ホワイトペーパーをご覧ください。", "simplex-network-overlay-card-1-p-1": "P2P メッセージング プロトコルとアプリには、SimpleX よりも信頼性が低く、分析がより複雑になるさまざまな問題があり、また、いくつかの種類の攻撃に対して脆弱です。", "simplex-network-overlay-card-1-li-1": "P2P ネットワークは、メッセージをルーティングするために DHT の一部の変種に依存します。 DHT の設計では、配信保証と遅延のバランスを取る必要があります。 SimpleX は、受信者が選択したサーバーを使用して、メッセージを複数のサーバーを介して並行して冗長的に渡すことができるため、P2P よりも優れた配信保証と低い遅延の両方を備えています。 P2P ネットワークでは、メッセージはアルゴリズムによって選択されたノードを使用して、O(log N) 個のノードを順番に通過します。", "privacy-matters-section-label": "メッセージアプリがあなたのデータにアクセスできないようにしてください!", diff --git a/website/langs/nl.json b/website/langs/nl.json index aebab52842..a7d3741e27 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -70,7 +70,7 @@ "simplex-private-6-title": "Out-of-band
sleuteluitwisseling", "simplex-private-7-title": "Bericht integriteit
verificatie", "simplex-private-card-4-point-1": "Om uw IP-adres te beschermen kunt u via Tor of een ander transport overlay netwerk toegang krijgen tot de servers.", - "simplex-private-card-4-point-2": "Installeer de Orbot-app en schakel de SOCKS5-proxy in (of VPN op iOS ) om SimpleX via Tor te gebruiken.", + "simplex-private-card-4-point-2": "Installeer de Orbot-app en schakel de SOCKS5-proxy in (of VPN op iOS ) om SimpleX via Tor te gebruiken.", "simplex-private-card-6-point-2": "Om te voorkomen dat SimpleX apps eenmalige sleutels out-of-band doorgeven, wanneer u een adres deelt als een link of een QR-code.", "simplex-private-card-7-point-1": "Om de integriteit te garanderen, zijn de berichten opeenvolgend genummerd en bevatten ze de hash van het vorige bericht.", "simplex-private-card-9-point-1": "Elke berichten wachtrij geeft berichten in één richting door, met verschillende verzend en ontvangst adressen.", @@ -96,7 +96,7 @@ "hero-overlay-card-2-p-1": "Wanneer gebruikers een blijvende identiteit hebben, zelfs als dit slechts een willekeurig nummer is, zoals een sessie ID, bestaat het risico dat de provider of een aanvaller kan zien hoe de gebruikers zijn verbonden en hoeveel berichten ze verzenden.", "hero-overlay-card-1-p-4": "Dit ontwerp voorkomt het lekken van eventuele gebruikersgegevens en metadata op applicatie niveau. Om de privacy verder te verbeteren en uw IP-adres te beschermen kunt u via Tor verbinding maken met berichten servers.", "hero-overlay-card-1-p-5": "Alleen client apparaten slaan gebruikers profielen, contacten en groepen op; de berichten worden verzonden met 2-laags end-to-end versleuteling.", - "hero-overlay-card-1-p-6": "Lees meer in SimpleX-whitepaper.", + "hero-overlay-card-1-p-6": "Lees meer in SimpleX-whitepaper.", "hero-overlay-card-2-p-2": "Ze zouden deze informatie vervolgens kunnen correleren met de bestaande openbare sociale netwerken en een aantal echte identiteiten kunnen bepalen.", "privacy-matters-3-overlay-1-linkText": "Privacy beschermt uw vrijheid", "simplex-unique-2-overlay-1-title": "De beste bescherming tegen spam en misbruik", @@ -178,14 +178,14 @@ "simplex-network-overlay-card-1-li-6": "P2P netwerken kunnen kwetsbaar zijn voor DRDoS-aanvallen, wanneer de clients het verkeer opnieuw kunnen uitzenden en versterken, wat resulteert in netwerkbrede denial of service. SimpleX clients sturen alleen verkeer van een bekende verbinding door en kunnen niet door een aanvaller worden gebruikt om het verkeer in het hele netwerk te versterken.", "privacy-matters-overlay-card-1-p-1": "Veel grote bedrijven gebruiken informatie over met wie u verbonden bent om uw inkomen te schatten, u de producten te verkopen die u niet echt nodig heeft en om de prijzen te bepalen.", "privacy-matters-overlay-card-1-p-2": "Webwinkels weten dat mensen met lagere inkomens eerder geneigd zijn om dringende aankopen te doen, dus kunnen ze hogere prijzen vragen of kortingen intrekken.", - "privacy-matters-overlay-card-1-p-3": "Sommige financiële en verzekeringsmaatschappijen gebruiken sociale grafieken om rentetarieven en premies te bepalen. Het zorgt er vaak voor dat mensen met lagere inkomens meer betalen — het staat bekend als 'armoedepremie'.", + "privacy-matters-overlay-card-1-p-3": "Sommige financiële en verzekeringsmaatschappijen gebruiken sociale grafieken om rentetarieven en premies te bepalen. Het zorgt er vaak voor dat mensen met lagere inkomens meer betalen — het staat bekend als 'armoedepremie'.", "privacy-matters-overlay-card-1-p-4": "SimpleX Network beschermt de privacy van uw verbindingen beter dan welk alternatief dan ook en voorkomt dat uw sociale netwerk beschikbaar wordt voor bedrijven of organisaties. Zelfs wanneer gebruikers servers gebruiken die vooraf zijn geconfigureerd in SimpleX Chat-apps, weten serverbeheerders niet hoeveel gebruikers er zijn of hoeveel verbindingen ze hebben.", "privacy-matters-overlay-card-2-p-2": "Om objectief te zijn en onafhankelijke beslissingen te nemen, moet je de controle hebben over je informatieruimte. Dit is alleen mogelijk als je gebruikmaakt van een privécommunicatienetwerk dat geen toegang heeft tot je sociale media-account.", "privacy-matters-overlay-card-2-p-3": "SimpleX is het eerste netwerk dat standaard geen gebruikers-ID's gebruikt. Hierdoor is uw verbindingsgrafiek beter beschermd dan elk ander bekend alternatief.", "privacy-matters-overlay-card-3-p-1": "Iedereen zou zich zorgen moeten maken over de privacy en veiligheid van hun communicatie — onschuldige gesprekken kunnen je in gevaar brengen, zelfs als je niets te verbergen hebt.", "simplex-unique-overlay-card-1-p-1": "In tegenstelling tot andere berichtennetwerken heeft SimpleX geen identificatiegegevens toegewezen aan de gebruikers. Het maakt geen gebruik van telefoonnummers, domeingebaseerde adressen (zoals e-mail of XMPP), gebruikersnamen, openbare sleutels of zelfs willekeurige getallen om gebruikers te identificeren – SimpleX-serverbeheerders weten niet hoeveel mensen hun servers gebruiken.", - "privacy-matters-overlay-card-3-p-2": "Een van de meest schokkende verhalen is de ervaring van Mohamedou Ould Salahi beschreven in zijn memoires en getoond in The Mauritanian movie. Hij werd zonder proces in het Guantanamo kamp geplaatst en werd daar 15 jaar lang gemarteld na een telefoontje naar zijn familielid in Afghanistan, op verdenking van betrokkenheid bij aanslagen van 9/11, ook al woonde hij de afgelopen 10 jaar in Duitsland.", - "privacy-matters-overlay-card-3-p-3": "Gewone mensen worden gearresteerd voor wat ze online delen, zelfs via hun 'anonieme' accounts, zelfs in democratische landen.", + "privacy-matters-overlay-card-3-p-2": "Een van de meest schokkende verhalen is de ervaring van Mohamedou Ould Salahi beschreven in zijn memoires en getoond in The Mauritanian movie. Hij werd zonder proces in het Guantanamo kamp geplaatst en werd daar 15 jaar lang gemarteld na een telefoontje naar zijn familielid in Afghanistan, op verdenking van betrokkenheid bij aanslagen van 9/11, ook al woonde hij de afgelopen 10 jaar in Duitsland.", + "privacy-matters-overlay-card-3-p-3": "Gewone mensen worden gearresteerd voor wat ze online delen, zelfs via hun 'anonieme' accounts, zelfs in democratische landen.", "privacy-matters-overlay-card-3-p-4": "Het is niet genoeg om een end-to-end versleutelde messenger te gebruiken, we zouden allemaal de messengers moeten gebruiken die de privacy van onze persoonlijke netwerken beschermen — met wie we verbonden zijn.", "simplex-unique-overlay-card-1-p-2": "Om berichten af te leveren gebruikt SimpleX paarsgewijze anonieme adressen van unidirectionele berichten wachtrijen, gescheiden voor ontvangen en verzonden berichten, meestal via verschillende servers.", "simplex-unique-overlay-card-1-p-3": "Dit ontwerp beschermt de privacy van uw communicatiepartner door deze te verbergen voor SimpleX-netwerkservers en andere buitenstaanders. Om uw IP-adres voor de servers te verbergen, kunt u via Tor verbinding maken met SimpleX-servers.", @@ -195,16 +195,16 @@ "simplex-unique-overlay-card-3-p-2": "De end-to-end versleutelde berichten worden tijdelijk op SimpleX relayservers bewaard totdat ze zijn ontvangen, waarna ze permanent worden verwijderd.", "simplex-unique-overlay-card-3-p-3": "In tegenstelling tot gefedereerde netwerkservers (e-mail, XMPP of Matrix), slaan SimpleX servers geen gebruikers account op, ze sturen alleen berichten door, waardoor de privacy van beide partijen wordt beschermd.", "simplex-unique-overlay-card-4-p-1": "U kunt SimpleX gebruiken met uw eigen servers en toch communiceren met mensen die de servers gebruiken die vooraf in de apps zijn geconfigureerd.", - "simplex-unique-overlay-card-4-p-3": "Als u overweegt om voor het SimpleX-netwerk te ontwikkelen, bijvoorbeeld de chatbot voor SimpleX-appgebruikers of de integratie van de SimpleX Chat-bibliotheek in uw mobiele apps, neem dan contact op voor advies en ondersteuning.", + "simplex-unique-overlay-card-4-p-3": "Als u overweegt om voor het SimpleX-netwerk te ontwikkelen, bijvoorbeeld de chatbot voor SimpleX-appgebruikers of de integratie van de SimpleX Chat-bibliotheek in uw mobiele apps, neem dan contact op voor advies en ondersteuning.", "simplex-unique-card-1-p-1": "SimpleX beschermt de privacy van uw profiel, contacten en metagegevens door deze te verbergen voor de netwerkservers van SimpleX en andere buitenstaanders.", "enter-your-email-address": "Voer uw e-mail adres in", "get-simplex": "Verkrijg SimpleX desktop app", "tap-to-close": "Tik om te sluiten", "contact-hero-header": "Je hebt een adres ontvangen om verbinding te maken met SimpleX Chat", "invitation-hero-header": "Je hebt een eenmalige link ontvangen om verbinding te maken met SimpleX Chat", - "privacy-matters-overlay-card-2-p-1": "Nog niet zo lang geleden zagen we dat de grote verkiezingen werden gemanipuleerd door een gerenommeerd adviesbureau dat onze sociale grafieken gebruikte om ons beeld van de echte wereld te vervormen en onze stemmen te manipuleren.", + "privacy-matters-overlay-card-2-p-1": "Nog niet zo lang geleden zagen we dat de grote verkiezingen werden gemanipuleerd door een gerenommeerd adviesbureau dat onze sociale grafieken gebruikte om ons beeld van de echte wereld te vervormen en onze stemmen te manipuleren.", "simplex-unique-overlay-card-3-p-4": "Er zijn geen identifiers of gecodeerde tekst tussen verzonden en ontvangen serververkeer — als iemand het waarneemt, kunnen ze niet gemakkelijk bepalen wie met wie communiceert, zelfs als TLS is gecompromitteerd.", - "simplex-unique-overlay-card-4-p-2": "SimpleX Network maakt gebruik van een open protocol en biedt SDK om chatbots te maken. Dit maakt de implementatie van services mogelijk waarmee gebruikers via SimpleX Chat-apps kunnen communiceren. We zijn erg benieuwd welke SimpleX-services jullie gaan bouwen.", + "simplex-unique-overlay-card-4-p-2": "SimpleX Network maakt gebruik van een open protocol en biedt SDK om chatbots te maken. Dit maakt de implementatie van services mogelijk waarmee gebruikers via SimpleX Chat-apps kunnen communiceren. We zijn erg benieuwd welke SimpleX-services jullie gaan bouwen.", "simplex-unique-card-1-p-2": "In tegenstelling tot andere bestaande berichtennetwerken worden bij SimpleX geen identificatiegegevens aan de gebruikers toegewezen – zelfs geen willekeurige getallen.", "comparison-section-list-point-1": "Meestal op basis van een telefoonnummer, in sommige gevallen op gebruikersnamen", "comparison-section-list-point-2": "Op DNS gebaseerde adressen", diff --git a/website/langs/pl.json b/website/langs/pl.json index 21a3576cb8..a760dc34cf 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -68,7 +68,7 @@ "simplex-private-card-6-point-2": "Aby temu zapobiec aplikacje SimpleX przekazują klucze jednorazowe out-of-band, gdy udostępniasz adres jako link lub kod QR.", "simplex-private-card-7-point-1": "Aby zagwarantować integralność, wiadomości są kolejno numerowane i zawierają hash poprzedniej wiadomości.", "simplex-private-card-7-point-2": "Jeśli jakaś wiadomość zostanie dodana, usunięta lub zmieniona, odbiorca zostanie o tym powiadomiony.", - "simplex-private-card-4-point-2": "Aby użyć SimpleX przez Tor zainstaluj aplikację Orbot na android i włącz proxy SOCKS5 (lub VPN na iOS).", + "simplex-private-card-4-point-2": "Aby użyć SimpleX przez Tor zainstaluj aplikację Orbot na android i włącz proxy SOCKS5 (lub VPN na iOS).", "simplex-private-card-5-point-1": "SimpleX używa paddingu treści dla każdej warstwy szyfrowania, aby udaremnić ataki na rozmiar wiadomości.", "simplex-private-card-5-point-2": "Dzięki temu wiadomości o różnych rozmiarach wyglądają tak samo dla serwerów i obserwatorów sieci.", "simplex-private-card-8-point-1": "Serwery SimpleX działają jak węzły mieszania o niskim opóźnieniu — wiadomości przychodzące i wychodzące mają inną kolejność.", @@ -115,23 +115,23 @@ "hero-overlay-card-1-p-1": "Wielu użytkowników pytało: jeśli SimpleX nie ma identyfikatorów użytkowników, skąd może wiedzieć, gdzie dostarczyć wiadomości?", "hero-overlay-card-1-p-2": "Aby dostarczyć wiadomości, zamiast identyfikatorów użytkownika używanych przez wszystkie inne sieci, SimpleX używa tymczasowych anonimowych identyfikatorów kolejek wiadomości, oddzielnych dla każdego z twoich połączeń — nie ma żadnych długoterminowych identyfikatorów.", "simplex-network-overlay-card-1-li-1": "Sieci P2P opierają się na pewnym wariancie DHT do przekazywania wiadomości. DHT muszą równoważyć gwarancję dostarczenia i opóźnienie. SimpleX ma zarówno lepszą gwarancję dostarczenia, jak i mniejsze opóźnienia niż P2P, ponieważ wiadomość może być nadmiarowo przepuszczona przez kilka serwerów równolegle, z wykorzystaniem serwerów wybranych przez odbiorcę. W sieciach P2P wiadomość jest przepuszczana przez , O(log N) węzłów sekwencyjnie, z wykorzystaniem węzłów wybranych przez algorytm.", - "privacy-matters-overlay-card-3-p-3": "Zwykli ludzie są aresztowani za to, co udostępniają w sieci, nawet za pośrednictwem swoich \"anonimowych\" kont, nawet w krajach demokratycznych.", - "hero-overlay-card-1-p-6": "Więcej w SimpleX whitepaper.", + "privacy-matters-overlay-card-3-p-3": "Zwykli ludzie są aresztowani za to, co udostępniają w sieci, nawet za pośrednictwem swoich \"anonimowych\" kont, nawet w krajach demokratycznych.", + "hero-overlay-card-1-p-6": "Więcej w SimpleX whitepaper.", "hero-overlay-card-2-p-1": "Gdy użytkownicy mają trwałe tożsamości, nawet jeśli jest to tylko losowa liczba, jak ID sesji, istnieje ryzyko, że dostawca lub atakujący może obserwować, jak użytkownicy są połączeni i ile wiadomości wysyłają.", "hero-overlay-card-2-p-2": "Następnie mogliby skorelować te informacje z istniejącymi publicznymi sieciami społecznymi i określić niektóre prawdziwe tożsamości.", "hero-overlay-card-2-p-4": "SimpleX chroni przed tymi atakami, nie mając w swojej konstrukcji żadnych identyfikatorów użytkowników. A jeśli używasz trybu Incognito, będziesz miał inną nazwę wyświetlania dla każdego kontaktu, unikając wszelkich wspólnych danych między nimi.", "simplex-network-overlay-card-1-p-1": "Protokoły P2P i aplikacje do przesyłania wiadomości mają różne problemy, które czynią je mniej niezawodnymi niż SimpleX, bardziej skomplikowanymi w analizie i podatnymi na kilka rodzajów ataków.", "simplex-network-overlay-card-1-li-2": "Projekt SimpleX, w przeciwieństwie do większości sieci P2P, nie posiada żadnych globalnych identyfikatorów użytkowników, nawet tymczasowych, a jedynie używa tymczasowych identyfikatorów parami, zapewniając lepszą anonimowość i ochronę metadanych.", "simplex-network-overlay-card-1-li-4": "Implementacje P2P mogą być blokowane przez niektórych dostawców Internetu (np. BitTorrent). SimpleX jest agnostykiem transportowym — może działać przez standardowe protokoły internetowe, np. WebSockets.", - "privacy-matters-overlay-card-1-p-3": "Niektóre firmy finansowe i ubezpieczeniowe wykorzystują wykresy społeczne do określania stóp procentowych i składek. Często powoduje to, że osoby o niższych dochodach płacą więcej — jest to znane jako \"premia za ubóstwo\".", + "privacy-matters-overlay-card-1-p-3": "Niektóre firmy finansowe i ubezpieczeniowe wykorzystują wykresy społeczne do określania stóp procentowych i składek. Często powoduje to, że osoby o niższych dochodach płacą więcej — jest to znane jako \"premia za ubóstwo\".", "privacy-matters-overlay-card-1-p-4": "Sieć SimpleX chroni prywatność Twoich połączeń lepiej niż jakakolwiek alternatywa, w pełni zapobiegając udostępnieniu Twojego wykresu społecznego jakimkolwiek firmom lub organizacjom. Nawet gdy ludzie korzystają z serwerów prekonfigurowanych przez SimpleX Chat, aplikacje i operatorzy serwerów nie znają liczby użytkowników ani ich połączeń.", "privacy-matters-overlay-card-1-p-2": "Sprzedawcy internetowi wiedzą, że osoby o niższych dochodach częściej dokonują pilnych zakupów, więc mogą naliczać wyższe ceny lub usuwać rabaty.", "privacy-matters-overlay-card-2-p-2": "Aby być obiektywnym i podejmować niezależne decyzje musisz mieć kontrolę nad swoją przestrzenią informacyjną. Jest to możliwe tylko wtedy, gdy korzystasz z prywatnej sieci komunikacyjnej, która nie ma dostępu do Twojego wykresu społecznego.", "privacy-matters-overlay-card-2-p-3": "SimpleX to pierwsza sieć, która z założenia nie posiada żadnych identyfikatorów użytkowników, w ten sposób chroniąc Twój graf połączeń lepiej niż jakakolwiek znana alternatywa.", "simplex-network-overlay-card-1-li-5": "Wszystkie znane sieci P2P mogą być podatne na atak Sybil, ponieważ każdy węzeł jest możliwy do odkrycia, a sieć działa jako całość. Znane środki łagodzące wymagają albo scentralizowanego komponentu, albo kosztownego proof of work. SimpleX nie ma możliwości odkrywania serwerów, jest pofragmentowana i działa jako wiele odizolowanych podsieci, co uniemożliwia ataki na całą sieć.", - "privacy-matters-overlay-card-2-p-1": "Jeszcze nie tak dawno obserwowaliśmy, jak główne wybory były manipulowane przez szacowną firmę konsultingową, która wykorzystywała nasze wykresy społeczne do zniekształcania naszego spojrzenia na świat rzeczywisty i manipulowania naszymi głosami.", + "privacy-matters-overlay-card-2-p-1": "Jeszcze nie tak dawno obserwowaliśmy, jak główne wybory były manipulowane przez szacowną firmę konsultingową, która wykorzystywała nasze wykresy społeczne do zniekształcania naszego spojrzenia na świat rzeczywisty i manipulowania naszymi głosami.", "privacy-matters-overlay-card-3-p-1": "Każdy powinien dbać o prywatność i bezpieczeństwo swojej komunikacji — nieszkodliwe rozmowy mogą narazić Cię na niebezpieczeństwo, nawet jeśli nie masz nic do ukrycia.", - "privacy-matters-overlay-card-3-p-2": "Jedną z najbardziej wstrząsających historii jest doświadczenie Mohamedou Ould Salahi opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech.", + "privacy-matters-overlay-card-3-p-2": "Jedną z najbardziej wstrząsających historii jest doświadczenie Mohamedou Ould Salahi opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech.", "privacy-matters-overlay-card-3-p-4": "Nie wystarczy używać komunikatora szyfrowanego end-to-end, wszyscy powinniśmy używać komunikatorów, które chronią prywatność naszych sieci osobistych — z kim jesteśmy połączeni.", "simplex-unique-overlay-card-1-p-1": "W przeciwieństwie do innych sieci komunikacyjnych, SimpleX ma brak identyfikatorów przypisanych do użytkowników. Nie polega na numerach telefonów, adresach opartych na domenie (jak e-mail lub XMPP), nazwach użytkowników, kluczach publicznych czy nawet losowych liczbach, aby zidentyfikować swoich użytkowników — operatorzy serwerów SimpleX nie wiedzą, ile osób korzysta z ich serwerów.", "hero-overlay-card-2-p-3": "Nawet w przypadku najbardziej prywatnych aplikacji, które korzystają z trzeciej wersji usług Tor, jeśli rozmawiasz z dwoma różnymi kontaktami za pośrednictwem tego samego profilu, mogą one udowodnić, że są połączone z tą samą osobą.", @@ -149,9 +149,9 @@ "simplex-unique-overlay-card-3-p-3": "W przeciwieństwie do serwerów sieci federacyjnych (e-mail, XMPP czy Matrix), serwery SimpleX nie przechowują kont użytkowników, a jedynie przekazują wiadomości, chroniąc prywatność obu stron.", "simplex-unique-overlay-card-4-p-1": "Możesz używać SimpleX z własnymi serwerami i nadal komunikować się z osobami, które korzystają z prekonfigurowanych w aplikacji serwerów.", "donate-here-to-help-us": "Przekaż darowiznę tutaj, aby nam pomóc", - "simplex-unique-overlay-card-4-p-3": "Jeśli rozważasz rozwój dla sieci SimpleX, na przykład czatbotu dla użytkowników aplikacji SimpleX lub integrację biblioteki SimpleX Chat w swoich aplikacjach mobilnych, prosimy o kontakt dla wszelkich porad i wsparcia.", + "simplex-unique-overlay-card-4-p-3": "Jeśli rozważasz rozwój dla sieci SimpleX, na przykład czatbotu dla użytkowników aplikacji SimpleX lub integrację biblioteki SimpleX Chat w swoich aplikacjach mobilnych, prosimy o kontakt dla wszelkich porad i wsparcia.", "simplex-unique-card-1-p-1": "SimpleX chroni prywatność Twojego profilu, kontaktów i metadanych, ukrywając je przed serwerami sieci SimpleX i wszelkimi obserwatorami.", - "simplex-unique-overlay-card-4-p-2": "Sieć SimpleX wykorzystuje otwarty protokół i udostępnia SDK do tworzenia chatbotów, umożliwiając implementację usług, z którymi użytkownicy mogą wchodzić w interakcje za pośrednictwem aplikacji SimpleX Chat — naprawdę nie możemy się doczekać, aby zobaczyć, jakie usługi SimpleX zbudujesz.", + "simplex-unique-overlay-card-4-p-2": "Sieć SimpleX wykorzystuje otwarty protokół i udostępnia SDK do tworzenia chatbotów, umożliwiając implementację usług, z którymi użytkownicy mogą wchodzić w interakcje za pośrednictwem aplikacji SimpleX Chat — naprawdę nie możemy się doczekać, aby zobaczyć, jakie usługi SimpleX zbudujesz.", "simplex-unique-card-1-p-2": "W przeciwieństwie do każdej innej istniejącej sieci komunikacyjnej, SimpleX nie ma żadnych identyfikatorów przypisanych do użytkowników — nawet losowych liczb.", "tap-the-connect-button-in-the-app": "Stuknij przycisk \"połącz się\" w aplikacji", "join-the-REDDIT-community": "Dołącz do społeczności REDDIT", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 52848e5cea..4b663e75ec 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -46,7 +46,7 @@ "simplex-private-3-title": "Transporte TLS
seguro e autenticado", "simplex-private-card-2-point-1": "Camada adicional de criptografia de servidor para entrega ao destinatário, para evitar a correlação entre o tráfego de servidor recebido e enviado se o TLS for comprometido.", "simplex-private-card-4-point-1": "Para proteger seu endereço IP, você pode acessar os servidores por meio do Tor ou de alguma outra rede de sobreposição de transporte.", - "simplex-private-card-4-point-2": "Para usar o SimpleX via Tor, instale o aplicativo Orbot e ative o proxy SOCKS5 (ou VPN no iOS).", + "simplex-private-card-4-point-2": "Para usar o SimpleX via Tor, instale o aplicativo Orbot e ative o proxy SOCKS5 (ou VPN no iOS).", "simplex-private-card-7-point-2": "Se alguma mensagem for adicionada, removida ou alterada, o destinatário será alertado.", "simplex-private-card-10-point-1": "O SimpleX usa endereços anônimos temporários em pares e credenciais para cada contato de usuário ou membro de grupo.", "privacy-matters-1-overlay-1-title": "A privacidade economiza seu dinheiro", @@ -68,13 +68,13 @@ "privacy-matters-overlay-card-2-p-2": "Para ser objetivo e tomar decisões independentes, você precisa ter o controle do seu espaço de informações. Isso só é possível se você usar uma rede de comunicação privada que não tenha acesso ao seu gráfico social.", "privacy-matters-overlay-card-3-p-1": "Todos devem se preocupar com a privacidade e a segurança de suas comunicações — conversas inofensivas podem colocá-lo em perigo, mesmo que você não tenha nada a esconder.", "simplex-unique-overlay-card-3-p-1": "O SimpleX Chat armazena todos os dados do usuário somente em dispositivos clientes usando um formato de banco de dados criptografado portátil que pode ser exportado e transferido para qualquer dispositivo compatível.", - "privacy-matters-overlay-card-3-p-2": "Uma das histórias mais chocantes é a experiência de Mohamedou Ould Salahi, descrita em seu livro de memórias e mostrada no filme O Mauritano. Ele foi colocado no campo de Guantánamo, sem julgamento, e lá foi torturado por 15 anos após um telefonema para seu parente no Afeganistão, sob suspeita de estar envolvido nos ataques de 11 de setembro, embora tenha vivido na Alemanha nos 10 anos anteriores.", + "privacy-matters-overlay-card-3-p-2": "Uma das histórias mais chocantes é a experiência de Mohamedou Ould Salahi, descrita em seu livro de memórias e mostrada no filme O Mauritano. Ele foi colocado no campo de Guantánamo, sem julgamento, e lá foi torturado por 15 anos após um telefonema para seu parente no Afeganistão, sob suspeita de estar envolvido nos ataques de 11 de setembro, embora tenha vivido na Alemanha nos 10 anos anteriores.", "simplex-unique-overlay-card-1-p-1": "Ao contrário de outras redes de mensagens, o SimpleX não tem identificadores atribuídos aos usuários. Ele não depende de números de telefone, endereços baseados em domínio (como email ou XMPP), nomes de usuário, chaves públicas ou mesmo números aleatórios para identificar seus usuários — Os operadores dos servidores não sabem quantas pessoas usam os servidores SimpleX.", "simplex-private-card-1-point-1": "Protocolo de dupla catraca —
mensagens OTR com Sigilo de Encaminhamento Perfeito (Perfect Forward Secrecy) e recuperação de invasão.", "simplex-private-8-title": "Mistura de mensagens
para reduzir a correlação", - "simplex-unique-overlay-card-4-p-2": "A rede SimpleX usa um protocolo aberto e fornece um SDK para criar bots de chat, permitindo a implementação de serviços com os quais os usuários podem interagir por meio dos aplicativos SimpleX Chat — estamos' realmente ansiosos para ver quais serviços SimpleX você pode criar.", + "simplex-unique-overlay-card-4-p-2": "A rede SimpleX usa um protocolo aberto e fornece um SDK para criar bots de chat, permitindo a implementação de serviços com os quais os usuários podem interagir por meio dos aplicativos SimpleX Chat — estamos' realmente ansiosos para ver quais serviços SimpleX você pode criar.", "simplex-unique-overlay-card-3-p-4": "Não há identificadores ou texto cifrado em comum entre o tráfego de servidor enviado e recebido — se alguém estiver observando, não poderá determinar facilmente quem se comunica com quem, mesmo que o TLS esteja comprometido.", - "simplex-unique-overlay-card-4-p-3": "Se estiver pensando em desenvolver para a rede SimpleX, por exemplo, o bot de chat para os usuários do aplicativo SimpleX ou a integração da biblioteca SimpleX Chat em seus aplicativos móveis, entre em contato para qualquer orientação e suporte.", + "simplex-unique-overlay-card-4-p-3": "Se estiver pensando em desenvolver para a rede SimpleX, por exemplo, o bot de chat para os usuários do aplicativo SimpleX ou a integração da biblioteca SimpleX Chat em seus aplicativos móveis, entre em contato para qualquer orientação e suporte.", "simplex-unique-card-2-p-1": "Como você não tem um identificador ou endereço fixo na rede SimpleX, ninguém pode entrar em contato com você, a menos que você compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", "simplex-unique-card-3-p-1": "O SimpleX armazena todos os dados do usuário nos dispositivos clientes em um formato de banco de dados criptografado portátil — que pode ser transferido para outro dispositivo.", "hide-info": "Ocultar informações", @@ -116,17 +116,17 @@ "simplex-unique-4-overlay-1-title": "Totalmente descentralizado — os usuários são proprietários da rede SimpleX", "hero-overlay-card-1-p-3": "Você define o(s) servidor(es) a ser(em) usado(s) para receber as mensagens, seus contatos — os servidores que você usa para enviar as mensagens a eles. É provável que cada conversa use dois servidores diferentes.", "hero-overlay-card-1-p-4": "Esse design evita o vazamento de metadados de qualquer usuário' no nível do aplicativo. Para aumentar ainda mais a privacidade e proteger seu endereço IP, você pode se conectar aos servidores de mensagens via Tor.", - "hero-overlay-card-1-p-6": "Leia mais no informativo técnico do SimpleX.", + "hero-overlay-card-1-p-6": "Leia mais no informativo técnico do SimpleX.", "hero-overlay-card-2-p-2": "Eles poderiam então correlacionar essas informações com as redes sociais públicas existentes e determinar algumas identidades reais.", "hero-overlay-card-2-p-4": "O SimpleX se protege contra esses ataques por não ter nenhum ID de usuário em seu design. E, se você usar o modo Anônimo, terá um nome de exibição diferente para cada contato, evitando qualquer compartilhamento de dados entre eles.", "simplex-network-overlay-card-1-p-1": "Os protocolos e aplicativos de mensagens P2P têm vários problemas que os tornam menos confiáveis do que o SimpleX, mais complexos de analisar e vulneráveis a vários tipos de ataque.", "simplex-network-overlay-card-1-li-2": "O design do SimpleX, ao contrário da maioria das redes P2P, não tem identificadores de usuário globais de qualquer tipo, mesmo temporários, e usa apenas identificadores temporários em pares, proporcionando melhor anonimato e proteção de metadados.", "simplex-network-overlay-card-1-li-3": "O P2P não resolve o problema do ataque MITM, e a maioria das implementações existentes não usa mensagens fora de banda para a troca de chaves inicial. O SimpleX usa mensagens fora de banda ou, em alguns casos, conexões pré-existentes seguras e confiáveis para a troca de chaves inicial.", "simplex-network-overlay-card-1-li-6": "As redes P2P podem ser vulneráveis a ataques DRDoS, quando os clientes podem retransmitir e amplificar o tráfego, resultando em uma negação de serviço em toda a rede. Os clientes SimpleX apenas retransmitem o tráfego de uma conexão conhecida e não podem ser usados por um invasor para amplificar o tráfego em toda a rede.", - "privacy-matters-overlay-card-1-p-3": "Algumas empresas financeiras e de seguros usam gráficos sociais para determinar taxas de juros e prêmios. Isso geralmente faz com que as pessoas com renda mais baixa paguem mais — isso é conhecido como \"prêmio de pobreza\".", - "privacy-matters-overlay-card-2-p-1": "Não faz muito tempo que observamos as eleições sendo manipuladas por uma empresa de consultoria respeitável que usou nossos gráficos sociais para distorcer nossa visão do mundo real e manipular nossos votos.", + "privacy-matters-overlay-card-1-p-3": "Algumas empresas financeiras e de seguros usam gráficos sociais para determinar taxas de juros e prêmios. Isso geralmente faz com que as pessoas com renda mais baixa paguem mais — isso é conhecido como \"prêmio de pobreza\".", + "privacy-matters-overlay-card-2-p-1": "Não faz muito tempo que observamos as eleições sendo manipuladas por uma empresa de consultoria respeitável que usou nossos gráficos sociais para distorcer nossa visão do mundo real e manipular nossos votos.", "privacy-matters-overlay-card-2-p-3": "O SimpleX é a primeira rede que não tem nenhum identificador de usuário por design, protegendo assim seu gráfico de conexões melhor do que qualquer alternativa conhecida.", - "privacy-matters-overlay-card-3-p-3": "Pessoas comuns são presas pelo que compartilham online, mesmo por meio de suas contas \"anônimas\", mesmo em países democráticos.", + "privacy-matters-overlay-card-3-p-3": "Pessoas comuns são presas pelo que compartilham online, mesmo por meio de suas contas \"anônimas\", mesmo em países democráticos.", "privacy-matters-overlay-card-3-p-4": "Não basta usar um mensageiro criptografado de ponta-a-ponta, todos nós devemos usar os mensageiros que protegem a privacidade de nossas redes pessoais — com quem estamos conectados.", "simplex-unique-overlay-card-1-p-2": "Para entregar mensagens, o SimpleX usa endereços anônimos em pares de filas de mensagens unidirecionais, separadas para mensagens recebidas e enviadas, geralmente por meio de servidores diferentes.", "simplex-unique-overlay-card-1-p-3": "Esse design protege a privacidade com quem você está se comunicando, ocultando-a dos servidores da rede SimpleX e de quaisquer observadores. Para ocultar seu endereço IP dos servidores, você pode se conectar aos servidores do SimpleX via Tor.", diff --git a/website/langs/ru.json b/website/langs/ru.json index 9e714e59ff..a8bdfa8725 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -14,7 +14,7 @@ "hero-2-header": "Установите конфиденциальное соединение", "simplex-unique-overlay-card-3-p-3": "В отличие от серверов федеративных сетей (электронной почты, XMPP или Matrix), серверы SimpleX не хранят учетные записи пользователей, они только ретранслируют сообщения, защищая конфиденциальность обеих сторон.", "hero-subheader": "Первый мессенджер
без идентификаторов пользователей", - "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй — это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистане, из-за подозрения в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", + "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй — это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистане, из-за подозрения в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", "signing-key-fingerprint": "Идентификатор ключа подписи (SHA-256)", "simplex-network-2-desc": "Серверные узлы SimpleX НЕ хранят профили пользователей, контакты и доставленные сообщения, НЕ подключаются друг к другу, и НЕ имеют каталога серверов.", "simplex-privacy": "Конфиденциальность SimpleX", @@ -73,7 +73,7 @@ "contact-hero-header": "Вы получили адрес контакта для соединения в SimpleX Chat", "feature-8-title": "Режим инкогнито —
уникальный для SimpleX Chat", "why-simplex": "Почему SimpleX", - "simplex-private-card-4-point-2": "Чтобы использовать SimpleX через сеть Tor, пожалуйста, установите приложение Orbot и включите прокси (или режим VPN на iOS).", + "simplex-private-card-4-point-2": "Чтобы использовать SimpleX через сеть Tor, пожалуйста, установите приложение Orbot и включите прокси (или режим VPN на iOS).", "contact-hero-subheader": "Отсканируйте QR-код с помощью приложения SimpleX Chat на Вашем телефоне или планшете.", "simplex-unique-2-overlay-1-title": "Лучшая защита от спама и злоупотреблений", "simplex-private-6-title": "Обмен ключами
вне протокола", @@ -95,7 +95,7 @@ "comparison-point-1-text": "Требуется глобальный идентификатор", "comparison-section-list-point-5": "Не защищает конфиденциальность пользовательских метаданных", "hero-overlay-card-2-p-2": "Эта информация может быть сопоставлена с социальными сетями, чтобы определить реальные личности пользователей.", - "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные сети для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"наценка за бедность\".", + "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные сети для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"наценка за бедность\".", "comparison-point-3-text": "Зависимость от DNS", "yes": "Да", "docs-dropdown-6": "Сервера WebRTC", @@ -157,7 +157,7 @@ "get-simplex": "Скачать SimpleX для компьютера", "privacy-matters-overlay-card-3-p-1": "Каждый должен заботиться о конфиденциальности и безопасности своих коммуникаций — безобидные разговоры могут подвергнуть Вас опасности, например за Ваши политические взгляды, даже если кажется, что Вам \"нечего скрывать\".", "simplex-unique-2-title": "Вы защищены от
спама и злоупотреблений", - "simplex-unique-overlay-card-4-p-3": "Если Вы рассматриваете возможность разработки для сети SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в Ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", + "simplex-unique-overlay-card-4-p-3": "Если Вы рассматриваете возможность разработки для сети SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в Ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", "comparison-section-list-point-2": "Адреса на основе DNS", "stable-and-beta-versions-built-by-developers": "Стабильные и бета-версии, созданные разработчиками", "simplex-network-3-header": "Сеть SimpleX", @@ -180,11 +180,11 @@ "hero-p-1": "Другие приложения используют ID пользователей: Signal, Matrix, Session, Briar, Jami, Cwtch и т. п.
SimpleX не использует, даже случайных цифр.
Это значительно повышает Вашу конфиденциальность.", "simplex-network-overlay-card-1-li-2": "В отличие от многих P2P сетей, SimpleX спроектирован так, чтобы не нуждаться в глобальных идентификаторах пользователей, даже временных, используя только временные попарные идентификаторы, обеспечивая лучшую анонимность и защиту метаданных пользователей.", "simplex-unique-4-title": "Вы владеете
сетью SimpleX", - "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", + "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", "simplex-unique-overlay-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", "blog": "Блог", "simplex-private-card-7-point-1": "Для обеспечения неизменности, сообщения нумеруются по порядку и содержат хеш предыдущего сообщения.", - "simplex-unique-overlay-card-4-p-2": "Сеть SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем сервисы SimpleX, которые Вы создадите.", + "simplex-unique-overlay-card-4-p-2": "Сеть SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем сервисы SimpleX, которые Вы создадите.", "simplex-explained-tab-1-p-1": "Вы можете создавать контакты и группы, а также вести двусторонние беседы, как и в любом другом мессенджере.", "contact-hero-p-2": "Еще не скачали SimpleX Chat?", "why-simplex-is-unique": "Почему SimpleX уникален", @@ -203,7 +203,7 @@ "simplex-explained-tab-2-text": "2. Как это работает", "docs-dropdown-4": "Свой SMP Сервер", "feature-4-title": "Голосовые сообщения с E2E-шифрованием", - "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши контакты в социальных сетях, чтобы изменять наши мнения и манипулировать нашими голосами.", + "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши контакты в социальных сетях, чтобы изменять наши мнения и манипулировать нашими голосами.", "privacy-matters-overlay-card-2-p-3": "SimpleX — это первая сеть, которая не имеет никаких идентификаторов пользователей, таким образом защищая Ваши контакты лучше, чем любая известная альтернатива.", "learn-more": "Узнать больше", "donate": "Пожертвовать", @@ -234,7 +234,7 @@ "developers": "Разработчики", "simplex-private-7-title": "Проверка неизменности
сообщений", "privacy-matters-overlay-card-1-p-4": "Сеть SimpleX обеспечивает более высокий уровень конфиденциальности ваших связей, чем любое другое решение, полностью предотвращая доступ компаний и организаций к вашему социальному графу. Даже если пользователи используют серверы, предварительно настроенные в приложениях SimpleX Chat, операторы этих серверов не знают ни количества пользователей, ни их связей.", - "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", + "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", "terms-and-privacy-policy": "Политика Конфиденциальности", "about-and-contact-us": "O нас и наши контакты", diff --git a/website/langs/tr.json b/website/langs/tr.json index de607f689a..216e03b43c 100644 --- a/website/langs/tr.json +++ b/website/langs/tr.json @@ -67,7 +67,7 @@ "simplex-private-card-3-point-2": "Sunucu parmak izleri ve kanal bağlama, MITM ve tekrar saldırılarını önler.", "simplex-private-card-3-point-3": "Oturum saldırılarını önlemek için bağlantı devamı devre dışı bırakılmıştır.", "simplex-private-card-4-point-1": "IP adresinizi korumak için Tor veya başka bir taşıma katmanı ağı üzerinden sunuculara erişebilirsiniz.", - "simplex-private-card-4-point-2": "Android'de Tor ile SimpleX kullanmak için Orbot uygulamasını yükleyin ve SOCKS5 proxy'yi etkinleştirin veya iOS'ta VPN kullanın.", + "simplex-private-card-4-point-2": "Android'de Tor ile SimpleX kullanmak için Orbot uygulamasını yükleyin ve SOCKS5 proxy'yi etkinleştirin veya iOS'ta VPN kullanın.", "simplex-private-card-5-point-1": "SimpleX, mesaj boyutu saldırılarını önlemek için her şifreleme katmanında içerik doldurma kullanır.", "simplex-private-card-5-point-2": "Sunucu ve ağ gözlemcileri için aynı görünen farklı boyutlarda mesajlar üretir.", "simplex-private-card-6-point-1": "Birçok iletişim ağı, sunucu veya ağ sağlayıcıları tarafından yapılan MITM saldırılarına karşı savunmasızdır.", @@ -101,7 +101,7 @@ "hero-overlay-card-1-p-3": "Hangi sunucuları mesaj almak için kullanacağınızı siz belirlersiniz. Kişileriniz — bu sunucuları size mesaj göndermek için kullanır. Her konuşma genellikle iki farklı sunucu kullanır.", "hero-overlay-card-1-p-4": "Bu tasarım, uygulama düzeyinde kullanıcı meta verilerinin sızmasını önler. Mesaj sunucularına Tor üzerinden bağlanarak gizliliğinizi ve IP adresinizi koruyabilirsiniz.", "hero-overlay-card-1-p-5": "Kullanıcı profilleri, kişiler ve gruplar yalnızca kullanıcının cihazında saklanır. Mesajlar, iki katmanlı uçtan uca şifreleme ile gönderilir.", - "hero-overlay-card-1-p-6": "Daha fazla bilgi için SimpleX teknik dokümanını okuyun.", + "hero-overlay-card-1-p-6": "Daha fazla bilgi için SimpleX teknik dokümanını okuyun.", "hero-overlay-card-2-p-1": "Kullanıcıların kalıcı kimlikleri olduğunda, bu bir oturum kimliği gibi rastgele bir sayı olsa bile, sağlayıcıların veya saldırganların kullanıcıların birbirleriyle nasıl bağlantılı olduğunu ve kaç mesaj gönderdiklerini tespit etme riski vardır.", "hero-overlay-card-2-p-2": "Bu bilgiler, mevcut halka açık sosyal ağlarla ilişkilendirilebilir ve gerçek kimlikler ortaya çıkarılabilir.", "hero-overlay-card-2-p-3": "Aynı profil üzerinden iki farklı kişiyle sohbet ederseniz, en gizlilik odaklı uygulamalarda bile (ör. Tor v3 hizmetleri kullananlar) bu kişiler, aynı kişiyle bağlantılı olduklarını anlayabilir.", @@ -118,14 +118,14 @@ "simplex-network-overlay-card-1-li-6": "P2P ağları, istemciler trafiği tekrar gönderip güçlendirebildiğinde DRDoS saldırılarına karşı savunmasız olabilir ve bu da tüm ağda hizmet reddine yol açabilir. SimpleX istemcileri yalnızca bilinen bağlantılardan gelen trafiği iletir ve bir saldırgan tarafından ağ genelinde trafiği güçlendirmek için kullanılamaz.", "privacy-matters-overlay-card-1-p-1": "Birçok büyük şirket, kiminle bağlantılı olduğunuz bilgisini gelir seviyenizi tahmin etmek, size gerçekten ihtiyacınız olmayan ürünleri satmak ve fiyatları belirlemek için kullanır.", "privacy-matters-overlay-card-1-p-2": "Çevrimiçi satıcılar, daha düşük gelire sahip kişilerin daha acil alışverişler yaptığını bilir ve bu nedenle daha yüksek fiyatlar talep edebilir veya indirimleri kaldırabilir.", - "privacy-matters-overlay-card-1-p-3": "Bazı finans ve sigorta şirketleri, faiz oranlarını ve primleri belirlemek için sosyal grafikleri kullanır. Düşük gelirliler genellikle daha fazla öder — bu, \"yoksulluk primi\" olarak bilinir.", + "privacy-matters-overlay-card-1-p-3": "Bazı finans ve sigorta şirketleri, faiz oranlarını ve primleri belirlemek için sosyal grafikleri kullanır. Düşük gelirliler genellikle daha fazla öder — bu, \"yoksulluk primi\" olarak bilinir.", "privacy-matters-overlay-card-1-p-4": "SimpleX ağı, bağlantılarınızın gizliliğini diğer tüm alternatiflerden daha iyi korur ve sosyal grafiğinizin şirketler veya kuruluşlar tarafından erişilmesini tamamen engeller. Kullanıcılar SimpleX Chat uygulamasının önceden yapılandırılmış sunucularını kullansa bile, sunucu sahipleri kullanıcı sayısını veya bağlantılarını bilmez.", - "privacy-matters-overlay-card-2-p-1": "Çok da uzun olmayan bir süre önce, büyük seçimlerin saygın bir danışmanlık şirketi tarafından sosyal grafiklerimiz kullanılarak manipüle edildiğine tanık olduk; bu, gerçek dünyaya bakışımızı çarpıttı ve oylarımızı etkiledi.", + "privacy-matters-overlay-card-2-p-1": "Çok da uzun olmayan bir süre önce, büyük seçimlerin saygın bir danışmanlık şirketi tarafından sosyal grafiklerimiz kullanılarak manipüle edildiğine tanık olduk; bu, gerçek dünyaya bakışımızı çarpıttı ve oylarımızı etkiledi.", "privacy-matters-overlay-card-2-p-2": "Tarafsız olmak ve bağımsız kararlar verebilmek için bilgi alanınız üzerinde kontrol sahibi olmalısınız. Bu, yalnızca sosyal grafiğinize erişimi olmayan özel bir iletişim ağı kullanıyorsanız mümkündür.", "privacy-matters-overlay-card-2-p-3": "SimpleX, tasarımı gereği hiçbir kullanıcı kimliği olmayan ilk ağdır ve bağlantı grafiğinizi diğer tüm bilinen alternatiflerden daha iyi korur.", "privacy-matters-overlay-card-3-p-1": "Herkes gizliliğine ve iletişiminin güvenliğine önem vermelidir — zararsız sohbetler bile, saklayacak bir şeyiniz olmasa da sizi tehlikeye atabilir.", - "privacy-matters-overlay-card-3-p-2": "En çarpıcı hikayelerden biri, Mohamedou Ould Slahi'nin yaşadıklarıdır; anılarını yazdığı kitapta ve \"The Mauritanian\" filminde anlatılır. Afganistan'daki akrabalarını aradıktan sonra, Almanya'da 10 yıl yaşamasına rağmen, Guantanamo'ya gönderildi ve yıllarca yargılanmadan işkence gördü çünkü 11 Eylül saldırılarına karıştığından şüpheleniliyordu.", - "privacy-matters-overlay-card-3-p-3": "Normal insanlar bile, çevrimiçi olarak paylaştıkları şeyler nedeniyle, hatta \"anonim\" hesaplarını kullansalar bile, demokratik ülkelerde bile tutuklanabiliyor.", + "privacy-matters-overlay-card-3-p-2": "En çarpıcı hikayelerden biri, Mohamedou Ould Slahi'nin yaşadıklarıdır; anılarını yazdığı kitapta ve \"The Mauritanian\" filminde anlatılır. Afganistan'daki akrabalarını aradıktan sonra, Almanya'da 10 yıl yaşamasına rağmen, Guantanamo'ya gönderildi ve yıllarca yargılanmadan işkence gördü çünkü 11 Eylül saldırılarına karıştığından şüpheleniliyordu.", + "privacy-matters-overlay-card-3-p-3": "Normal insanlar bile, çevrimiçi olarak paylaştıkları şeyler nedeniyle, hatta \"anonim\" hesaplarını kullansalar bile, demokratik ülkelerde bile tutuklanabiliyor.", "privacy-matters-overlay-card-3-p-4": "Sadece uçtan uca şifreli bir mesajlaşma uygulaması kullanmak yeterli değildir. Hepimiz, bağlı olduğumuz kişisel ağlarımızın gizliliğini koruyan bir mesajlaşma uygulaması kullanmalıyız.", "simplex-unique-overlay-card-1-p-1": "Diğer mesajlaşma ağlarının aksine, SimpleX kullanıcılara hiçbir kimlik atamaz. Kullanıcıları tanımlamak için telefon numaralarına, alan adı tabanlı adreslere (e-posta veya XMPP gibi), kullanıcı adlarına, genel anahtarlara veya rastgele sayılara dayanmaz — SimpleX sunucu sahipleri bile sunucularını kaç kişinin kullandığını bilmez.", "simplex-unique-overlay-card-1-p-2": "SimpleX, mesajları iletmek için ikili anonim adresler kullanır; bunlar tek yönlü mesaj kuyruklarından oluşur ve alınan ve gönderilen mesajlar için ayrıdır, genellikle farklı sunucular üzerinden iletilir.", @@ -137,8 +137,8 @@ "simplex-unique-overlay-card-3-p-3": "Federasyonlu ağ sunucularının (ör. e-posta, XMPP veya Matrix) aksine, SimpleX sunucuları kullanıcı hesabı saklamaz, yalnızca mesajları iletir ve böylece tarafların gizliliği korunur.", "simplex-unique-overlay-card-3-p-4": "Gönderilen ve alınan sunucu trafiği arasında ortak bir kimlik veya şifreli metin yoktur — bu nedenle, bir gözlemci TLS ele geçirilse bile kimin kiminle iletişim kurduğunu kolayca tespit edemez.", "simplex-unique-overlay-card-4-p-1": "Kendi sunucularınızla SimpleX kullanabilirsiniz ve yine de uygulamanın önceden yapılandırılmış sunucularını kullanan kişilerle iletişim kurabilirsiniz.", - "simplex-unique-overlay-card-4-p-2": "SimpleX ağı açık bir protokol kullanır ve sohbet botları geliştirmek için bir SDK sunar. Bu sayede kullanıcıların SimpleX Chat uygulamalarıyla etkileşime geçebileceği servisler oluşturabilirsiniz — hangi SimpleX servislerini geliştireceğinizi merakla bekliyoruz.", - "simplex-unique-overlay-card-4-p-3": "SimpleX ağının geliştirilmesine aktif olarak katkıda bulunmak, örneğin SimpleX uygulama kullanıcıları için bir sohbet botu geliştirmek veya SimpleX Chat kütüphanesinin mobil uygulamalara entegrasyonunu ilerletmek isterseniz, bizimle iletişime geçin ve destek alın.", + "simplex-unique-overlay-card-4-p-2": "SimpleX ağı açık bir protokol kullanır ve sohbet botları geliştirmek için bir SDK sunar. Bu sayede kullanıcıların SimpleX Chat uygulamalarıyla etkileşime geçebileceği servisler oluşturabilirsiniz — hangi SimpleX servislerini geliştireceğinizi merakla bekliyoruz.", + "simplex-unique-overlay-card-4-p-3": "SimpleX ağının geliştirilmesine aktif olarak katkıda bulunmak, örneğin SimpleX uygulama kullanıcıları için bir sohbet botu geliştirmek veya SimpleX Chat kütüphanesinin mobil uygulamalara entegrasyonunu ilerletmek isterseniz, bizimle iletişime geçin ve destek alın.", "simplex-unique-card-1-p-1": "SimpleX, profilinizin, kişilerin ve meta verilerinizin gizliliğini korur ve bunları SimpleX ağ sunucularından ve diğer olası gözlemcilerden gizler.", "simplex-unique-card-1-p-2": "Mevcut tüm diğer mesajlaşma ağlarının aksine, SimpleX kullanıcılarına hiçbir kimlik atamaz — rastgele sayılar bile yoktur.", "simplex-unique-card-2-p-1": "SimpleX ağında kimliğiniz veya sabit bir adresiniz olmadığı için, kimse size ulaşamaz; yalnızca tek kullanımlık veya geçici bir kullanıcı adresini QR kodu veya bağlantı olarak paylaşırsanız iletişim kurabilir.", diff --git a/website/langs/uk.json b/website/langs/uk.json index 1848b5ce96..d681517200 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -13,13 +13,13 @@ "simplex-network-overlay-card-1-li-5": "Всі відомі P2P-мережі можуть бути вразливими до атаки Sybil, оскільки кожен вузол є відкритим, і мережа працює як ціле. Відомі заходи для пом'якшення цієї атаки вимагають або централізованого компонента, або дорогого доказу роботи. Мережа SimpleX не має виявності сервера, вона роздроблена і працює як кілька ізольованих підмереж, що робить неможливими атаки на всю мережу.", "simplex-network-overlay-card-1-li-6": "P2P-мережі можуть бути вразливими до атаки DRDoS, коли клієнти можуть ребродкастити та збільшувати трафік, що призводить до відмови в обслуговуванні на рівні всієї мережі. Клієнти SimpleX лише пересилають трафік від відомого підключення і не можуть бути використані зловмисником для збільшення трафіку в усій мережі.", "privacy-matters-overlay-card-1-p-2": "Інтернет-роздрібники знають, що люди з низьким доходом частіше роблять термінові покупки, тому вони можуть встановлювати вищі ціни або скасовувати знижки.", - "privacy-matters-overlay-card-1-p-3": "Деякі фінансові та страхові компанії використовують соціальні графи для визначення ставок та страхових премій. Це часто змушує людей з низькими доходами платити більше — це відомо як 'поширений преміум'.", + "privacy-matters-overlay-card-1-p-3": "Деякі фінансові та страхові компанії використовують соціальні графи для визначення ставок та страхових премій. Це часто змушує людей з низькими доходами платити більше — це відомо як 'поширений преміум'.", "privacy-matters-overlay-card-1-p-4": "Мережа SimpleX краще захищає конфіденційність ваших з'єднань, ніж будь-яка інша альтернатива, повністю запобігаючи тому, щоб ваш соціальний граф став доступним для будь-яких компаній чи організацій. Навіть коли люди використовують сервери, попередньо налаштовані в додатках SimpleX Chat, оператори серверів не знають кількості користувачів або їхніх з'єднань.", - "privacy-matters-overlay-card-2-p-1": "Не так давно ми спостерігали, як великі вибори маніпулювалися поважною консалтинговою компанією, яка використовувала наші соціальні графи для спотворення нашого уявлення про реальний світ та маніпулювання нашими голосами.", + "privacy-matters-overlay-card-2-p-1": "Не так давно ми спостерігали, як великі вибори маніпулювалися поважною консалтинговою компанією, яка використовувала наші соціальні графи для спотворення нашого уявлення про реальний світ та маніпулювання нашими голосами.", "privacy-matters-overlay-card-2-p-2": "Щоб бути об'єктивним та приймати незалежні рішення, вам потрібно контролювати ваш інформаційний простір. Це можливо лише за умови використання приватної мережі для спілкування, яка не має доступу до вашого соціального графа.", "privacy-matters-overlay-card-2-p-3": "SimpleX - це перша мережа, яка не має жодних ідентифікаторів користувачів за своїм дизайном, таким чином, захищаючи ваш графік з'єднань краще, ніж будь-яка відома альтернатива.", "privacy-matters-overlay-card-3-p-1": "Кожен повинен турбуватися про конфіденційність та безпеку свого спілкування — безпечні розмови можуть поставити вас під загрозу, навіть якщо у вас немає чого приховувати.", - "privacy-matters-overlay-card-3-p-2": "Однією з найшокуючих історій є досвід Мохамеду Ульд Слахі, описаний в його мемуарах і показаний у фільмі \"Мавританець\". Його посадили в табір Гуантанамо без суду і мукили там 15 років після телефонного дзвінка йому родичеві в Афганістані, під підозрою в причетності до атак 11 вересня, навіть не дивлячись на те, що він проживав у Німеччині протягом попередніх 10 років.", + "privacy-matters-overlay-card-3-p-2": "Однією з найшокуючих історій є досвід Мохамеду Ульд Слахі, описаний в його мемуарах і показаний у фільмі \"Мавританець\". Його посадили в табір Гуантанамо без суду і мукили там 15 років після телефонного дзвінка йому родичеві в Афганістані, під підозрою в причетності до атак 11 вересня, навіть не дивлячись на те, що він проживав у Німеччині протягом попередніх 10 років.", "privacy-matters-overlay-card-3-p-4": "Недостатньо використовувати зашифрований від кінця до кінця месенджер; ми всі повинні використовувати месенджери, які захищають конфіденційність наших особистих мереж — з ким ми з'єднані.", "simplex-unique-overlay-card-1-p-1": "На відміну від інших мереж для обміну повідомленнями, у SimpleX немає ідентифікаторів, призначених користувачам. Він не покладається на номери телефонів, адреси на основі доменів (як електронна пошта або XMPP), імена користувачів, публічні ключі або навіть випадкові числа для ідентифікації своїх користувачів — оператори серверів SimpleX не знають, скільки людей використовують їхні сервери.", "simplex-unique-overlay-card-3-p-3": "На відміну від федеративних мереж серверів (електронна пошта, XMPP або Matrix), сервери SimpleX не зберігають облікові записи користувачів, вони лише ретранслюють повідомлення, захищаючи приватність обох сторін.", @@ -118,7 +118,7 @@ "simplex-private-card-3-point-1": "Використовується лише TLS 1.2/1.3 із сильними алгоритмами для з'єднань клієнт-сервер.", "simplex-private-card-3-point-2": "Відбиток сервера та зв'язування каналу запобігають MITM та атакам повтору.", "simplex-private-card-4-point-1": "Для захисту вашої IP-адреси ви можете отримати доступ до серверів через Tor чи іншу транспортну оверлейну мережу.", - "simplex-private-card-4-point-2": "Для використання SimpleX через Tor, будь ласка, встановіть додаток Orbot та активуйте SOCKS5-проксі (або VPN на iOS).", + "simplex-private-card-4-point-2": "Для використання SimpleX через Tor, будь ласка, встановіть додаток Orbot та активуйте SOCKS5-проксі (або VPN на iOS).", "simplex-private-card-5-point-1": "SimpleX використовує наповнення вмісту для кожного шару шифрування для ускладнення атак за розміром повідомлень.", "simplex-private-card-5-point-2": "Це забезпечує, що повідомлення різних розмірів виглядають однаково для серверів та спостерігачів мережі.", "simplex-private-card-6-point-1": "Багато комунікаційних мереж вразливі до MITM-атак серверів чи постачальників мережі.", @@ -148,7 +148,7 @@ "hero-overlay-card-1-p-2": "Для доставки повідомлень, замість ідентифікаторів користувачів, які використовують усі інші мережі, SimpleX використовує тимчасові анонімні парні ідентифікатори черг повідомлень, окремі для кожного вашого підключення — тут немає довгострокових ідентифікаторів.", "hero-overlay-card-1-p-3": "Ви визначаєте, який(кі) сервер(и) використовувати для отримання повідомлень, ваші контакти — сервери, які ви використовуєте для відправки повідомлень їм. Кожна розмова, ймовірно, використовує два різних сервери.", "hero-overlay-card-1-p-4": "Цей дизайн запобігає витоку метаданих будь-яких користувачів на рівні додатка. Для подальшого покращення конфіденційності та захисту вашої IP-адреси ви можете підключитися до серверів обміну повідомленнями через Tor.", - "hero-overlay-card-1-p-6": "Докладніше читайте у білетному запису SimpleX.", + "hero-overlay-card-1-p-6": "Докладніше читайте у білетному запису SimpleX.", "hero-overlay-card-2-p-2": "Вони можуть потім корелювати цю інформацію з існуючими громадськими соціальними мережами та визначати деякі реальні ідентифікатори.", "hero-overlay-card-2-p-3": "Навіть з найбільш приватними додатками, які використовують служби Tor v3, якщо ви розмовляєте з двома різними контактами через той самий профіль, вони можуть довести, що вони підключені до однієї й тієї ж особи.", "hero-overlay-card-2-p-4": "SimpleX захищає від цих атак, не маючи жодних ідентифікаторів користувачів в своєму дизайні. І, якщо ви використовуєте режим інкогніто, у вас буде різне відображення для кожного контакту, уникнення будь-яких спільних даних між ними.", @@ -156,7 +156,7 @@ "simplex-network-overlay-card-1-li-1": "P2P-мережі покладаються на якусь варіацію розподіленої хеш-таблиці (DHT) для маршрутизації повідомлень. Дизайни DHT повинні балансувати гарантію доставки та затримку. У SimpleX є як краща гарантія доставки, так і менша затримка порівняно із P2P, оскільки повідомлення може передаватися паралельно кількома серверами, використовуючи сервери, обрані отримувачем. У P2P-мережах повідомлення проходить через вузли O(log N) послідовно, використовуючи вузли, обрані алгоритмом.", "simplex-network-overlay-card-1-li-2": "У дизайні SimpleX, на відміну від більшості P2P-мереж, немає жодних глобальних ідентифікаторів користувачів будь-якого виду, навіть тимчасових, та використовуються лише тимчасові парні ідентифікатори, що забезпечує кращу анонімність та захист метаданих.", "privacy-matters-overlay-card-1-p-1": "Багато великих компаній використовують інформацію про те, з ким ви з'єднані, щоб оцінити ваш дохід, продавати вам продукти, які вам дійсно не потрібні, і визначати ціни.", - "privacy-matters-overlay-card-3-p-3": "Звичайних людей арештовують за те, що вони публікують онлайн, навіть через свої 'анонімні' облікові записи, навіть у демократичних країнах.", + "privacy-matters-overlay-card-3-p-3": "Звичайних людей арештовують за те, що вони публікують онлайн, навіть через свої 'анонімні' облікові записи, навіть у демократичних країнах.", "simplex-unique-overlay-card-1-p-2": "Для доставки повідомлень SimpleX використовує парні анонімні адреси однобічних черг повідомлень, окремо для отриманих та відправлених повідомлень, зазвичай через різні сервери.", "simplex-unique-overlay-card-1-p-3": "Цей дизайн захищає конфіденційність того, з ким ви спілкуєтесь, приховуючи це від серверів мережі SimpleX та від будь-яких спостерігачів. Щоб приховати вашу IP-адресу від серверів, ви можете підключатися до серверів SimpleX через Tor.", "simplex-unique-overlay-card-2-p-1": "Оскільки у вас немає ідентифікатора в мережі SimpleX, ніхто не може зв'язатися з вами, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, як QR-кодом або посиланням.", @@ -165,8 +165,8 @@ "simplex-unique-overlay-card-3-p-2": "Зашифровані повідомлення від кінця до кінця тимчасово зберігаються на ретрансляційних серверах SimpleX до їх отримання, після чого вони назавжди видаляються.", "simplex-unique-overlay-card-3-p-4": "Між надісланим і отриманим серверним трафіком немає спільних ідентифікаторів чи шифрованого тексту — якщо хтось його спостерігає, він не може легко визначити, хто спілкується з ким, навіть якщо TLS скомпрометовано.", "simplex-unique-overlay-card-4-p-1": "Ви можете використовувати SimpleX зі своїми власними серверами і при цьому спілкуватися з людьми, які використовують сервери, попередньо налаштовані в додатках.", - "simplex-unique-overlay-card-4-p-2": "Мережа SimpleX використовує відкритий протокол і надає SDK для створення чат-ботів, що дозволяє реалізувати сервіси, з якими користувачі можуть взаємодіяти через додатки SimpleX Chat — ми справді чекаємо, щоб побачити, які сервіси SimpleX ви створите.", - "simplex-unique-overlay-card-4-p-3": "Якщо ви плануєте розробляти для мережі SimpleX, наприклад, чат-бота для користувачів додатка SimpleX або інтеграцію бібліотеки SimpleX Chat у ваші мобільні додатки, будь ласка, зв'яжіться з нами для отримання порад та підтримки.", + "simplex-unique-overlay-card-4-p-2": "Мережа SimpleX використовує відкритий протокол і надає SDK для створення чат-ботів, що дозволяє реалізувати сервіси, з якими користувачі можуть взаємодіяти через додатки SimpleX Chat — ми справді чекаємо, щоб побачити, які сервіси SimpleX ви створите.", + "simplex-unique-overlay-card-4-p-3": "Якщо ви плануєте розробляти для мережі SimpleX, наприклад, чат-бота для користувачів додатка SimpleX або інтеграцію бібліотеки SimpleX Chat у ваші мобільні додатки, будь ласка, зв'яжіться з нами для отримання порад та підтримки.", "simplex-unique-card-1-p-1": "SimpleX захищає конфіденційність вашого профілю, контактів та метаданих, приховуючи їх від серверів мережі SimpleX та будь-яких спостерігачів.", "simplex-unique-card-1-p-2": "На відміну від будь-якої іншої існуючої мережі для обміну повідомленнями, у SimpleX немає ідентифікаторів, призначених користувачам — не навіть випадкових чисел.", "simplex-unique-card-2-p-1": "Оскільки у вас немає ідентифікатора або фіксованої адреси в мережі SimpleX, ніхто не може зв'язатися з вами, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, як QR-кодом або посиланням.", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 2ead3da487..45cee06f5a 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -86,7 +86,7 @@ "simplex-private-card-3-point-1": "客户端与服务器之间的连接只使用强加密的 TLS 1.2/1.3。", "privacy-matters-3-overlay-1-title": "隐私保您自由", "privacy-matters-3-overlay-1-linkText": "隐私保您自由", - "simplex-private-card-4-point-2": "要通过 Tor 使用 SimpleX,请安装 Orbot 应用并启用 SOCKS5 代理(在 iOS上需要使用 VPN 模式)。", + "simplex-private-card-4-point-2": "要通过 Tor 使用 SimpleX,请安装 Orbot 应用并启用 SOCKS5 代理(在 iOS上需要使用 VPN 模式)。", "simplex-unique-1-overlay-1-title": "您的身份、个人资料、联系人和元数据的完整隐私", "simplex-unique-2-title": "您可以免受垃圾消息和平台滥用的侵害", "simplex-unique-1-title": "您有完整的隐私", @@ -110,7 +110,7 @@ "hero-overlay-card-1-p-4": "此设计可防止在应用程序级别泄漏任何用户元数据。 为了进一步改善隐私并保护您的 IP 地址,您可以通过 Tor 连接到消息服务器。", "hero-overlay-card-1-p-5": "只有客户端设备存储用户配置文件、联系人和群组; 消息均使用两层端到端加密发送。", "simplex-network-overlay-card-1-p-1": "P2P 消息传递协议和应用程序存在各种问题,使得它们不如 SimpleX 可靠,分析起来更复杂,并且 容易受到多种类型的攻击。", - "hero-overlay-card-1-p-6": "阅读 SimpleX 白皮书了解更多信息。", + "hero-overlay-card-1-p-6": "阅读 SimpleX 白皮书了解更多信息。", "hero-overlay-card-2-p-2": "然后他们可以将这些信息与现有的公共社交网络相关联,并确定一些真实身份。", "hero-overlay-card-2-p-1": "当用户具有持久身份时,即使这只是一个随机数(如会话 ID),提供商或攻击者也有可能观察到用户如何连接以及他们发送了多少消息。", "hero-overlay-card-2-p-3": "即使是使用 Tor v3 服务的最私密的应用程序,如果您通过同一个人资料与两个不同的联系人交谈,他们也可以证明他们与同一个人有联系。", @@ -121,14 +121,14 @@ "simplex-network-overlay-card-1-li-5": "所有已知的 P2P 网络都可能受到Sybil 攻击,因为每个节点都是可发现的,并且网络作为一个整体运行。 已知的缓解措施不是需要一个中心化的组件就是需要昂贵的工作量证明。而 SimpleX 网络没有服务器可发现性,它是碎片化的并且作为多个隔离的子网运行,这样全网络范围的攻击便无从实现。", "simplex-network-overlay-card-1-li-6": "P2P 网络可能受到 分布式反射拒绝服务攻击 。客户端有能力重新广播和放大流量,从而导致整个网络范围内的服务中断。 SimpleX 客户端仅中继来自已知连接的流量,因此不能被攻击者用来放大整个网络的流量。", "privacy-matters-overlay-card-1-p-2": "在线零售商知道收入较低的人更有可能在紧急情况下购买商品,因此他们可能会收取更高的价格或取消折扣。", - "privacy-matters-overlay-card-1-p-3": "一些金融和保险公司使用社交图谱来确定利率和保费。 它通常会让收入较低的人支付更多—它被称为“贫困溢价”。", + "privacy-matters-overlay-card-1-p-3": "一些金融和保险公司使用社交图谱来确定利率和保费。 它通常会让收入较低的人支付更多—它被称为“贫困溢价”。", "privacy-matters-overlay-card-1-p-4": "SimpleX 网络比任何替代方案都能更好地保护您人际关系层面的隐私,彻底防止您的社交图谱被任何公司或组织使用。 即使人们使用 SimpleX Chat 应用预配置的服务器,服务器运营方也不知道用户数量或他们的连接数。", - "privacy-matters-overlay-card-2-p-1": "不久前,我们观察到几次大选被一家知名咨询公司操纵,该公司使用我们的社交图谱扭曲我们对现实世界的看法并操纵我们的选票。", + "privacy-matters-overlay-card-2-p-1": "不久前,我们观察到几次大选被一家知名咨询公司操纵,该公司使用我们的社交图谱扭曲我们对现实世界的看法并操纵我们的选票。", "privacy-matters-overlay-card-2-p-2": "为了客观并做出独立的决定,您需要控制您的信息空间。 而这只有当您使用没有能力访问您的社交图谱的,注重隐私的通信网络时,这才有可能。", "privacy-matters-overlay-card-2-p-3": "SimpleX 是第一个没有设计任何用户标识符的网络,这样能比任何已知的替代方案都更好地保护您的连接图谱。", - "privacy-matters-overlay-card-3-p-2": "最令人震惊的故事之一是 Mohamedou Ould Salahi 在他的回忆录中描述并在毛里塔尼亚电影中展示的经历。 他在未经审判的情况下被关进关塔那摩集中营,并在打电话给他在阿富汗的亲戚后在那里遭受了 15 年的折磨,他被怀疑参与了 9/11 袭击,尽管他在过去 10 年住在德国。", + "privacy-matters-overlay-card-3-p-2": "最令人震惊的故事之一是 Mohamedou Ould Salahi 在他的回忆录中描述并在毛里塔尼亚电影中展示的经历。 他在未经审判的情况下被关进关塔那摩集中营,并在打电话给他在阿富汗的亲戚后在那里遭受了 15 年的折磨,他被怀疑参与了 9/11 袭击,尽管他在过去 10 年住在德国。", "privacy-matters-overlay-card-3-p-1": "每个人都应该关心他们通信的隐私和安全——无害的谈话会让您处于危险之中,即使您没有什么可隐瞒的。", - "privacy-matters-overlay-card-3-p-3": "普通人会因为他们在网上分享的内容而被捕,即使是通过他们的“匿名”账户,即使是在民主国家。", + "privacy-matters-overlay-card-3-p-3": "普通人会因为他们在网上分享的内容而被捕,即使是通过他们的“匿名”账户,即使是在民主国家。", "privacy-matters-overlay-card-3-p-4": "使用端到端加密的即时通讯软件还不够,我们都应该使用保护我们个人网络隐私——即我们与谁有联系的即时通讯软件。", "simplex-unique-overlay-card-1-p-1": "与其他消息网络不同,SimpleX 没有分配给用户的标识符。 它不依赖电话号码、基于域的地址(如电子邮件或 XMPP)、用户名、公钥甚至随机数来识别其用户—— SimpleX 服务器运营方不知道有多少人使用其服务器。", "simplex-unique-overlay-card-1-p-2": "为了传递消息,SimpleX 使用单向消息队列的成对匿名地址,通常通过不同的服务器将接收和发送的消息分开。", @@ -136,7 +136,7 @@ "simplex-unique-overlay-card-4-p-1": "您可以将 SimpleX 与您自己的服务器一起使用,并且仍然可以与使用应用中预配置服务器的人们进行通信。", "simplex-unique-card-3-p-2": "端到端加密的消息在被收到前会暂时保存在 SimpleX 中继服务器上,传送完成后它们会被永久删除。", "simplex-unique-card-1-p-1": "SimpleX 保护您的个人资料、联系人和元数据的隐私,不让 SimpleX 网络服务器和任何观察者看到它们。", - "simplex-unique-overlay-card-4-p-3": "如果您正在考虑为在SimpleX 网络上开发,例如,为 SimpleX 应用程序用户开发聊天机器人,或将 SimpleX 聊天库集成到您的移动应用,请 联系我们获取建议和支持。", + "simplex-unique-overlay-card-4-p-3": "如果您正在考虑为在SimpleX 网络上开发,例如,为 SimpleX 应用程序用户开发聊天机器人,或将 SimpleX 聊天库集成到您的移动应用,请 联系我们获取建议和支持。", "simplex-unique-card-4-p-2": "您可以搭配自己的服务器来使用 SimpleX 或使用我们提供的服务器 — 并仍然连接到任何用户。", "simplex-unique-card-2-p-1": "因为您在 SimpleX 网络上没有标识符或固定地址,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", "simplex-unique-card-3-p-1": "SimpleX 以便携式加密数据库格式将所有用户数据存储在客户端设备上—— 它可以转移到另一个设备。", @@ -202,7 +202,7 @@ "see-here": "参见此处", "comparison-section-list-point-7": "P2P 网络要么拥有中央权威,要么整个网络可能被攻陷", "simplex-private-card-5-point-1": "SimpleX 为每个加密层进行内容填充来对抗长度扩展攻击。", - "simplex-unique-overlay-card-4-p-2": "SimpleX 网络使用开放协议并提供用于创建聊天机器人的 SDK, 允许用户实现通过 SimpleX Chat 应用程序与之交互的服务—我们真的很期待看到您会依托SimpleX构建哪些服务。", + "simplex-unique-overlay-card-4-p-2": "SimpleX 网络使用开放协议并提供用于创建聊天机器人的 SDK, 允许用户实现通过 SimpleX Chat 应用程序与之交互的服务—我们真的很期待看到您会依托SimpleX构建哪些服务。", "simplex-unique-overlay-card-2-p-2": "即使使用可选的用户地址,当它被用于发送垃圾邮件联系请求,您可以更改或完全删除它而不会丢失任何连接。", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat 使用便携式加密数据库格式仅将所有用户数据存储在客户端设备上,该格式可以导出并传输到任何支持的设备。", "donate-here-to-help-us": "在这里捐款来帮助我们", diff --git a/website/langs/zh_Hant.json b/website/langs/zh_Hant.json index ced46c6b69..bf7403e053 100644 --- a/website/langs/zh_Hant.json +++ b/website/langs/zh_Hant.json @@ -69,12 +69,12 @@ "see-here": "見此", "privacy-matters-overlay-card-3-p-4": "僅使用端對端加密的通訊工具是不夠的,我們都應使用能保護個人網路隱私的通訊工具。", "hero-overlay-card-1-p-1": "很多用戶問: 如果 SimpleX 沒有用戶標識符,它何以知道向何處傳送訊息?", - "simplex-unique-overlay-card-4-p-2": "SimpleX網路使用開源協議並提供開發包以編寫聊天機器人,允許使用者透過SimpleX Chat應用程式與服務互動—我們非常期待看到您的SimpleX服務之作!", + "simplex-unique-overlay-card-4-p-2": "SimpleX網路使用開源協議並提供開發包以編寫聊天機器人,允許使用者透過SimpleX Chat應用程式與服務互動—我們非常期待看到您的SimpleX服務之作!", "hero-overlay-card-1-p-2": "為傳送訊息,SimpleX 使用消息佇列中的臨時匿名標識符對,每連線各異,而不像其他網路一樣使用用戶ID— SimpleX 沒有長期標識符。", - "privacy-matters-overlay-card-3-p-2": "最令人震驚的故事之一是 Mohamedou Ould Salahi 的經歷,描述於他的回憶錄中,並在《The Mauritanian movie》中呈現。他在未經審訊的情況下被送進關塔納摩集中營,並在那裏遭受了長達 15 年的酷刑,原因是他被懷疑參與了 9/11 襲擊事件,而此前 10 年他一直住在德國。", + "privacy-matters-overlay-card-3-p-2": "最令人震驚的故事之一是 Mohamedou Ould Salahi 的經歷,描述於他的回憶錄中,並在《The Mauritanian movie》中呈現。他在未經審訊的情況下被送進關塔納摩集中營,並在那裏遭受了長達 15 年的酷刑,原因是他被懷疑參與了 9/11 襲擊事件,而此前 10 年他一直住在德國。", "simplex-private-2-title": "伺服器附加加密層", "hero-overlay-card-2-p-1": "如使用者擁有持久性身份,即使只是一個隨機數,例如 Session ID,也會有服務提供者或攻擊者可以觀察使用者如何連線以及傳送了多少條訊息的風險。", - "simplex-unique-overlay-card-4-p-3": "如您正考慮針對 SimpleX 網路進行開發,例如針對 SimpleX 使用者的聊天機器人,或將 SimpleX 聊天函式庫整合至您的手機應用程式,請與我們聯絡以獲取支持與建議。", + "simplex-unique-overlay-card-4-p-3": "如您正考慮針對 SimpleX 網路進行開發,例如針對 SimpleX 使用者的聊天機器人,或將 SimpleX 聊天函式庫整合至您的手機應用程式,請與我們聯絡以獲取支持與建議。", "simplex-network-overlay-card-1-li-1": "點對點網路依賴 DHT 的某些變體來路由訊息。DHT 設計必須平衡傳送保證和延遲。與點對點相比,SimpleX 具有更好的傳送保證和更低的延遲,因為訊息可以使用收件者選擇的伺服器,經由多個伺服器並行冗餘地傳送。在點對點網路中,訊息依次經由O(log N)個由演算法選擇的節點。", "simplex-private-card-3-point-3": "連線恢復被停用,以防止會話攻擊。", "simplex-private-card-2-point-1": "傳送至收件者的伺服器加密附加層,以防止 TLS 遭到攻擊時,接收和傳送的伺服器流量之間的關聯。", @@ -127,7 +127,7 @@ "simplex-unique-3-overlay-1-title": "您的數據的擁有權、控制與安全", "hero-overlay-card-2-p-4": "SimpleX 通過在設計中不使用任何使用者 ID以防止這些攻擊。而且,如果您使用匿名聊天模式,每個聯絡人都會有不同的顯示名稱,避免他們之間有任何共用資料。", "hero-overlay-card-2-p-3": "即使是使用 Tor v3 服務的最隱私的應用程式,如果您透過相同的設定檔與兩個不同的人交談,他們也可以證明他們連線的是同一個人。", - "hero-overlay-card-1-p-6": "請參閱 SimpleX 白皮書以閱讀更多資訊。", + "hero-overlay-card-1-p-6": "請參閱 SimpleX 白皮書以閱讀更多資訊。", "hero-overlay-card-3-p-1": "Trail of Bits 是一家領先的安全和技術顧問公司,客戶包括大型科技公司、政府機構和主要的區塊鏈專案。", "simplex-network-overlay-card-1-p-1": "點對點通訊協定和應用程式有多種問題,使得它們不如 SimpleX 可靠、分析起來更複雜,而且易受幾種類型的攻擊。", "simplex-network-overlay-card-1-li-2": "SimpleX 的設計與大多數點對點網路不同,沒有任何類型的全局使用者標識符,即使是臨時標識符。SimpleX 只使用臨時的標識符對,提供更好的匿名性和元資料保護。", @@ -145,8 +145,8 @@ "simplex-unique-overlay-card-3-p-4": "傳送與接收的伺服器流量之間沒有共同的標識符或密文—如果有人觀察,即使 TLS 遭到破壞,也無法輕易確定誰與誰通訊。", "join-the-REDDIT-community": "加入Reddit社群", "privacy-matters-overlay-card-3-p-1": "人人都應關心自己通訊的隱私和安全;無害的對話可能會讓您陷入危險,即使您沒有什麼要隱瞞的。", - "privacy-matters-overlay-card-3-p-3": "普通人會因為他們在網路上分享的內容而被捕,即使是透過他們的「匿名」帳號,即使是在民主國家。", - "privacy-matters-overlay-card-2-p-1": "不久前我們觀察到大選被一家聲譽良好的顧問公司操縱,該公司利用我們的社交圖來扭曲我們對真實世界的看法,並操縱我們的選票。", + "privacy-matters-overlay-card-3-p-3": "普通人會因為他們在網路上分享的內容而被捕,即使是透過他們的「匿名」帳號,即使是在民主國家。", + "privacy-matters-overlay-card-2-p-1": "不久前我們觀察到大選被一家聲譽良好的顧問公司操縱,該公司利用我們的社交圖來扭曲我們對真實世界的看法,並操縱我們的選票。", "comparison-section-list-point-2": "基於DNS的位址", "comparison-section-list-point-1": "通常基於電話號碼,有時用戶名", "simplex-private-card-6-point-1": "許多通訊網路容易受到伺服器或網路供應商的中間人攻擊。", diff --git a/website/src/_includes/blog_previews/20221108.html b/website/src/_includes/blog_previews/20221108.html index f07180a17b..64174a0bcb 100644 --- a/website/src/_includes/blog_previews/20221108.html +++ b/website/src/_includes/blog_previews/20221108.html @@ -2,7 +2,7 @@

SimpleX Chat security has been assessed by - Trail of Bits, + Trail of Bits, 4 issues were identified, and 3 of them are fixed in v4.2.

diff --git a/website/src/_includes/blog_previews/20221206.html b/website/src/_includes/blog_previews/20221206.html index 55530cd301..df7e113250 100644 --- a/website/src/_includes/blog_previews/20221206.html +++ b/website/src/_includes/blog_previews/20221206.html @@ -1,9 +1,9 @@

SimpleX Chat November reviews:

-

Privacy Guides recommendations.

-

Review by Mike Kuketz.

-

The messenger matrix.

-

Supernova review and messenger ratings.

+

Privacy Guides recommendations.

+

Review by Mike Kuketz.

+

The messenger matrix.

+

Supernova review and messenger ratings.

v4.3 is released:

diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index 0616665a70..5fbd13c1f2 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -17,11 +17,11 @@

{{ "contact-hero-p-3" | i18n({}, lang ) | safe }}

- App Store - Google Play + App Store + Google Play - TestFlight - Android APK + TestFlight + Android APK
@@ -54,8 +54,8 @@

{{ "install-simplex-app" | i18n({}, lang ) | safe }}

- - + +
diff --git a/website/src/_includes/footer.html b/website/src/_includes/footer.html index 31c9d9e557..521e717eb5 100644 --- a/website/src/_includes/footer.html +++ b/website/src/_includes/footer.html @@ -8,9 +8,11 @@ `; @@ -674,7 +674,7 @@ if (nextMeta.description) html += `

${nextMeta.description}

`; html += '

'; html += `${appState.totalMinted} NFTs minted. `; - html += `Explorer ${linkSvg}`; + html += `Explorer ${linkSvg}`; html += '

'; if (withAttributes) { html += renderAttributes(nextMeta.attributes, appState.nextOpenedTraits); diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index ab56059931..0d4d9619e5 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -67,12 +67,12 @@ @@ -126,7 +126,7 @@ diff --git a/website/src/crowdfunding.html b/website/src/crowdfunding.html index cf12b7b31a..3b0b293f02 100644 --- a/website/src/crowdfunding.html +++ b/website/src/crowdfunding.html @@ -1149,7 +1149,7 @@ templateEngineOverride: njk
-

Get a stake
in SimpleX Chat

+

Own a part
of SimpleX Chat

We are building a network that people own.
We invite you to invest and become part of it.

diff --git a/website/src/directory.html b/website/src/directory.html index 08bb047aa5..679534d41a 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -267,7 +267,7 @@ active_directory: true

SimpleX Directory

Welcome to the selected users' communities that you can join via SimpleX Chat app.

-

SimpleX Directory is also available as a SimpleX chat bot.

+

SimpleX Directory is also available as a SimpleX chat bot.

Read about how to add your community.

diff --git a/website/src/fdroid.html b/website/src/fdroid.html index ff56b9cc95..67e4821e20 100644 --- a/website/src/fdroid.html +++ b/website/src/fdroid.html @@ -48,7 +48,7 @@ templateEngineOverride: njk

{{ "f-droid-org-repo" | i18n({}, lang ) | safe }}

- +

{{ "stable-versions-built-by-f-droid-org" | i18n({}, lang ) | safe }}

{{ "releases-to-this-repo-are-done-1-2-days-later" | i18n({}, lang ) | safe }}.

{{ "f-droid-page-f-droid-org-repo-section-text" | i18n({}, lang ) | safe }}

diff --git a/website/src/file.html b/website/src/file.html index 5eb545ef39..e5a25b446b 100644 --- a/website/src/file.html +++ b/website/src/file.html @@ -230,11 +230,11 @@ noEvents: true {{ "file-cta-subheading" | i18n({}, lang) | safe }}

- - + + - - + +
diff --git a/website/src/index.html b/website/src/index.html index 57580c1f4d..4b8491640c 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -166,7 +166,7 @@ active_home: true

{{ "index-hero-h1" | i18n({}, lang) | safe }}

{{ "index-hero-h2" | i18n({}, lang) | safe }}

{{ "index-hero-p1" | i18n({}, lang) | safe }}

-

{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "learn-more" | i18n({}, lang) }}.

+

{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "learn-more" | i18n({}, lang) }}.

- - + + - - + +
@@ -251,7 +251,7 @@ active_home: true

{{ "index-token-h2" | i18n({}, lang) }}

{{ "index-token-p1" | i18n({}, lang) }}

{{ "index-token-p2-cf" | i18n({}, lang) | safe }}

- {{ "index-token-cta-cf" | i18n({}, lang) | safe }} + {{ "index-token-cta-cf" | i18n({}, lang) | safe }} @@ -288,36 +288,36 @@ active_home: true
- - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + +

{{ "index-directory-h2" | i18n({}, lang) }}

diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index 2f88e885b9..b0a1696010 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -1037,11 +1037,11 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp } } -var BADGE_APPLE = 'App Store'; -var BADGE_GOOGLE = 'Google Play'; -var BADGE_FDROID = 'F-Droid'; -var BADGE_APK = 'APK Download'; -var BADGE_TESTFLIGHT = 'TestFlight'; +var BADGE_APPLE = 'App Store'; +var BADGE_GOOGLE = 'Google Play'; +var BADGE_FDROID = 'F-Droid'; +var BADGE_APK = 'APK Download'; +var BADGE_TESTFLIGHT = 'TestFlight'; function renderAppBadges(container) { const title = document.createElement('p'); diff --git a/website/src/js/directory.jsc b/website/src/js/directory.jsc index 7d99e71dad..5ca3b7fbcf 100644 --- a/website/src/js/directory.jsc +++ b/website/src/js/directory.jsc @@ -338,6 +338,7 @@ function displayEntries(entries) { imgLinkElement.href = groupLinkUri; } imgLinkElement.target = "_blank"; + imgLinkElement.rel = "noopener"; imgLinkElement.title = `Join ${displayName}`; const imgElement = document.createElement('img'); diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc index ffec278ca2..d05dd6a931 100644 --- a/website/src/js/simplex-lib.jsc +++ b/website/src/js/simplex-lib.jsc @@ -51,7 +51,7 @@ function isCurrentSite(uri) { } function targetBlank(uri) { - return isCurrentSite(uri) ? '' : ' target="_blank"' + return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } function renderMarkdown(fts) { @@ -98,7 +98,7 @@ function renderMarkdown(fts) { case 'simplexLink': { const { showText, linkType, simplexUri, smpHosts } = format; const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); - html += `${linkText} (${escapeHtml(viaHost(smpHosts))})`; + html += `${linkText} (${escapeHtml(viaHost(smpHosts))})`; break; } case 'command': diff --git a/website/src/livestream.html b/website/src/livestream.html index a0a6a2762e..33f6575101 100644 --- a/website/src/livestream.html +++ b/website/src/livestream.html @@ -79,7 +79,7 @@ templateEngineOverride: njk
diff --git a/website/src/messaging.html b/website/src/messaging.html index 994aa37b1b..33b01cfd9e 100644 --- a/website/src/messaging.html +++ b/website/src/messaging.html @@ -318,7 +318,7 @@ templateEngineOverride: njk
  • {{ "messengers-comparison-section-list-point-1" | i18n({}, lang ) | safe }}
  • {{ "messengers-comparison-section-list-point-2" | i18n({}, lang ) | safe }}
  • {{ "messengers-comparison-section-list-point-3" | i18n({}, lang ) | safe }}
  • -
  • {{ "messengers-comparison-section-list-point-4" | i18n({}, lang ) | safe }} — {{ "see-here" | i18n({}, lang ) | safe }}.
  • +
  • {{ "messengers-comparison-section-list-point-4" | i18n({}, lang ) | safe }} — {{ "see-here" | i18n({}, lang ) | safe }}.
  • {{ "messengers-comparison-section-list-point-5" | i18n({}, lang ) | safe }}
  • {{ "messengers-comparison-section-list-point-6" | i18n({}, lang ) | safe }}
  • From 000258224c3410a37fa03797a9a0dcb77d0980cd Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:14:55 +0000 Subject: [PATCH 29/84] desktop: fix crash in SimpleX address screen when there is no profile (#7679) * desktop: fix crash in SimpleX address screen when there is no profile * desktop: close SimpleX address screen when there is no profile * desktop: rerun address screen self-close when its modal is reused or the stack changes * desktop: close SimpleX address screen only when it is the top modal * plans: correct address no-profile crash triggers and fix description * plans: correct address no-profile crash fix description and verification * plans: tighten address no-profile crash wording * plans: add self-destruct trigger to address no-profile crash * plans: qualify self-destruct trigger on desktop * plans: drop desktop-only title from address no-profile crash * plans: no connected mobile, not no mobile * spec, product: document isLastModal and address screen without profile * plans, spec, product: plan sections, address row spec, GAP-08 and GAP-09 * spec, product, plans: fix GAP-08/09 wording, table placement, ModalManager line * spec, product: GAP-08/09 accuracy, gap count, clickable ModalManager location * product, plans: GAP-09 at the level the code supports; qualify picker auto-open * spec, product, plans: fix GAP-09 locations, stale line links, wording * spec, product, plans: update plain-text line numbers; GAP-09 read wording * plans: name the picker auto-open condition * spec, plans: wording; security note * plans: self-destruct did not crash on Android master; not a trigger * plans: self-destruct path possible but unconfirmed; Android recovers silently * spec, product, plans: drop GAP-09 and unrelated line refreshes * plan: Android self-destruct path confirmed in logcat * desktop: close Chat preferences screen when there is no profile, as the address screen does --- .../common/views/chatlist/UserPicker.kt | 22 ++++- .../simplex/common/views/helpers/ModalView.kt | 2 + .../product/views/user-profiles.md | 4 +- apps/multiplatform/spec/client/chat-list.md | 2 + apps/multiplatform/spec/client/navigation.md | 1 + ...-10-10-desktop-address-no-profile-crash.md | 99 +++++++++++++++++++ 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 plans/2026-10-10-desktop-address-no-profile-crash.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 652d072c9c..62fd634d29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -220,14 +220,32 @@ fun UserPicker( UserPickerOptionRow( painterResource(MR.images.ic_qr_code), if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), - showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped + showCustomModal { it, close -> + val user = it.currentUser.value + if (user != null) { + UserAddressView(it, shareViaProfile = user.addressShared, close = close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } + }, disabled = stopped ) UserPickerOptionRow( painterResource(MR.images.ic_toggle_on), stringResource(MR.strings.chat_preferences), click = if (stopped) null else ({ showCustomModal { m, close -> - PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close) + val user = m.currentUser.value + if (user != null) { + PreferencesView(m, user, close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } }() }), disabled = stopped 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 b13c321f70..f8e81c8ee2 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 @@ -115,6 +115,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + fun isLastModal(data: ModalData): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.data === data + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { showCustomModal(id = id, forceAnimated = forceAnimated) { close -> ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() }) diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md index dfc37a5e8d..9f31c8285e 100644 --- a/apps/multiplatform/product/views/user-profiles.md +++ b/apps/multiplatform/product/views/user-profiles.md @@ -28,8 +28,8 @@ Overlay panel triggered from `ChatListView` toolbar: | Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | | Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | | User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | -| SimpleX address | Row to open `UserAddressView` (create or view address) | -| Chat preferences | Row to open `PreferencesView` | +| SimpleX address | Row to open `UserAddressView` (create or view address); with no active profile the screen closes itself (if another screen covers it, when the user goes back to it) | +| Chat preferences | Row to open `PreferencesView`; with no active profile the screen closes itself (if another screen covers it, when the user goes back to it) | | Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | | Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | | Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index e34da9613d..aba6d3ad5f 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -279,6 +279,8 @@ fun UserPicker( - **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. - **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. - Closes automatically when any `ModalManager.start` modal opens. +- The SimpleX address row opens `UserAddressView` in `ModalManager.start`. With no active user (`currentUser == null`) the modal draws nothing and closes itself when it is the top modal (`ModalManager.start.isLastModal`); if another screen covers it, it closes when the user goes back to it. +- The Chat preferences row closes its modal the same way when there is no active user. ### Content diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index d0340e8d23..7838abae69 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -256,6 +256,7 @@ Each `ModalManager` maintains a stack of `ModalViewHolder` objects with: | `closeModals` | Pop all modals from the stack | | `closeModalsExceptFirst` | Pop all but the bottom modal | | `hasModalsOpen()` | Check if any modals are on the stack | +| `isLastModal(data)` | Check if the modal with this `ModalData` is the top modal not being removed | | `showInView` | Render the current modal stack into the composable tree | ### Usage Pattern diff --git a/plans/2026-10-10-desktop-address-no-profile-crash.md b/plans/2026-10-10-desktop-address-no-profile-crash.md new file mode 100644 index 0000000000..c51eeaff04 --- /dev/null +++ b/plans/2026-10-10-desktop-address-no-profile-crash.md @@ -0,0 +1,99 @@ +# Crash in the SimpleX address screen when there is no profile + +## Problem + +A Windows user who uses the desktop app only through a linked mobile reported this crash: + +``` +java.lang.NullPointerException + at chat.simplex.common.views.chatlist.ComposableSingletons$UserPickerKt$lambda$-1608374373$1.invoke(UserPicker.kt:223) + at chat.simplex.common.views.chatlist.UserPickerKt$UserPicker$6$showCustomModal$1$1$1.invoke(UserPicker.kt:157) + at chat.simplex.common.views.helpers.ModalManager$showInView$4$1$1.invoke(ModalView.kt:218) +``` + +The "SimpleX address" screen crashes whenever it is shown while there is no active user. + +## Solution summary + +When there is no current user, the address modal closes itself instead of composing `UserAddressView`. It closes only while it is the top modal, so it does not close a different screen. The new `ModalManager.isLastModal` checks that. + +The "Chat preferences" row, which in paths 1 and 2 below left an invisible modal instead of crashing, closes itself the same way. + +## Technical design + +### Cause + +The "SimpleX address" row of the user picker opens `UserAddressView` with `shareViaProfile = it.currentUser.value!!.addressShared`. The modal content reads `currentUser`, so it recomposes whenever `currentUser` changes. It throws whenever it is composed while `currentUser` is null, that is, when there is no active user. A desktop that was only used with a linked mobile has no active user of its own. + +The screen can be shown in that state in three ways: + +1. **The mobile disconnects.** The screen was opened for the mobile's profile, and then the mobile disconnects. `switchUIRemoteHost(null)` sets `currentUser` to the local user, which is null, and the left-panel modals stay open. + - If the address screen is on top, it crashes when it recomposes. + - If another screen is on top of it, it crashes when the user goes back to it. + + `UserAddressView` closes itself when the user changes (`KeyChangeEffect` on the user), but with a null user it is never reached. +2. **The row is clicked with no active user.** With no active profile, the user picker still shows the "Create SimpleX address" row. With no profile and no mobile in use, the picker even opens by itself, at startup or when the state begins while no left-panel screen is open (`App.kt`, `desktopNoUserNoRemote`). Clicking the row crashes immediately. + + At startup, no profile and no connected mobile needs a mobile that was linked before; with no linked mobile, startup shows onboarding instead (`Core.kt`). + + No active user also follows from deleting the active profile when no other visible profile remains, including the only profile, and including the last visible profile of a connected mobile. `doRemoveUser` (`UserProfilesView.kt`) then calls `changeActiveUser_` with no user, which sets `currentUser` from `apiGetActiveUser`. After the deletion there is no active user, so it is null. + +3. **The self-destruct passcode is entered while the screen is open.** `deleteStorageAndRestart` (`LocalAuthView.kt`) calls `reinitChatController`. That calls `initChatController`, which sets `currentUser` from the new empty database to null (`Core.kt`). Only later does `deleteStorageAndRestart` create the new profile and call `closeAllModalsEverywhere`. + - On Android, the shared modal stack stays composed under the lock screen, so the address screen can recompose with a null user and throw. It throws only if a frame falls after `currentUser` becomes null and before onboarding replaces the main screen, so it does not happen on every run. Android's main loop catches exceptions (`SimplexApp.kt`). `GlobalExceptionsHandler` (`UI.android.kt`) then closes the top modal, recreates the activity and posts a crash alert, and the self-destruct flow hides all alerts at its end, so no crash is visible. + - On desktop, `initChatController` replaces the main screen with the splash screen shortly afterwards (`localUserCreated = null`), so the crash needs a frame to fall in that short window. This was not tested. + +The "Chat preferences" row has the same cause without the crash. It opens `PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)`, so with no active user the modal content returns early. `PreferencesView` closes itself when the user changes (`KeyChangeEffect` on the user, `Preferences.kt`), but with a null user it is never reached either. The modal stays on the left-panel stack with nothing drawn and no back button, in paths 1 and 2 above. + +### Self-closing modal + +Both rows use the same effect. `ModalManager.closeModal` closes the top modal that is not already being removed, whichever modal calls it. So the effect closes only when `ModalManager.isLastModal(data)` reports that this modal is that one. + +The effect is keyed on the modal and on the modal count of `ModalManager.start` (the left panel on desktop, the shared stack on Android). So it checks again when the count changes, or when its composition is reused for another modal, while this content is still composed. Other than the top modal, a modal stays composed only while it animates out (250 ms, longer if the animation is interrupted). For example, a quick second click on the row reuses the composition of the first modal, which is still animating out. + +A modal that is covered after an animation ends is disposed. It is composed afresh when it becomes the top again, for example when the user goes back to it, and the effect runs then. + +With the `isLastModal` check, the effect does not close a different screen, such as one opened just before or just after the address screen during an animation. This holds for stack changes on the main thread. As before, `ModalManager` is not synchronized with background calls such as `closeAllModalsEverywhere`. + +### Visible behaviour + +When there is no active user: +- Clicking either row now only hides the picker, as opening any modal does. The modal closes itself, and the picker is not reopened. +- After the mobile disconnects with the address or preferences screen open, the picker does not reopen by itself (observed in testing). When the state with no user and no mobile in use begins, the app checks for open left-panel modals, and the modal closes itself only after that check. Master does the same when any other left-panel screen is open. + +The change is in common code. On Android the user picker is not reachable without an active user, because deleting the last visible profile returns to onboarding. In the self-destruct case on Android, the screen can no longer throw, so `GlobalExceptionsHandler` does not have to recover. With an active user, behaviour does not change on any platform. + +### Security + +The change is UI-only and crosses no trust boundary: it reads local state and closes a local screen. It sends nothing and changes no stored data. + +### Rejected alternative + +An early `return@showCustomModal`, as the "Chat preferences" row did, would avoid the crash. But it would leave an invisible modal on the left-panel stack, with no back button. It stays until something closes the left-panel modals, such as a click on the centre panel. If a profile is created first, it comes back as the address screen. The "Chat preferences" row has this behaviour on master; this change gives it the self-closing effect too. + +## Implementation steps + +1. `views/helpers/ModalView.kt`: add `ModalManager.isLastModal(data)`, true when `data` belongs to the top modal that is not being removed. +2. `views/chatlist/UserPicker.kt`: in the "SimpleX address" row, compose `UserAddressView` only when `currentUser` is not null. Otherwise run `LaunchedEffect(modal, ModalManager.start.modalCount.value)`, which closes the modal when `isLastModal(modal)` is true. Do the same in the "Chat preferences" row for `PreferencesView`. +3. Documentation, under `apps/multiplatform/`: + - `spec/client/navigation.md`: list `isLastModal` among the `ModalManager` methods. + - `spec/client/chat-list.md`, UserPicker section: describe the address and preferences rows closing themselves with no active user. + - `product/views/user-profiles.md`: describe both rows with no active profile. + +## Verification + +Desktop AppImages from master and from this branch, each with a fresh database, unless stated otherwise. The v7.1.0-beta.6 CLI (core v7.1.0.10) acts as the linked mobile (`/connect remote ctrl`). "Leftover" is checked by creating a profile afterwards: a leftover modal reappears as the address screen when onboarding finishes. + +- master (at the merge base), case 1: NPE at `UserPicker.kt:223`. +- master (at the merge base), case 2: NPE at `UserPicker.kt:223` with the reported stack (`showCustomModal` 157, `ModalView.kt:218`). +- This branch, case 1 with the address screen on top: no crash, and the screen closes. +- This branch, case 1 with the "Address or 1-time link?" screen (opened from "SimpleX address or 1-time link?") on top: no crash. That screen stays open after the disconnect, as on master. Going back closes the address screen and shows the chat list. +- This branch, case 2, double click (seven runs, 8 to 40 ms apart): no crash, and no leftover. With `LaunchedEffect(Unit)` the address screen reappeared after onboarding in 2 of 3 runs at 30 ms. +- This branch, case 2, "Settings" then "Create SimpleX address" 100 ms apart: no crash, and Settings stays open. With the close keyed on the modal count but without the `isLastModal` check, Settings was closed too. +- This branch, with a local profile: the address screen opens and closes normally. When the mobile disconnects with its address screen open, the screen closes through `UserAddressView`'s own user-change effect, and the app returns to the local profile. +- "Chat preferences", desktop with no profile, the same CLI linked as the mobile: + - Preferences open, then the mobile disconnects. Before this change the screen disappeared, and after a profile was created it came back as "Your preferences" over the chat. With this change it does not come back, and there is no crash. + - Row clicked with no profile and no mobile: nothing opens, and nothing comes back after a profile is created. + - With a profile: the screen opens and closes normally. +- Android (Waydroid: Android 13 with arm64 translation), debug APKs of master and of this branch, fresh app data, case 3 with the address screen opened from the user picker: + - master, two runs: in one, logcat shows `App crashed, thread name: main` with the NPE at `UserPicker.kt:223`; in the other, no exception. No crash is visible in either, as in an earlier manual test. + - This branch: one run completed to the new profile with no exception in logcat. As master did not throw on every run, this checks for no new failure rather than proving the fix. An earlier run hung during self-destruct, with the main thread, a coroutine thread and a Haskell worker all waiting for the native allocator lock under arm64 translation. From 23ca3ea42fa03a064243379a8b969f19b16e0893 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:37:37 +0000 Subject: [PATCH 30/84] desktop: hide SimpleX address and chat preferences rows with no profile and no mobile in use (#7717) --- .../common/views/chatlist/UserPicker.kt | 56 ++++++++++--------- .../product/views/user-profiles.md | 4 +- apps/multiplatform/spec/client/chat-list.md | 1 + ...sktop-hide-profile-rows-without-profile.md | 49 ++++++++++++++++ 4 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 plans/2026-10-14-desktop-hide-profile-rows-without-profile.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 62fd634d29..ea567d7da6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -217,41 +217,43 @@ fun UserPicker( @Composable fun SecondSection() { - UserPickerOptionRow( - painterResource(MR.images.ic_qr_code), - if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), - showCustomModal { it, close -> - val user = it.currentUser.value - if (user != null) { - UserAddressView(it, shareViaProfile = user.addressShared, close = close) - } else { - val modal = this - LaunchedEffect(modal, ModalManager.start.modalCount.value) { - if (ModalManager.start.isLastModal(modal)) close() - } - } - }, disabled = stopped - ) - UserPickerOptionRow( - painterResource(MR.images.ic_toggle_on), - stringResource(MR.strings.chat_preferences), - click = if (stopped) null else ({ - showCustomModal { m, close -> - val user = m.currentUser.value + if (!chatModel.desktopNoUserNoRemote) { + UserPickerOptionRow( + painterResource(MR.images.ic_qr_code), + if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), + showCustomModal { it, close -> + val user = it.currentUser.value if (user != null) { - PreferencesView(m, user, close) + UserAddressView(it, shareViaProfile = user.addressShared, close = close) } else { val modal = this LaunchedEffect(modal, ModalManager.start.modalCount.value) { if (ModalManager.start.isLastModal(modal)) close() } } - }() - }), - disabled = stopped - ) + }, disabled = stopped + ) + UserPickerOptionRow( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.chat_preferences), + click = if (stopped) null else ({ + showCustomModal { m, close -> + val user = m.currentUser.value + if (user != null) { + PreferencesView(m, user, close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } + }() + }), + disabled = stopped + ) + } if (appPlatform.isDesktop) { - Divider(Modifier.padding(DEFAULT_PADDING)) + if (!chatModel.desktopNoUserNoRemote) Divider(Modifier.padding(DEFAULT_PADDING)) val inactiveUsers = users.filter { !it.user.activeUser } diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md index 9f31c8285e..38a0e13874 100644 --- a/apps/multiplatform/product/views/user-profiles.md +++ b/apps/multiplatform/product/views/user-profiles.md @@ -28,8 +28,8 @@ Overlay panel triggered from `ChatListView` toolbar: | Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | | Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | | User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | -| SimpleX address | Row to open `UserAddressView` (create or view address); with no active profile the screen closes itself (if another screen covers it, when the user goes back to it) | -| Chat preferences | Row to open `PreferencesView`; with no active profile the screen closes itself (if another screen covers it, when the user goes back to it) | +| SimpleX address | Row to open `UserAddressView` (create or view address); hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) | +| Chat preferences | Row to open `PreferencesView`; hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) | | Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | | Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | | Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index aba6d3ad5f..5a63ad92bc 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -279,6 +279,7 @@ fun UserPicker( - **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. - **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. - Closes automatically when any `ModalManager.start` modal opens. +- On Desktop, when there is no active user and no mobile is in use (`desktopNoUserNoRemote`), the SimpleX address and Chat preferences rows and the divider below them are hidden. - The SimpleX address row opens `UserAddressView` in `ModalManager.start`. With no active user (`currentUser == null`) the modal draws nothing and closes itself when it is the top modal (`ModalManager.start.isLastModal`); if another screen covers it, it closes when the user goes back to it. - The Chat preferences row closes its modal the same way when there is no active user. diff --git a/plans/2026-10-14-desktop-hide-profile-rows-without-profile.md b/plans/2026-10-14-desktop-hide-profile-rows-without-profile.md new file mode 100644 index 0000000000..e906a6f275 --- /dev/null +++ b/plans/2026-10-14-desktop-hide-profile-rows-without-profile.md @@ -0,0 +1,49 @@ +# Hide the address and preferences rows when there is no profile and no mobile in use + +## Problem + +On a desktop with no active profile and no mobile in use, the user picker shows "Create SimpleX address" as its first option row, then "Chat preferences" and, below a divider, "Create chat profile". The picker opens by itself in this state when no left-panel screen is open (`App.kt`). Neither of the first two rows can work without a profile: + +- "Create SimpleX address" crashed with a NullPointerException at `UserPicker.kt:223` (`:216` in v6.1, `:222` in v6.2–v6.4) before #7679. GitHub reports from using the row: #5068, #5476, #6194, #7067 (Linux; comments on #5068 and #6194 report the same on Windows 11). +- "Chat preferences" left an invisible modal with no back button. +- With #7679 both screens close themselves instead, so a click only hides the picker and nothing opens. + +Users have told the team that the crash stops them from creating a profile. On v7.0.4 a profile can still be created from "Create chat profile" after the crash, but the first row the user tries is one that cannot work. + +The same crash also happens when a mobile disconnects while its address screen is open (#6452, macOS: the same stack, reported alongside phone disconnects). That path is fixed by #7679, not by this change. + +## Solution summary + +Hide the "SimpleX address" and "Chat preferences" rows when the desktop has no active user and no mobile in use (`chatModel.desktopNoUserNoRemote`). The divider below them is hidden with them. This is the state in which the picker already shows "Create chat profile" instead of "Your chat profiles". While a connected mobile is in use, both rows stay and act on the mobile's profile; if the mobile has no active profile, the screens close themselves (#7679). + +Before the new user picker (#4796), `SettingsView` showed both rows only inside `if (profile != null)`, with "Create chat profile" in the `else if (chatModel.localUserCreated.value == false)` branch. + +## Technical design + +`SecondSection` in `UserPicker.kt` emits both rows inside `if (!chatModel.desktopNoUserNoRemote)`. The desktop divider below them belongs to the same group and is drawn under the same condition. The composable getter reads `currentUser` and `currentRemoteHost` state, so the picker updates as soon as a profile is created, a mobile is connected, selected or disconnected, or the desktop is switched to "This device" with no profile. On Android `desktopNoUserNoRemote` is always false, so nothing changes there. + +The self-closing modals from #7679 stay. Hiding the rows only removes the entry point in that state. A screen shown with no active user in any other state still relies on #7679's handling; #7679's plan lists the paths. For example: +- a screen opened while there was a user, which then loses it: a mobile disconnects, or the self-destruct passcode is entered, with the address or preferences screen open; +- a mobile in use with no active profile (its last visible profile deleted): the rows are shown, and the screens close themselves. + +### Security + +UI-only: it hides two rows and the divider below them, based on local state. No data or trust boundary is involved. + +## Implementation steps + +1. `views/chatlist/UserPicker.kt`: in `SecondSection`, wrap the "SimpleX address" and "Chat preferences" rows in `if (!chatModel.desktopNoUserNoRemote)`, and draw the desktop divider below them under the same condition. +2. Documentation, under `apps/multiplatform/`: + - `spec/client/chat-list.md`, UserPicker section: when the two rows are hidden. + - `product/views/user-profiles.md`: when both rows are hidden. + +## Verification + +Desktop AppImage of this branch, fresh database, the v7.1.0-beta.6 CLI as the linked mobile; Android debug APK of this branch on Waydroid. + +- Mobile connected, no desktop profile: both rows are shown for the mobile's profile. "Create SimpleX address" creates the address on the mobile (`/show_address` on the CLI returns it). +- Mobile connected, switched to "This device" (no profile): both rows are hidden and "Create chat profile" is shown. Switching back to the mobile shows them again. +- Address screen open, then the mobile disconnects: the screen closes (#7679), no crash, and the picker then shows no address or preferences rows. +- "Create chat profile" from that state: the profile is created, both rows appear and open their screens. +- No profile, mobile linked but not in use: the device row, then "Create chat profile", with no divider. After deleting the linked mobile ("Linked mobiles", right-click, "Delete"): "Create chat profile" is the first row. The spacing matches the gap between rows. After creating a profile, both rows and the divider are back. +- Android, with a profile: both rows are shown and open their screens; no crash in logcat. From f5b13f608f3d56f59d772d3c3e1f264fbe2be280 Mon Sep 17 00:00:00 2001 From: Simplex Operations Date: Thu, 15 Oct 2026 06:18:30 +0000 Subject: [PATCH 31/84] packages: release nodejs 7.1.0-beta.6.3 (#7720) --- packages/simplex-chat-nodejs/package.json | 2 +- packages/simplex-chat-nodejs/src/release.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index ca62e57fa6..67eefdee4a 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.6.2", + "version": "7.1.0-beta.6.3", "main": "dist/index.js", "types": "dist/index.d.ts", "gypfile": false, diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts index ccd436e250..b56435e210 100644 --- a/packages/simplex-chat-nodejs/src/release.ts +++ b/packages/simplex-chat-nodejs/src/release.ts @@ -2,7 +2,7 @@ export const LIBS_RELEASE = "core-7.1.0.11" -export const ADDON_RELEASE: string | undefined = undefined +export const ADDON_RELEASE: string | undefined = "nodejs-addon-308c0960c707" export const SHA256: {readonly [asset: string]: string} = { "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", @@ -11,4 +11,9 @@ export const SHA256: {readonly [asset: string]: string} = { "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", + "simplex-chat-nodejs-linux-aarch64.node": "3636a82c56c48229e0326fa03d6ceb5c16dd3afbcc1e86337783dc53a7328d67", + "simplex-chat-nodejs-linux-x86_64.node": "55f3479b03fdb57591179ed730da744f1aa362e7529df55221b60028415746e9", + "simplex-chat-nodejs-macos-aarch64.node": "a3472872fb0816f435f52ee5471088e6545ebffb95e89fbf8e1ab82275f2d56e", + "simplex-chat-nodejs-macos-x86_64.node": "290901f7f227019a8cee3acc832541c930c4926fc08110df25169810a9002f5a", + "simplex-chat-nodejs-windows-x86_64.node": "ec689373b4f0fef73253a8328f60b8e902a45ea67e756d42a1f759b44dfd7339", } From e8c98e82316cc9444fd24ebfa1bd388d9d8877f1 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 15 Oct 2026 08:41:57 +0100 Subject: [PATCH 32/84] core, ios: fix notifications for forwarded and channel messages (#7706) * plan: add notification flag for forwarded messages * core, ui: fix notifications for forwarded and relay messages * improvements * only send duplicate message event in NSE * query plans --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/SimpleX NSE/NSEAPITypes.swift | 3 + .../ios/SimpleX NSE/NotificationService.swift | 20 +- apps/ios/SimpleXChat/ChatTypes.swift | 11 +- bots/src/API/Docs/Events.hs | 1 + .../2026-10-13-forwarded-notification-flag.md | 330 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Delivery.hs | 1 + src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Library/Internal.hs | 43 ++- src/Simplex/Chat/Library/Subscriber.hs | 17 +- src/Simplex/Chat/Protocol.hs | 5 + src/Simplex/Chat/Store/Delivery.hs | 18 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20261012_delivery_job_notification.hs | 19 + src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20261012_delivery_job_notification.hs | 18 + .../SQLite/Migrations/agent_query_plans.txt | 13 +- .../SQLite/Migrations/chat_query_plans.txt | 10 +- .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Groups.hs | 208 ++++++++++- 22 files changed, 674 insertions(+), 60 deletions(-) create mode 100644 plans/2026-10-13-forwarded-notification-flag.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift index 35a838fff9..28073ef342 100644 --- a/apps/ios/SimpleX NSE/NSEAPITypes.swift +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) case newChatItems(user: UserRef, chatItems: [AChatItem]) + case duplicateGroupMessage(user: UserRef, member: GroupMember) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) @@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case .contactConnected: "contactConnected" case .receivedContactRequest: "receivedContactRequest" case .newChatItems: "newChatItems" + case .duplicateGroupMessage: "duplicateGroupMessage" case .rcvFileSndCancelled: "rcvFileSndCancelled" case .sndFileComplete: "sndFileComplete" case .sndFileRcvCancelled: "sndFileRcvCancelled" @@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 25df063f82..38e1b604be 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -73,6 +73,16 @@ public enum NSENotificationData { default: nil } } + + func replaces(_ bestAttempt: NSENotificationData) -> Bool { + switch (self, bestAttempt) { + case (.noNtf, .connectionEvent): true + case (.noNtf, _): false + case (.callInvitation, _): true + case (_, .callInvitation): false + default: true + } + } } // Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid @@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension { // A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above. // If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once, // instead of requesting further messages. - if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil { + if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) { notificationEntities[id]?.msgBestAttemptNtf = ntf - } // otherwise keep call as best attempt + } return false } else { // We should not get to this branch, as notifications are not delivered for muted users. @@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf - let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir { - groupMember.id + let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId { + groupMemberEntityId(groupInfo.groupId, memberId) } else { chatItem.chatInfo.id } @@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? } else { return nil } + case let .duplicateGroupMessage(_, member): + return (member.id, .noNtf) case let .rcvFileSndCancelled(_, aChatItem, _): cleanupFile(aChatItem) return nil diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a8c5de3672..186d7dab64 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3255,7 +3255,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var relayLink: String? public var memberVerifiedCode: SecurityCode? - public var id: String { "#\(groupId) @\(groupMemberId)" } + public var id: String { groupMemberEntityId(groupId, groupMemberId) } public var ready: Bool { get { activeConn?.connStatus == .ready } } public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var sendMsgEnabled: Bool { get { @@ -3444,6 +3444,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ) } +public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String { + "#\(groupId) @\(groupMemberId)" +} + public struct GroupSupportChat: Codable, Hashable { public var chatTs: Date public var unread: Int @@ -4217,6 +4221,10 @@ public enum CIDirection: Decodable, Hashable { } } + public var groupMemberId: Int64? { + if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil } + } + public func sameDirection(_ dir: CIDirection) -> Bool { switch (self, dir) { case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId @@ -4242,6 +4250,7 @@ public struct CIMeta: Decodable, Hashable { public var userMention: Bool public var deletable: Bool public var editable: Bool + public var forwardedByMember: Int64? public var showGroupAsSender: Bool public var msgVerified: MsgVerified? diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 48f307f6e4..e310fd54ef 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -203,6 +203,7 @@ undocumentedEvents = "CEvtContactRequestRejected", "CEvtContactSwitch", "CEvtCustomChatEvent", + "CEvtDuplicateGroupMessage", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", "CEvtServiceSubStatus", diff --git a/plans/2026-10-13-forwarded-notification-flag.md b/plans/2026-10-13-forwarded-notification-flag.md new file mode 100644 index 0000000000..a1962a6e0f --- /dev/null +++ b/plans/2026-10-13-forwarded-notification-flag.md @@ -0,0 +1,330 @@ +# Notification flag of forwarded messages + +Date: 2026-10-13. + +A message forwarded by a chat relay or by a forwarding group host is sent with the SMP notification flag when its original event has the flag by the rule of `hasNotification`. + +References are to `/code/simplex-chat-4`, branch `master`, commit `2f4a443dd`. Haskell paths without a directory are in `src/Simplex/Chat/`. `SMQ/` is `dist-newstyle/src/simplexmq-f058c38b0eecb6c0/src/Simplex/Messaging/`. `NSE` is `apps/ios/SimpleX NSE/NotificationService.swift`. + +## 1. Present behaviour +Path of a forwarded message: +``` +received Library/Subscriber.hs:1014 MSG of a member connection; the flag of the received SMP message is ignored + Library/Subscriber.hs:1069-1144 processEvent: a NewMessageDeliveryTask is returned for each event to forward +task Library/Subscriber.hs:1022 created when the user is the relay of a channel, or an admin or owner of a group (:3800), and the author is not blocked by an admin + Library/Subscriber.hs:1158-1176 createDeliveryTasks; createMsgDeliveryTask (Store/Delivery.hs:78) is called at :1165 and :1172 only + Store/Delivery.hs:137-170 the task is read with its message: msg_body is decoded to verifiedMsg, the original event +job Library/Subscriber.hs:4233-4240 DJDeliveryJob: accepted tasks of one scope (Store/Delivery.hs:178) are batched into one job (Messages/Batch.hs:84) + Library/Subscriber.hs:4246-4251 DJRelayRemoved: one job for one task + Library/Commands.hs:3256-3265 leaveChannelRelay: one job with the x.grp.leave of the relay + Store/Delivery.hs:285-328 the job is read as scope, sender ids, body and cursor; its events are stored only as the encoded body +sent Library/Subscriber.hs:4379-4423 channel: for each recipient, overflow profile batches, then the body; MsgFlags False (:4416) + Library/Subscriber.hs:4424-4487 channel support scope and regular group: deliver; MsgFlags False (:4483) +``` +Types (`Delivery.hs`): +``` +DeliveryTaskContext :48 + jobScope :: DeliveryJobScope + sentAsGroup :: ShowGroupAsSender +DeliveryJobScope :54 + DJSGroup {jobSpec :: DeliveryJobSpec} + DJSMemberSupport {supportGMId :: GroupMemberId} +DeliveryJobSpec :60 + DJDeliveryJob {includePending :: Bool} + DJRelayRemoved +NewMessageDeliveryTask :122 + messageId :: MessageId + taskContext :: DeliveryTaskContext +MessageDeliveryTask :128 + taskId :: Int64 + jobScope :: DeliveryJobScope + senderGMId :: GroupMemberId + fwdSender :: FwdSender + brokerTs :: UTCTime + verifiedMsg :: VerifiedMsg 'Json +MessageDeliveryJob :161 + jobId :: Int64 + jobScope :: DeliveryJobScope + senderGMIds :: [GroupMemberId] + body :: ByteString + cursorGMId_ :: Maybe GroupMemberId +``` +Tables (`Store/SQLite/Migrations/M20250813_delivery_tasks.hs:54, :103`, `M20260529_delivery_job_senders.hs:17`; the same columns in Postgres): +``` +both tables + group_id + worker_scope + job_scope_spec_tag + job_scope_include_pending + job_scope_support_gm_id + failed + created_at + updated_at +delivery_tasks + delivery_task_id + sender_group_member_id + message_id -- messages.chat_msg_event: the tag; messages.msg_body: the original event + message_from_channel + task_status + task_err_reason +delivery_jobs + delivery_job_id + sender_group_member_ids + body + cursor_group_member_id + job_status + job_err_reason +``` +The flag, from the forwarder to the notification: +``` +forwarder agent SMQ/Agent.hs:2186-2203, 2563 one flag for each MsgReq, stored with the sent message; a body shared by VRRef is sent with the flag of each request + SMQ/Agent.hs:2637-2644 the flag is a parameter of SEND +SMP server SMQ/Server.hs:2038-2039 a notification is stored for a flagged message to a queue with notifier credentials (:2131-2136), and for such a message only + SMQ/Server.hs:361-414 the stored notification is sent as NMSG to the subscriber of the notifier, the notification server +notification server SMQ/Notifications/Server.hs:545-553 NMSG: a push notification with the last notifications of the device token is sent to the push provider (:693-702) +recipient Library/Subscriber.hs:890 the notification subscription of a member connection is created unless the chat is muted, MFNone (Library/Commands.hs:1922-1929; SMQ/Agent.hs:3345-3361) + SMQ/Agent.hs:2093-2144, 3741 the agent gets the message of the notified queue and passes MSG with the flag; forwarded content is opaque to the agent + Library/Subscriber.hs:1014 the core ignores the flag; the item is created from the forwarded event (:4008-4064) +``` +iOS notification extension, for a forwarded message: +``` +entity key NSE:489-498 id of the notified connection: "# @" of the relay or the host (apps/ios/SimpleXChat/ChatTypes.swift:3624-3632, :3258) +item key NSE:1025-1029 the member of the item, its author, for groupRcv; "#" for channelRcv +forwarded item NSE:173-187 the keys are different: the notification of the item is added to droppedNotifications, outside the entity of the notified connection +shown NSE:1196-1216 the default of the connection: MFAll: title "# :", text "message received" (apps/ios/SimpleXChat/Notifications.swift:102); MFMentions: empty content +``` +- With the flag alone the push notification is sent, and the message is received by the core of the NSE: the item is created. The shown notification is the default one. The NSE change of section 7 is required for a notification with the message text. + +Regular groups, the same message sent to a member by its author and by the host: +``` +author Library/Internal.hs:2847-2865 member with a ready connection: sent to the member; otherwise forwarded by the host (MSAForwarded) or stored as pending +host Library/Subscriber.hs:4447-4458 job recipients: members whose relation with the sender is MRIntroduced +both sent while the author is connected to the member and the relation at the host is MRIntroduced + the second copy is a duplicate: one item; the recipient sends x.grp.mem.con (Library/Internal.hs:2939-2945, 2958-2968), the host stops forwarding (Library/Subscriber.hs:3724-3729) +rule each copy of x.msg.new is flagged: two notifications for one item in that interval, one otherwise +``` + +## 2. Rule +``` +forwarded message flag = hasNotification of the tag of its original event (Protocol.hs:1338) + the flag of the received SMP message is unused +SMP message notification = True when the flag of any forwarded message of its body is True, as for sent events (Library/Internal.hs:2248, 2748) +job one value for the body, computed when the job is created; the same for every recipient, scope and cursor page +channel pieces body (id 0) and extBody, profiles with the body (id 1): the job value + overflow profile batches (id 2 and above): False; their elements are x.grp.mem.new of the relay (Library/Subscriber.hs:4277) +``` +Events (handlers at `Library/Subscriber.hs:1080-1132`; forwarded events are those of `isForwardedGroupMsg`, `Protocol.hs:528`): +``` +event flag +x.msg.new True every content message, with or without a file +x.grp.del True forwarded by the relay-removed job (:1118) +x.msg.file.descr False +x.msg.update False +x.msg.del False +x.msg.react False +x.file.cancel False +x.info False +x.grp.relay.new False +x.grp.mem.new False +x.grp.mem.role False +x.grp.mem.restrict False +x.grp.mem.del False also the relay-removed job of a removed relay or host (:3746) +x.grp.leave False also the job of a relay that leaves (Library/Commands.hs:3263) +x.grp.info False +x.grp.prefs False +x.grp.roster False +x.file True handled without a delivery task (:1096) +x.grp.mem.fwd True handled without a delivery task (:1110) +x.grp.inv True handled without a delivery task (:1124) +x.call.inv True handled without a delivery task (:1132) +x.contact True handled without a delivery task (:1132) +``` + +## 3. Core changes +``` +Protocol.hs + msgHasNotification new, next to hasNotification (:1338): VerifiedMsg e -> Bool + hasNotification of the tag of the event of the message (verifiedChatMsg, :1583) +Delivery.hs + MessageDeliveryJob (:161) notification :: Bool -- new, after body +Store/Delivery.hs + createMsgDeliveryJob (:251) new parameter notification :: Bool, before body; written to delivery_jobs.notification + MessageDeliveryJobRow (:283) BoolInt added after the body + getNextDeliveryJob (:309-328) notification selected after body; set in MessageDeliveryJob +Library/Subscriber.hs + processDeliveryTask (:4235) notification: True when msgHasNotification of verifiedMsg is True for any of acceptedTasks; computed next to senderGMIds, passed at :4237 + (:4250) msgHasNotification verifiedMsg of the task is passed + processDeliveryJob (:4331) notification is bound from the job + recipientBodyPieces (:4417) each piece is returned with its flag: overflow batch False; extBody and body: notification + addPiece (:4410-4416) MsgFlags {notification = the flag of the piece} + deliver (:4483) MsgFlags {notification} +Library/Commands.hs + leaveChannelRelay (:3263) hasNotification XGrpLeave_ is passed +Library/Internal.hs + sendFwdMemberMessage (:2903) MsgFlags {notification = msgHasNotification verifiedMsg} + False for both callers: x.grp.roster (:1307), x.grp.mem.del (Library/Subscriber.hs:3798) +``` +Sites with a fixed flag, unchanged: +``` +Library/Internal.hs:1428 sendHistory: history for a new member, with the welcome message of a group; MsgFlags False (open question 1) +Library/Internal.hs:2459 sendGroupMemberMessages: events of the user to one member; notification = True +Library/Internal.hs:2910 sendPendingGroupMessages: pending messages of the user to one member; notification = True +tests/ChatTests/Groups.hs:5681, 10907, 13320, 13452, 13608, 13701 elements sent by tests through the agent; MsgFlags False +``` +- Every other `MsgFlags` value of the repository is built for events of the user, from `hasNotification` of their tags: `Library/Internal.hs:2248, 2552, 2748`; `Library/Commands.hs:2672, 4131`; `Library/Subscriber.hs:356`. +- Forwarded content is sent as `>` elements only (`encodeFwdElement`, `Messages/Batch.hs:125`; used at `:98`, `Library/Subscriber.hs:4248`, `Library/Internal.hs:1423, 2902`). `XGrpMsgForward` events are only received (`Library/Subscriber.hs:1127`). + +## 4. Storage +Migration `M20261012_delivery_job_notification`, SQLite and Postgres, registered in `Store/SQLite/Migrations.hs` (`:180`, `:358`), `Store/Postgres/Migrations.hs` (`:57`, `:112`) and `simplex-chat.cabal` (`:172`, `:351`). It is one `ADD COLUMN`; indexes are unchanged. +``` +delivery_jobs + notification INTEGER NOT NULL DEFAULT 0 -- SMALLINT in Postgres +``` +- Down migration: the column is dropped. `delivery_tasks` is unchanged: the event of a task is read with its message. +- Applied migrations must be a prefix of the list sorted by name (`SMQ/Agent/Store/Migrations.hs:23-34`; `Store/SQLite/Migrations.hs:363`). The name is ordered after `20261010_invite_member_via_group` and before the unreleased `20261013_chat_layout` and `20261013_msg_priority` of branch `ep/llm-ux` (open question 3). +- The schema dumps and `chat_query_plans.txt` are regenerated by the tests "verify and overwrite schema dump" (`tests/SchemaDump.hs:58`, `tests/PostgresSchemaDump.hs:29`) and "verify and overwrite query plans" (`tests/SchemaDump.hs:165`); they are never edited by hand. + +## 5. Compatibility and cost +``` +chat protocol unchanged; the flag is a parameter of SMP SEND (SMQ/Protocol.hs:598) +recipient every version; the flag is used by the SMP server only +earlier relay or host sends without the flag, as now; a channel member is notified by each relay of this release +job of an earlier release notification = 0: sent without the flag +downgrade the column is dropped +traffic count and size of SMP messages unchanged +notifications one for each flagged SMP message to a queue with notifier credentials: a device with a notification token and the chat set to MFAll or MFMentions + one job is one flagged SMP message for a recipient, for all its events +channel a post is sent by each relay to each member (Library/Internal.hs:2843-2845; Store/Delivery.hs:352) + with N members and R relays up to N × R notifications are sent for a post; one item is created (Library/Internal.hs:2958-2959) + shown on iOS: one notification for a post; a duplicate copy is shown with empty content (section 7) +profile overflow batches unflagged +``` + +## 6. Tests +`tests/ChatTests/Groups.hs`, helpers next to `relayConnIdToMember` (`:13140`): +``` +agentSendBody cc connId body body sent to the connection by the agent of cc, MsgFlags False; a failure is a test failure +lastSndMsgFlags cc connId n msg_flags, "T" or "F", of the last n sent rows of type A_MSG ("M") of agent table messages for the connection, in sending order; withCCAgentTransaction (tests/ChatTests/Utils.hs:675) +lastJobNotification cc notification of the last row of delivery_jobs; withCCTransaction +``` +- The agent row of a sent message is kept after delivery until its receipt (`SMQ/Agent/Store/AgentStore.hs:1318-1349`; `SMQ/Agent.hs:2866-2869`): recipients run `/set receipts all off` before the checked messages (as `:1561`). + +New tests: +``` +group message forwarding (:191) + testGroupMsgForwardNotification createGroup3, setupGroupForwarding (:5802); bob sends a message: alice to cath "T", job True; bob edits it: "F", job False +relay delivery, single relay (:283) + testChannels1RelayDeliverNotification createChannel1Relay (:9523); alice posts: bob to cath, dan and eve "T", job True; cath adds a reaction: bob to dan and eve "F", job False +deliver member profiles via relay (:295) + testChannelOverflowProfileNotification the scenario of testChannelLargeProfileFits (:9956) with an image of 10000 characters and a message of 7000 characters, so that the profile is sent in an overflow batch: bob to dan, the rows added by the message: "F" for the overflow profile batch, "T" for the message +``` +- One sender and one message give one job. +Assertions added to present tests, on `delivery_jobs` only (the connections and their agent rows are deleted after the job, `Library/Subscriber.hs:4328`): +``` +testGroupMsgForwardGroupDeletion (:6135) alice: job True, x.grp.del +testChannelDeleteGroupSigned (:11031) bob: job True, x.grp.del +testGroupMsgForwardAdminRemoval (:6096) alice: job False, x.grp.mem.del +testChannelRelayLeave (:11220) bob: job False, x.grp.leave +``` +Present tests of delivery jobs, unchanged: "group message forwarding" (`:191-206`), "group scoped messages" (`:255-264`), "relay delivery" (`:282-292`), "deliver member profiles via relay" (`:295-301`), `testRelayBatchAllLarge` and `testRelayBatchSingletonOverflow` (`tests/MessageBatching.hs:164, 186`). + +## 7. iOS notification of a forwarded message and of a duplicate copy +Event, `Controller.hs:1048`, next to `CEvtUnknownMemberAnnounced`: +``` +CEvtDuplicateGroupMessage JSON type duplicateGroupMessage + user :: User + member :: GroupMember -- the member whose connection delivered the copy +``` +Emission, `Library/Internal.hs`, before the present handling of the duplicate, only when the chat is started with `main=off` (the NSE and the share extension): +``` +isMainApp :2976 subscriptionMode is SMSubscribe; startChatController sets SMOnlyCreate for main=off (Library/Commands.hs:235) +saveGroupRcvMsg :2925 direct copy; member: the member of the connection; every SEDuplicateGroupMessage + x.grp.mem.con to the forwarding member when the stored copy was forwarded, as now (:2940-2944) +saveGroupFwdRcvMsg :2959 channel; member: the forwarding relay; Nothing is returned, as now + :2960-2969 group; member: the forwarding host; x.grp.mem.con and the error, as now +``` +Consumers: +``` +terminal view View.hs:471: no output, as CEvtNtfMessage (:547) +API docs bots/src/API/Docs/Events.hs:205: in undocumentedEvents, as CEvtUnknownMemberCreated +apps, CLI, bots not emitted: started with main=on +NSE decoded: case duplicateGroupMessage (SimpleX NSE/NSEAPITypes.swift:91) +``` +Swift types, `apps/ios/SimpleXChat/ChatTypes.swift`: +``` +CIMeta.forwardedByMember :4253 Int64?, after editable, as in core CIMeta (Messages.hs:525); omitted in JSON for Nothing +groupMemberEntityId :3447 (groupId, groupMemberId) -> "# @"; used by GroupMember.id (:3258) and the NSE +CIDirection.groupMemberId :4224 groupMemberId of the member of groupRcv; nil for other directions +``` + +NSE, `apps/ios/SimpleX NSE/NotificationService.swift`: +``` +receivedMsgNtf (:1035-1039) + newChatItems key of a group item: groupMemberEntityId of the group and of the member whose connection delivered the item: + forwardedByMember, otherwise chatDir.groupMemberId; the id of the connection entity (ChatTypes.swift:3624-3632) + other items, and channelRcv without forwardedByMember: chatInfo.id, as now + duplicateGroupMessage key: member.id; notification: .noNtf +processReceivedNtf (:586-588): the best attempt of the entity is read from notificationEntities[id] and replaced when ntf.replaces(bestAttempt) +NSENotificationData.replaces (:77-85), new notification, best attempt: + .noNtf, .connectionEvent true: the default of the connection + .noNtf, other false + .callInvitation, any true + other, .callInvitation false + other, other true +``` +- `.noNtf` is used: `.connectionEvent` is created only as the default (`defaultBestAttemptNtf`, `:1208-1229`). A received item, a contact event and a call are never replaced by `.noNtf`. +- The best attempt is read from `notificationEntities[id]`: `processDroppedNotifications` (`:514-529`) passes the entity copied before its notifications are processed. + +Results: +``` +forwarded post, MFAll the text of the post (createMessageReceivedNtf, apps/ios/SimpleXChat/Notifications.swift:66) +forwarded post, MFMentions the text for a mention or a reply; empty content otherwise +duplicate copy empty content, unless an item of the same push notification is shown +mention, then other item the mention is shown; before this patch, .noNtf of the other item replaced it +``` +Tests, `tests/ChatTests/Groups.hs`; helpers next to `lastJobNotification`: +``` +duplicateMessageEventsCfg testCfg with an eventHook (Controller.hs:257) that records the member names of CEvtDuplicateGroupMessage +setMainAppOff cc subscriptionMode of cc set to SMOnlyCreate, as by /_start main=off; called after the group is created +``` +Copies are sent through the agent of the sender by `agentSendBody`, as in `testGroupMsgForwardOwnMembershipRejected` (`:5666`), so that the order of copies is fixed: +``` +group message forwarding (:191) + testGroupMsgForwardDuplicateEvent createGroup3; bob's message is received by cath directly; alice's agent sends a forwarded copy + (Library/Internal.hs:2960-2969): cath "duplicate group message, group id: 1"; no event + setMainAppOff cath; alice's agent sends the copy again: the same line; event member alice + testGroupMsgDirectDuplicateEvent alice's agent sends a forwarded copy; setMainAppOff cath; bob's agent sends the direct copy + (Library/Internal.hs:2939-2946): cath "duplicate group message, group id: 1"; event member bob +relay delivery, single relay (:283) + testChannelDuplicateEvent createChannel1Relay; alice posts; setMainAppOff cath; bob's agent sends a second forwarded copy + (Library/Internal.hs:2959): event member bob; cath without output +``` +- The NSE and the Swift types are not compiled on this machine. Manual checks on iOS: a post in a channel with 2 relays, with MFAll and with MFMentions; a forwarded message in a group; a mention followed by an ordinary message in one push notification. + +## Interfaces +``` +core functions msgHasNotification :: VerifiedMsg e -> Bool + createMsgDeliveryJob, new parameter notification + isMainApp :: CM Bool +core types MessageDeliveryJob.notification +core events CEvtDuplicateGroupMessage, JSON type duplicateGroupMessage +storage delivery_jobs.notification + migration M20261012_delivery_job_notification +iOS CIMeta.forwardedByMember + groupMemberEntityId + CIDirection.groupMemberId + NSEChatEvent.duplicateGroupMessage + NSENotificationData.replaces +test helpers agentSendBody + lastSndMsgFlags + lastJobNotification + duplicateMessageEventsCfg + setMainAppOff +``` +- `eventHasNotification :: ChatMsgEvent e -> Bool` of `2026-10-13-message-priority.md` is accepted by one change: `msgHasNotification` is computed as `eventHasNotification` of the event of the message. Its three uses, the job column and `hasNotification XGrpLeave_` in `leaveChannelRelay` are unchanged. +- With that change a forwarded `x.msg.update` that raises priority is flagged. The line of the priority plan, section 4, "delivery jobs of relays and forwarding hosts, MsgFlags False for every message" is replaced by this plan. + +## Open questions +1. History and the welcome message (`sendHistory`), flagged until commit `93831a0f2` (2026-07-17) and unflagged since: apply the rule? Recommended: unchanged, sent without the flag. +2. The job flag as a column, or computed by the job worker from the decoded body, without a migration? Recommended: the column, computed where the events of the tasks are read. +3. Release order with `ep/llm-ux`. Recommended: this correction first, with date `20261012` in the migration name; otherwise a date after the last released migration. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index c9a29b3ac8..205939a9a2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -170,6 +170,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group + Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification else exposed-modules: Simplex.Chat.Archive @@ -349,6 +350,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group + Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2d553bdc9b..65c70e75fe 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1046,6 +1046,7 @@ data ChatEvent | CEvtUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CEvtUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} | CEvtUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} + | CEvtDuplicateGroupMessage {user :: User, member :: GroupMember} | CEvtGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, msgSigned :: Maybe MsgSigStatus} | CEvtGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember, msgSigned :: Maybe MsgSigStatus} -- there is the same command response | CEvtAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index a6ccc74247..a83c624274 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -163,6 +163,7 @@ data MessageDeliveryJob = MessageDeliveryJob jobScope :: DeliveryJobScope, senderGMIds :: [GroupMemberId], body :: ByteString, + notification :: Bool, cursorGMId_ :: Maybe GroupMemberId } deriving (Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c37c45ec90..7a4e6d2d36 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3260,7 +3260,7 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo deleteGroupDeliveryJobs db gInfo - createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body + createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] (hasNotification XGrpLeave_) body lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg leaveGroupSendMsg user g@(GIK gInfo _) = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 6ea428ac1f..418d454ada 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2900,7 +2900,7 @@ sendFwdMemberMessage :: GroupMember -> GrpMsgForward -> VerifiedMsg 'Json -> CM sendFwdMemberMessage member fwd verifiedMsg = forM_ (readyMemberConn member) $ \(_, conn) -> do let body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] - void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)] + void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags {notification = msgHasNotification verifiedMsg}, VRValue Nothing body)] -- TODO ensure order - pending messages interleave with user input messages sendPendingGroupMessages :: User -> GroupInfo -> GroupMember -> Connection -> CM () @@ -2937,11 +2937,13 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) `catchAllErrors` \e -> case e of - ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do - cxt <- chatStoreCxt - fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId - forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId + ChatErrorStore (SEDuplicateGroupMessage _ _ _ forwardedByGroupMemberId_) -> do + unlessM isMainApp $ toView $ CEvtDuplicateGroupMessage user am' + forM_ forwardedByGroupMemberId_ $ \forwardedByGroupMemberId -> do + cxt <- chatStoreCxt + fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId + forM_ (memberConn fm) $ \fmConn -> + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId throwError e _ -> throwError e pure (am', conn', msg) @@ -2955,20 +2957,25 @@ saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMemb -- TODO [relays] TBC highlighting difference between deduplicated messages (useRelays branch) withStore' (\db -> runExceptT $ createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) >>= \case Right msg -> pure $ Just msg - Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} - | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected - | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of - (Just authorGMId, Nothing) -> do - cxt <- chatStoreCxt - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId - if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ - then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId - else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" - throwError $ ChatErrorStore e - _ -> throwError $ ChatErrorStore e + Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} -> do + unlessM isMainApp $ toView $ CEvtDuplicateGroupMessage user forwardingMember + if + | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected + | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of + (Just authorGMId, Nothing) -> do + cxt <- chatStoreCxt + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId + if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ + then forM_ (memberConn forwardingMember) $ \fmConn -> + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId + else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" + throwError $ ChatErrorStore e + _ -> throwError $ ChatErrorStore e Left e -> throwError $ ChatErrorStore e +isMainApp :: CM Bool +isMainApp = (SMSubscribe ==) <$> chatReadVar subscriptionMode + saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a1e748831c..9047ca1532 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -4233,8 +4233,9 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxForwardBatchLength nextTasks senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks + notification = any (\MessageDeliveryTask {verifiedMsg} -> msgHasNotification verifiedMsg) acceptedTasks withStore' $ \db -> do - forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds notification body forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" when (isJust body_) . lift . void $ getDeliveryJobWorker True deliveryKey @@ -4247,7 +4248,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do fwd = GrpMsgForward {fwdSender, fwdBrokerTs} body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope [senderGMId] body + createMsgDeliveryJob db gInfo jobScope [senderGMId] (msgHasNotification verifiedMsg) body updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed lift . void $ getDeliveryJobWorker True deliveryKey @@ -4328,7 +4329,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do deleteGroupConnections user gInfo True withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete where - MessageDeliveryJob {jobId, jobScope, senderGMIds, body, cursorGMId_ = startingCursor} = job + MessageDeliveryJob {jobId, jobScope, senderGMIds, body, notification, cursorGMId_ = startingCursor} = job singleSenderGMId_ = case senderGMIds of [s] -> Just s _ -> Nothing @@ -4407,16 +4408,16 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do addRecipient acc r = case readyMemberConn r of Just (_, conn) -> snd $ foldl' (addPiece conn) (0 :: Int, acc) (recipientBodyPieces r) Nothing -> acc - addPiece conn (k, (issued, reqs)) (bid, msgBody) = + addPiece conn (k, (issued, reqs)) (bid, ntf, msgBody) = let vor | IS.member bid issued = VRRef bid | otherwise = VRValue (Just bid) msgBody issued' = IS.insert bid issued connId = if k == 0 then aConnId conn else B.empty - in (k + 1, (issued', (connId, PQEncOff, MsgFlags False, vor) : reqs)) + in (k + 1, (issued', (connId, PQEncOff, MsgFlags {notification = ntf}, vor) : reqs)) recipientBodyPieces r = - [(i, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] - <> [if any missing inBodySenders then (1, extBody) else (0, body)] + [(i, False, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] + <> [if any missing inBodySenders then (1, notification, extBody) else (0, notification, body)] where missing s = case M.lookup (groupMemberId' s) senderVec of Just vec -> getRelation (indexInGroup r) vec == MRNew @@ -4480,7 +4481,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do addReq conn (memIdx_, reqs) = (subtract 1 <$> memIdx_, req : reqs) where - req = (aConnId conn, PQEncOff, MsgFlags False, vrValue_) + req = (aConnId conn, PQEncOff, MsgFlags {notification}, vrValue_) vrValue_ = case memIdx_ of Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body Just 1 -> VRValue (Just 1) msgBody diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 1266104e0e..a4fb12cc72 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1346,6 +1346,11 @@ hasNotification = \case XCallInv_ -> True _ -> False +msgHasNotification :: VerifiedMsg e -> Bool +msgHasNotification verifiedMsg = hasNotification $ toCMEventTag chatMsgEvent + where + ChatMessage {chatMsgEvent} = verifiedChatMsg verifiedMsg + hasDeliveryReceipt :: CMEventTag e -> Bool hasDeliveryReceipt = \case XMsgNew_ -> True diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index c80264da7a..c8875baab2 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -248,8 +248,8 @@ deleteDoneDeliveryTasks db createdAtCutoff = do |] (createdAtCutoff, DTSProcessed, DTSError) -createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> ByteString -> IO () -createMsgDeliveryJob db gInfo jobScope senderGMIds body = do +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> Bool -> ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope senderGMIds notification body = do currentTs <- getCurrentTime DB.execute db @@ -257,10 +257,10 @@ createMsgDeliveryJob db gInfo jobScope senderGMIds body = do INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, job_status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?) + sender_group_member_ids, body, notification, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, DJSPending, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, BI notification, DJSPending, currentTs, currentTs)) where GroupInfo {groupId} = gInfo -- NULL ↔ []; non-empty list ↔ comma-separated decimal Int64s. @@ -280,7 +280,7 @@ getPendingDeliveryJobScopes db = |] (Only DJSPending) -type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, Maybe GroupMemberId) +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, BoolInt, Maybe GroupMemberId) getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) getNextDeliveryJob db deliveryKey = do @@ -310,14 +310,14 @@ getNextDeliveryJob db deliveryKey = do SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, cursor_group_member_id + sender_group_member_ids, body, notification, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? |] (Only jobId) where toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob - toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, cursorGMId_)) = do + toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, BI notification, cursorGMId_)) = do jobScope <- maybe (Left $ SEInvalidDeliveryJob jobId') Right $ toJobScope_ jobScopeRow -- NULL or empty string means []; otherwise the value must parse -- as a comma-separated decimal Int64 list. An unparseable @@ -325,7 +325,7 @@ getNextDeliveryJob db deliveryKey = do senderGMIds <- case senderGMIdsText_ of Nothing -> Right [] Just t -> maybe (Left $ SEInvalidDeliveryJob jobId') Right $ parseSenderGMIds t - Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, cursorGMId_} + Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, notification, cursorGMId_} parseSenderGMIds :: Text -> Maybe [GroupMemberId] parseSenderGMIds t | T.null t = Just [] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index dc2de08739..7fceb20dfe 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -55,6 +55,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group +import Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -109,7 +110,8 @@ schemaMigrations = ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), - ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), + ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs new file mode 100644 index 0000000000..b8e025f53e --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20261012_delivery_job_notification :: Text +m20261012_delivery_job_notification = + [r| +ALTER TABLE delivery_jobs ADD COLUMN notification SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20261012_delivery_job_notification :: Text +down_m20261012_delivery_job_notification = + [r| +ALTER TABLE delivery_jobs DROP COLUMN notification; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 3e6d6694c6..6ed3e81d1f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -178,6 +178,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group +import Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -355,7 +356,8 @@ schemaMigrations = ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), - ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), + ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs new file mode 100644 index 0000000000..9a582823b5 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261012_delivery_job_notification :: Query +m20261012_delivery_job_notification = + [sql| +ALTER TABLE delivery_jobs ADD COLUMN notification INTEGER NOT NULL DEFAULT 0; +|] + +down_m20261012_delivery_job_notification :: Query +down_m20261012_delivery_job_notification = + [sql| +ALTER TABLE delivery_jobs DROP COLUMN notification; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index d12b3c98cf..dc8ad0f248 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -614,15 +614,6 @@ Query: Plan: SEARCH address_ratchet_keys USING INDEX idx_address_ratchet_keys (conn_id=? AND ratchet_key_id=?) -Query: - UPDATE rcv_messages - SET receive_attempts = receive_attempts + 1 - WHERE conn_id = ? AND internal_id = ? - RETURNING receive_attempts - -Plan: -SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) - Query: DELETE FROM address_ratchet_keys WHERE conn_id = ? @@ -1431,6 +1422,10 @@ Query: SELECT last_insert_rowid() Plan: SCAN CONSTANT ROW +Query: SELECT msg_flags FROM messages WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND msg_type = ? ORDER BY internal_id DESC LIMIT ? +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=?) + Query: SELECT ratchet_state FROM ratchets WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 433c2ec43f..64aea20620 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -774,7 +774,7 @@ Query: SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, cursor_group_member_id + sender_group_member_ids, body, notification, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? @@ -5058,8 +5058,8 @@ Query: INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, job_status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?) + sender_group_member_ids, body, notification, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -7838,6 +7838,10 @@ Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1 Plan: SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) +Query: SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMIT 1 +Plan: +SCAN delivery_jobs + Query: SELECT purchase_key FROM badge_code_redemptions Plan: SCAN badge_code_redemptions diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index d60a41047f..71abf5d37f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -806,7 +806,8 @@ CREATE TABLE delivery_jobs( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) , - sender_group_member_ids TEXT + sender_group_member_ids TEXT, + notification INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE TABLE group_member_status_predicates( member_status TEXT NOT NULL PRIMARY KEY, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e3e58cdf49..03b116202b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -469,6 +469,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] CEvtUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] CEvtUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] + CEvtDuplicateGroupMessage {} -> [] CEvtRcvFileDescrReady _ _ _ _ -> [] CEvtRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CEvtRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a127b160f5..58c1c9c51c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,7 +18,7 @@ import ChatTests.Profiles (addTestBadge, futureDate, issueTestBadge, testBadgeKe import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Concurrent.STM (atomically) +import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO, writeTVar) import Control.Monad (forM_, void, when) import Control.Monad.Except (runExceptT) import Control.Monad.Reader (runReaderT) @@ -32,14 +32,14 @@ import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Badges (FileSizeLimits (..)) -import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent) +import Simplex.Chat.Controller (ChatController (ChatController, smpAgent, subscriptionMode), ChatConfig (..), ChatEvent (CEvtDuplicateGroupMessage), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent) import Simplex.Chat.Library.Internal (encodeConnInfo, getShortLinkConnReq, uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), chatMsgToBody, mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -47,7 +47,7 @@ import Simplex.Messaging.Agent (joinConnection, prepareConnectionToJoin, sendMes import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsKeyGen) import Simplex.Messaging.Client (pattern NRMInteractive) @@ -194,7 +194,10 @@ chatGroupTests = do it "forward batched messages" testGroupMsgForwardBatched it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate + it "report duplicate when forwarded copy is received after direct copy" testGroupMsgForwardDuplicateEvent + it "report duplicate when direct copy is received after forwarded copy" testGroupMsgDirectDuplicateEvent it "forward message edit (x.msg.update)" testGroupMsgForwardEdit + it "set notification flag of forwarded messages by the original event" testGroupMsgForwardNotification it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction it "forward message deletion (x.msg.del)" testGroupMsgForwardDeletion it "forward file (x.msg.file.descr)" testGroupMsgForwardFile @@ -282,6 +285,8 @@ chatGroupTests = do describe "relay delivery" $ do describe "single relay" $ do it "should deliver messages to members" testChannels1RelayDeliver + it "should set notification flag of delivered messages" testChannels1RelayDeliverNotification + it "should report duplicate message delivered by relay" testChannelDuplicateEvent describe "should deliver messages in a loop over members" $ do it "number of recipients is multiple of bucket size (3/1)" (testChannels1RelayDeliverLoop 1) it "number of recipients is NOT multiple of bucket size (3/2)" (testChannels1RelayDeliverLoop 2) @@ -298,6 +303,7 @@ chatGroupTests = do it "multi senders disseminate independently" testChannelMultiSendersIndependent it "large profile fits in body" testChannelLargeProfileFits it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles + it "overflow profile batch is sent without notification flag" testChannelOverflowProfileNotification it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend describe "channel operations" $ do it "should update channel profile (signed)" testChannelUpdateProfileSigned @@ -5912,6 +5918,53 @@ testGroupMsgForwardDeduplicate = cath <#. "#team bob> hi there" cath <# "#team hey team" +testGroupMsgForwardDuplicateEvent :: HasCallStack => TestParams -> IO () +testGroupMsgForwardDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + groupForwardingRelations alice "bob" "cath" `shouldEventuallyReturn` (MRConnected, MRConnected) + + bob #> "#team hi there" + [alice, cath] *<# "#team bob> hi there" + + sharedMsgId <- itemSharedMsgId cath + bobMemId <- memberIdByName cath "bob" + connId <- relayConnIdToMember alice "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just sharedMsgId) (XMsgNew $ mcSimple $ MCText "hi there") + sendForwardedCopy = agentSendBody alice connId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward (FwdMember bobMemId "bob") ts) (VMUnsigned chatMsg)] + sendForwardedCopy + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` [] + setMainAppOff cath + sendForwardedCopy + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` ["alice"] + +testGroupMsgDirectDuplicateEvent :: HasCallStack => TestParams -> IO () +testGroupMsgDirectDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + + bobMemId <- memberIdByName cath "bob" + aliceConnId <- relayConnIdToMember alice "cath" + bobConnId <- relayConnIdToMember bob "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just $ SharedMsgId "duplicate_msg1") (XMsgNew $ mcSimple $ MCText "hi there") + agentSendBody alice aliceConnId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward (FwdMember bobMemId "bob") ts) (VMUnsigned chatMsg)] + cath <# "#team bob> hi there [>>]" + + setMainAppOff cath + agentSendBody bob bobConnId $ chatMsgToBody chatMsg + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` ["bob"] + testGroupMsgForwardEdit :: HasCallStack => TestParams -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ @@ -5936,6 +5989,29 @@ testGroupMsgForwardEdit = cath ##> "/tail #team 1" cath <# "#team bob> hello there [>>]" +testGroupMsgForwardNotification :: HasCallStack => TestParams -> IO () +testGroupMsgForwardNotification = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + setupGroupForwarding alice bob cath + cath ##> "/set receipts all off" + cath <## "ok" + cathConnId <- relayConnIdToMember alice "cath" + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + lastSndMsgFlags alice cathConnId 1 `shouldReturn` ["T"] + lastJobNotification alice `shouldReturn` True + + bob ##> "! #team hello there" + bob <# "#team [edited] hello there" + alice <# "#team bob> [edited] hello there" + cath <# "#team bob> [edited] hello there" + lastSndMsgFlags alice cathConnId 1 `shouldReturn` ["F"] + lastJobNotification alice `shouldReturn` False + testGroupMsgForwardReaction :: HasCallStack => TestParams -> IO () testGroupMsgForwardReaction = testChat3 aliceProfile bobProfile cathProfile $ @@ -6118,6 +6194,7 @@ testGroupMsgForwardAdminRemoval = alice <## "use /d #team to delete the group", cath <## "#team: bob removed alice from the group (signed)" ] + lastJobNotification alice `shouldReturn` False -- there is no forwarding admin anymore between bob and cath, so messages don't get delivered -- (this is not a desired behavior, just a test demonstration/proof of current implementation) @@ -6159,6 +6236,7 @@ testGroupMsgForwardGroupDeletion = cath <## "#team: bob deleted the group (signed)" cath <## "use /d #team to delete the local copy of the group" ] + lastJobNotification alice `shouldReturn` True alice ##> "/groups" alice <## "#team (group deleted, delete local copy: /d #team)" @@ -9520,6 +9598,65 @@ testChannels1RelayDeliver ps = cath <## "group ID: 1" cath <## "subscribers: 4" +testChannels1RelayDeliverNotification :: HasCallStack => TestParams -> IO () +testChannels1RelayDeliverNotification ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + forM_ [cath, dan, eve] $ \cc -> do + cc ##> "/set receipts all off" + cc <## "ok" + cathConnId <- relayConnIdToMember bob "cath" + danConnId <- relayConnIdToMember bob "dan" + eveConnId <- relayConnIdToMember bob "eve" + + alice #> "#team hi" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" + forM_ [cathConnId, danConnId, eveConnId] $ \connId -> + lastSndMsgFlags bob connId 1 `shouldReturn` ["T"] + lastJobNotification bob `shouldReturn` True + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > hi" + bob <## " + 👍" + alice <# "#team cath> > hi" + alice <## " + 👍" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> > hi" + dan <## " + 👍" + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> > hi" + eve <## " + 👍" + forM_ [danConnId, eveConnId] $ \connId -> + lastSndMsgFlags bob connId 1 `shouldReturn` ["F"] + lastJobNotification bob `shouldReturn` False + +testChannelDuplicateEvent :: HasCallStack => TestParams -> IO () +testChannelDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + alice #> "#team hi" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" + + sharedMsgId <- itemSharedMsgId cath + connId <- relayConnIdToMember bob "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just sharedMsgId) (XMsgNew $ mcSimple $ MCText "hi") + setMainAppOff cath + agentSendBody bob connId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward FwdChannel ts) (VMUnsigned chatMsg)] + readTVarIO dups `shouldEventuallyReturn` ["bob"] + createChannel1Relay :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () createChannel1Relay gName owner relay cath dan eve = do (shortLink, fullLink) <- prepareChannel1Relay gName owner relay @@ -9979,6 +10116,34 @@ testChannelLargeProfileFits ps = memberIntroducedTo bob "cath" "dan" +testChannelOverflowProfileNotification :: HasCallStack => TestParams -> IO () +testChannelOverflowProfileNotification ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] + dan ##> "/set receipts all off" + dan <## "ok" + danConnId <- relayConnIdToMember bob "dan" + + let bigImage = T.pack ("data:image/png;base64," <> replicate 10000 'A') + longText = replicate 7000 'x' + withCCTransaction bob $ \db -> + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text) + sentBefore <- length <$> lastSndMsgFlags bob danConnId 100 + + cath #> ("#team " <> longText) + bob <# ("#team cath> " <> longText) + alice <# ("#team cath> " <> longText <> " [>>]") + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# ("#team cath> " <> longText <> " [>>]") + drop sentBefore <$> lastSndMsgFlags bob danConnId 100 `shouldReturn` ["F", "T"] + testChannelMultipleLargeProfiles :: HasCallStack => TestParams -> IO () testChannelMultipleLargeProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -11053,6 +11218,7 @@ testChannelDeleteGroupSigned ps = eve <## "#team: alice deleted the group (signed)" eve <## "use /d #team to delete the local copy of the group" ] + lastJobNotification bob `shouldReturn` True testChannelDeleteGroupCleanup :: HasCallStack => TestParams -> IO () testChannelDeleteGroupCleanup ps = @@ -11246,6 +11412,7 @@ testChannelRelayLeave ps = dan <## "#team: bob left the group (signed)", eve <## "#team: bob left the group (signed)" ] + lastJobNotification bob `shouldReturn` False -- verify relay1 member status is "left" on all clients that know bob checkMemberStatus alice "bob" (Just "left") @@ -13149,6 +13316,39 @@ relayConnIdToMember cc name = do (Only connId : _) -> pure connId _ -> fail $ "no relay connection to member " <> T.unpack name +agentSendBody :: TestCC -> ByteString -> ByteString -> IO () +agentSendBody cc connId body = do + let ChatController {smpAgent} = chatController cc + runExceptT (sendMessages smpAgent [(connId, PQEncOff, MsgFlags False, vrValue body)]) >>= either (fail . show) (const $ pure ()) + +lastSndMsgFlags :: TestCC -> ByteString -> Int -> IO [T.Text] +lastSndMsgFlags cc connId n = do + rows <- withCCAgentTransaction cc $ \db -> + DB.query + db + "SELECT msg_flags FROM messages WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND msg_type = ? ORDER BY internal_id DESC LIMIT ?" + (connId, Binary ("M" :: ByteString), n) + pure $ reverse $ map fromOnly rows + +lastJobNotification :: TestCC -> IO Bool +lastJobNotification cc = do + [Only (BI ntf)] <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMIT 1" + pure ntf + +duplicateMessageEventsCfg :: IO (TVar [ContactName], ChatConfig) +duplicateMessageEventsCfg = do + dups <- newTVarIO [] + let hook _ evt = do + case evt of + Right (CEvtDuplicateGroupMessage _ GroupMember {localDisplayName}) -> atomically $ modifyTVar' dups (<> [localDisplayName]) + _ -> pure () + pure evt + pure (dups, testCfg {chatHooks = defaultChatHooks {eventHook = Just hook}}) + +setMainAppOff :: TestCC -> IO () +setMainAppOff cc = atomically $ writeTVar (subscriptionMode $ chatController cc) SMOnlyCreate + itemSharedMsgId :: TestCC -> IO SharedMsgId itemSharedMsgId cc = do rows <- withCCTransaction cc $ \db -> From 41f0c429b3d250249b3a748619d96b418c8de882 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 15 Oct 2026 12:50:38 +0100 Subject: [PATCH 33/84] website: update home page (#7724) * website: update home page * update --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- website/langs/ar.json | 10 +- website/langs/cs.json | 10 +- website/langs/de.json | 9 +- website/langs/en.json | 10 +- website/langs/es.json | 9 +- website/langs/fr.json | 9 +- website/langs/hu.json | 9 +- website/langs/id.json | 10 +- website/langs/it.json | 9 +- website/langs/ja.json | 10 +- website/langs/pl.json | 10 +- website/langs/pt_BR.json | 10 +- website/langs/ru.json | 10 +- website/langs/zh_Hans.json | 2 +- website/src/crowdfunding.html | 26 +- website/src/css/design3-nav.css | 14 +- website/src/css/design3.css | 367 +++++++++++++++++++++------ website/src/css/livestream.css | 2 +- website/src/fdroid.html | 5 + website/src/img/new/f_droid_icon.svg | 50 ++++ website/src/index.html | 97 +++---- website/src/js/design3.js | 5 +- website/src/livestream.html | 8 +- 23 files changed, 507 insertions(+), 194 deletions(-) create mode 100644 website/src/img/new/f_droid_icon.svg diff --git a/website/langs/ar.json b/website/langs/ar.json index 39d72effc2..92d7ddb72a 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -261,7 +261,13 @@ "about-and-contact-us": "عن واتصل بنا", "index-hero-h1": "كن
    حراً", "index-hero-h2": "في شبكتك", - "index-hero-p1": "أول شبكة بدون معرّفات مستخدمين.
    أنت تملك جهات اتصالك ومجموعاتك وقنواتك.", + "index-hero-p1": "شبكة المراسلة الوحيدة بدون أي معرّفات للمستخدمين.
    أنت تملك جهات اتصالك ومجموعاتك وقنواتك.", + "index-hero-own-part": "امتلك جزءًا منها.", + "index-hero-invest": "استثمر في SimpleX Chat.", + "index-hero-downloads": "تنزيلات", + "index-hero-monthly-users": "مستخدمون شهريًا", + "index-hero-countdown-lead": "إطلاق نطاقات SimpleX", + "index-hero-countdown-done": "احصل على نطاق SimpleX", "index-hero-download-desktop-btn-title": "نزّل تطبيق سطح مكتب SimpleX", "index-testflight-title": "إصدار SimpleX التجريبي ل iOS على TestFlight", "index-f-droid-title": "تطبيق SimpleX من خلال F-Droid", @@ -296,7 +302,7 @@ "index-roadmap-3-title": "اجعل مجتمعاتك تنمو", "index-roadmap-3-desc": "أدوات لتعزيز مجتمعاتك", "index-directory-h2": "انضم إلى مجتمعات SimpleX", - "index-directory-p1": "أكثر من 2 مليون شخص قاموا بتنزيل تطبيقات SimpleX.", + "index-directory-p1": "أكثر من 3 ملايين شخص قاموا بتنزيل تطبيقات SimpleX.", "index-directory-p2": "ابحث عن قنواتك ومجتمعاتك في الدليل وأنشئ قنواتك الخاصة!", "index-directory-cta": "اعرض دليل SimpleX", "index-directory-users-group-title": "مجموعة مستخدمي SimpleX", diff --git a/website/langs/cs.json b/website/langs/cs.json index 0df72eeab9..800578e2c4 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -262,7 +262,13 @@ "navbar-token": "Token", "index-hero-h1": "
    Žijte
    svobodně", "index-hero-h2": "Ve Své Síti", - "index-hero-p1": "První síť bez uživatelských ID.
    Vaše kontakty, skupiny a kanály patří vám.", + "index-hero-p1": "Jediná síť pro zasílání zpráv bez jakýchkoli uživatelských identifikátorů.
    Vaše kontakty, skupiny a kanály patří vám.", + "index-hero-own-part": "Vlastněte její část.", + "index-hero-invest": "Investujte do SimpleX Chat.", + "index-hero-downloads": "Stažení", + "index-hero-monthly-users": "Uživatelů měsíčně", + "index-hero-countdown-lead": "Spuštění domén SimpleX", + "index-hero-countdown-done": "Získejte doménu SimpleX", "index-hero-download-desktop-btn-title": "Stáhněte si desktopovou aplikaci SimpleX", "index-testflight-title": "Beta verze SimpleX pro iOS na TestFlight", "index-f-droid-title": "Stáhnout aplikaci SimpleX přes F-Droid", @@ -300,7 +306,7 @@ "index-roadmap-3-title": "Pomozte své komunitě růst", "index-roadmap-3-desc": "Nástroje na podporu vašich komunit", "index-directory-h2": "Zapojte se do komunit SimpleX", - "index-directory-p1": "Více než 2 miliony lidí si stáhly aplikace SimpleX.", + "index-directory-p1": "Více než 3 miliony lidí si stáhly aplikace SimpleX.", "index-directory-p2": "Najděte své kanály a komunity v katalogu a vytvořte si vlastní!", "index-directory-cta": "Zobrazit katalog SimpleX", "index-directory-users-group-title": "Uživatelské skupiny SimpleX", diff --git a/website/langs/de.json b/website/langs/de.json index 34cecb8e4d..0f144010ea 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -261,7 +261,12 @@ "directory": "Verzeichnis", "index-hero-h1": "Sei
    frei", "index-hero-h2": "In Deinem Netzwerk", - "index-hero-p1": "Das erste Netzwerk ohne Benutzer-IDs.
    Ihre Kontakte, Gruppen und Kanäle gehören Ihnen.", + "index-hero-p1": "Das einzige Messaging-Netzwerk ohne jegliche Benutzerkennungen.
    Ihre Kontakte, Gruppen und Kanäle gehören Ihnen.", + "index-hero-own-part": "Besitzen Sie einen Teil davon.", + "index-hero-downloads": "Downloads", + "index-hero-monthly-users": "Monatliche Nutzer", + "index-hero-countdown-lead": "Start der SimpleX-Domains", + "index-hero-countdown-done": "SimpleX-Domain sichern", "index-hero-download-desktop-btn-title": "Download der SimpleX Desktop-App", "index-testflight-title": "Öffentlicher iOS-Preview auf TestFlight", "index-f-droid-title": "SimpleX-App über das F-Droid-Repository", @@ -299,7 +304,7 @@ "index-roadmap-3-title": "Lassen Sie Ihre Communitys wachsen", "index-roadmap-3-desc": "Tools zur Förderung Ihrer Communitys", "index-directory-h2": "Treten Sie SimpleX-Communitys bei", - "index-directory-p1": "Mehr als 2 Millionen Nutzer haben schon SimpleX-Apps heruntergeladen.", + "index-directory-p1": "Mehr als 3 Millionen Nutzer haben schon SimpleX-Apps heruntergeladen.", "index-directory-p2": "Finden Sie Kanäle und Communitys im Verzeichnis oder erstellen Sie Ihre Eigenen!", "index-directory-cta": "SimpleX-Verzeichnis anzeigen", "index-directory-users-group-title": "SimpleX-Nutzergruppe", diff --git a/website/langs/en.json b/website/langs/en.json index 6138b34c8c..1293d83aff 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -255,6 +255,7 @@ "stable-and-beta-versions-built-by-developers": "Stable and beta versions built by the developers", "f-droid-page-simplex-chat-repo-section-text": "To add it to your F-Droid client, scan the QR code or use this URL:", "signing-key-fingerprint": "Signing key fingerprint (SHA-256)", + "f-droid-page-apk-text": "Or download the APK directly:", "f-droid-org-repo": "F-Droid.org repo", "stable-versions-built-by-f-droid-org": "Stable versions built by F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "The releases to this repo are done several days later", @@ -264,9 +265,14 @@ "please-use-link-in-mobile-app": "Please use the link in the mobile app", "index-hero-h1": "Be
    Free", "index-hero-h2": "In Your Network", - "index-hero-p1": "The first network without user IDs.
    You own your contacts, groups and channels.", + "index-hero-p1": "The only messaging network without any user identifiers.
    You own your contacts, groups and channels.", "index-hero-invest": "Invest in SimpleX Chat.", "index-hero-invest-cta": "Learn more on Wefunder.", + "index-hero-own-part": "Own a part of it.", + "index-hero-downloads": "Downloads", + "index-hero-monthly-users": "Monthly users", + "index-hero-countdown-lead": "SimpleX domains launch", + "index-hero-countdown-done": "Get SimpleX domain", "index-hero-download-desktop-btn-title": "Download SimpleX Desktop App", "index-testflight-title": "SimpleX iOS beta-release on TestFlight", "index-f-droid-title": "SimpleX app via F-Droid", @@ -304,7 +310,7 @@ "index-roadmap-3-title": "Make Your Communities Grow", "index-roadmap-3-desc": "Tools to promote your communities", "index-directory-h2": "Join SimpleX Communities", - "index-directory-p1": "More than 2 million people downloaded SimpleX apps.", + "index-directory-p1": "More than 3 million people downloaded SimpleX apps.", "index-directory-p2": "Find your channels and communities in directory and create your own!", "index-directory-cta": "View SimpleX Directory", "index-directory-users-group-title": "SimpleX users group", diff --git a/website/langs/es.json b/website/langs/es.json index 22b69cfbeb..1e9d4e6159 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -261,7 +261,12 @@ "about-and-contact-us": "Quiénes somos & Contacto", "index-hero-h1": "Sé
    Libre", "index-hero-h2": "En Tu Red", - "index-hero-p1": "La primera red sin IDs de usuario.
    Tus contactos, grupos y canales te pertenecen.", + "index-hero-p1": "La única red de mensajería sin ningún identificador de usuario.
    Tus contactos, grupos y canales te pertenecen.", + "index-hero-own-part": "Hazte dueño de una parte.", + "index-hero-downloads": "Descargas", + "index-hero-monthly-users": "Usuarios mensuales", + "index-hero-countdown-lead": "Lanzamiento de dominios SimpleX", + "index-hero-countdown-done": "Obtén un dominio SimpleX", "index-hero-download-desktop-btn-title": "Descargar SimpleX Desktop App", "index-testflight-title": "Betas SimpleX para iOS en TestFlight", "index-f-droid-title": "SimpleX app vía F-Droid", @@ -299,7 +304,7 @@ "index-roadmap-3-title": "Haz Crecer Tus Comunidades", "index-roadmap-3-desc": "Herramientas para la promoción", "index-directory-h2": "Únete a las Comunidades SimpleX", - "index-directory-p1": "Más de 2 millones de personas descargaron las aplicaciones SimpleX.", + "index-directory-p1": "Más de 3 millones de personas descargaron las aplicaciones SimpleX.", "index-directory-p2": "¡Encuentra tus canales y comunidades en el directorio y crea los tuyos!", "index-directory-cta": "Ir al Directorio SimpleX", "index-directory-users-group-title": "SimpleX users group", diff --git a/website/langs/fr.json b/website/langs/fr.json index 124c7ddc67..b53bd0a14e 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -263,7 +263,12 @@ "about-and-contact-us": "À propos & Contactez-nous", "index-hero-h1": "Soyez
    Libre", "index-hero-h2": "Dans Votre Réseau", - "index-hero-p1": "Le premier réseau sans identifiants d'utilisateur.
    Vos contacts, groupes et canaux vous appartiennent.", + "index-hero-p1": "Le seul réseau de messagerie sans aucun identifiant d'utilisateur.
    Vos contacts, groupes et canaux vous appartiennent.", + "index-hero-own-part": "Possédez-en une part.", + "index-hero-downloads": "Téléchargements", + "index-hero-monthly-users": "Utilisateurs mensuels", + "index-hero-countdown-lead": "Lancement des domaines SimpleX", + "index-hero-countdown-done": "Obtenez un domaine SimpleX", "index-hero-download-desktop-btn-title": "Téléchargez l’application de bureau SimpleX", "index-testflight-title": "Version bêta de SimpleX pour iOS sur TestFlight", "index-f-droid-title": "Application SimpleX via F-Droid", @@ -301,7 +306,7 @@ "index-token-cta-cf": "Investir dans SimpleX Chat", "index-token-p2": "Cela couvrira l'infrastructure, le développement logiciel et la gouvernance du réseau.", "index-directory-h2": "Rejoindre les communautés SimpleX", - "index-directory-p1": "Plus de 2 millions de personnes ont téléchargé les applications SimpleX.", + "index-directory-p1": "Plus de 3 millions de personnes ont téléchargé les applications SimpleX.", "index-directory-p2": "Trouvez vos chaînes et communautés dans l'annuaire et créez-en les vôtres !", "index-directory-cta": "Afficher l’annuaire SimpleX", "index-directory-users-group-title": "Groupe d'utilisateurs SimpleX", diff --git a/website/langs/hu.json b/website/langs/hu.json index 1b3a4f5869..fec3633ed9 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -260,7 +260,12 @@ "directory": "Csoportjegyzék", "about-and-contact-us": "Névjegy és kapcsolat", "index-hero-h1": "
    Legyen
    szabad
    ", - "index-hero-p1": "Az első hálózat felhasználói azonosítók nélkül.
    A saját partnerei, csoportjai és csatornái felett Ön rendelkezik.", + "index-hero-p1": "Az egyetlen üzenetküldő hálózat semmilyen felhasználói azonosító nélkül.
    A saját partnerei, csoportjai és csatornái felett Ön rendelkezik.", + "index-hero-own-part": "Legyen résztulajdonos.", + "index-hero-downloads": "Letöltés", + "index-hero-monthly-users": "Havi felhasználó", + "index-hero-countdown-lead": "A SimpleX domainek indulása", + "index-hero-countdown-done": "Szerezzen SimpleX domaint", "index-hero-download-desktop-btn-title": "SimpleX számítógépes alkalmazásának letöltése", "index-security-assessment-title": "Biztonsági auditok", "index-security-review-2022-title": "Biztonsági audit 2022", @@ -299,7 +304,7 @@ "index-roadmap-3-title": "Közösségek növelése", "index-roadmap-3-desc": "Eszközök biztosítása a közösségek népszerűsítéséhez", "index-directory-h2": "Csatlakozzon a SimpleX közösségekhez", - "index-directory-p1": "Több mint 2 millió ember töltötte le a SimpleX alkalmazások egyikét.", + "index-directory-p1": "Több mint 3 millió ember töltötte le a SimpleX alkalmazások egyikét.", "index-directory-p2": "Találja meg a kedvenc csatornáit és közösségeit a csoportjegyzékben, vagy hozza létre a sajátját!", "index-directory-cta": "SimpleX-csoportjegyzék megtekintése", "index-directory-users-group-title": "SimpleX felhasználók csoportja", diff --git a/website/langs/id.json b/website/langs/id.json index b1134bcaff..a88a5aaff3 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -261,7 +261,13 @@ "get-simplex": "Dapatkan aplikasi desktop SimpleX", "index-hero-h1": "
    Bebaslah
    ", "index-hero-h2": "Di Jaringan Anda", - "index-hero-p1": "Jaringan pertama tanpa ID pengguna.
    Anda pemilik kontak, grup, dan kanal Anda.", + "index-hero-p1": "Satu-satunya jaringan pesan tanpa pengenal pengguna apa pun.
    Anda pemilik kontak, grup, dan kanal Anda.", + "index-hero-own-part": "Miliki sebagian darinya.", + "index-hero-invest": "Berinvestasi di SimpleX Chat.", + "index-hero-downloads": "Unduhan", + "index-hero-monthly-users": "Pengguna bulanan", + "index-hero-countdown-lead": "Peluncuran domain SimpleX", + "index-hero-countdown-done": "Dapatkan domain SimpleX", "index-hero-download-desktop-btn-title": "Unduh Aplikasi Desktop SimpleX", "index-testflight-title": "Pratinjau iOS publik di TestFlight", "index-f-droid-title": "Aplikasi SimpleX via F-Droid", @@ -299,7 +305,7 @@ "index-roadmap-3-title": "Buat Komunitas Anda Meningkat", "index-roadmap-3-desc": "Alat untuk promosikan komunitas Anda", "index-directory-h2": "Gabung ke Komunitas SimpleX", - "index-directory-p1": "Lebih dari 2 juta orang telah mengunduh aplikasi SimpleX.", + "index-directory-p1": "Lebih dari 3 juta orang telah mengunduh aplikasi SimpleX.", "index-directory-p2": "Temukan kanal dan komunitas Anda di direktori dan buat milik Anda sendiri!", "index-directory-cta": "Lihat Direktori SimpleX", "index-directory-users-group-title": "Grup pengguna SimpleX", diff --git a/website/langs/it.json b/website/langs/it.json index 649637b8e2..013b3be7f0 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -260,7 +260,12 @@ "about-and-contact-us": "Informazioni e contatti", "directory": "Directory", "index-hero-h2": "nella tua rete", - "index-hero-p1": "La prima rete senza ID utente.
    I tuoi contatti, gruppi e canali sono tuoi.", + "index-hero-p1": "L'unica rete di messaggistica senza alcun identificatore utente.
    I tuoi contatti, gruppi e canali sono tuoi.", + "index-hero-own-part": "Possiedine una parte.", + "index-hero-downloads": "Download", + "index-hero-monthly-users": "Utenti mensili", + "index-hero-countdown-lead": "Lancio dei domini SimpleX", + "index-hero-countdown-done": "Ottieni un dominio SimpleX", "index-hero-download-desktop-btn-title": "Scarica l'app desktop di SimpleX", "index-testflight-title": "Anteprima pubblica per iOS su TestFlight", "index-f-droid-title": "SimpleX via F-Droid", @@ -299,7 +304,7 @@ "index-roadmap-3-title": "Fare crescere le tue comunità", "index-roadmap-3-desc": "Strumenti per promuovere le tue comunità", "index-directory-h2": "Unisciti alle comunità di SimpleX", - "index-directory-p1": "Più di 2 milioni di persone hanno scaricato le app SimpleX.", + "index-directory-p1": "Più di 3 milioni di persone hanno scaricato le app SimpleX.", "index-directory-p2": "Trova i tuoi canali e le comunità nella directory e creane di tuoi!", "index-directory-cta": "Vedi la directory di SimpleX", "index-directory-users-group-title": "Gruppo utenti SimpleX", diff --git a/website/langs/ja.json b/website/langs/ja.json index 4c850853e4..af2e29e26f 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -261,7 +261,13 @@ "about-and-contact-us": "概要・お問い合わせ", "index-hero-h1": "自由で
    あれ", "index-hero-h2": "あなたのネットワークで", - "index-hero-p1": "ユーザーIDのない世界初のネットワーク。
    連絡先、グループ、チャンネルはあなたのものです。", + "index-hero-p1": "ユーザー識別子を一切持たない唯一のメッセージングネットワーク。
    連絡先、グループ、チャンネルはあなたのものです。", + "index-hero-own-part": "その一部を所有しよう。", + "index-hero-invest": "SimpleX Chatに投資しよう。", + "index-hero-downloads": "ダウンロード", + "index-hero-monthly-users": "月間ユーザー", + "index-hero-countdown-lead": "SimpleXドメイン公開", + "index-hero-countdown-done": "SimpleXドメインを取得", "index-hero-download-desktop-btn-title": "SimpleX デスクトップアプリをダウンロード", "index-security-assessment-title": "セキュリティ監査", "index-security-review-2022-title": "セキュリティ監査 2022", @@ -291,7 +297,7 @@ "index-roadmap-3-title": "コミュニティの成長", "index-roadmap-3-desc": "コミュニティを広げるツール", "index-directory-h2": "SimpleXコミュニティに参加する", - "index-directory-p1": "200万人以上がSimpleXアプリをダウンロードしました。", + "index-directory-p1": "300万人以上がSimpleXアプリをダウンロードしました。", "index-directory-p2": "ディレクトリでチャンネルやコミュニティを見つけて、あなた自身のものを作成しましょう!", "index-directory-cta": "SimpleXディレクトリを見る", "index-directory-users-group-title": "SimpleXユーザグループ", diff --git a/website/langs/pl.json b/website/langs/pl.json index a760dc34cf..90690c2c1c 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -262,7 +262,13 @@ "about-and-contact-us": "O nas i Kontakt", "index-hero-h1": "Bądź
    Wolny", "index-hero-h2": "W Swojej Sieci", - "index-hero-p1": "Pierwsza sieć bez identyfikatorów użytkowników.
    Twoje kontakty, grupy i kanały należą do Ciebie.", + "index-hero-p1": "Jedyna sieć komunikacyjna bez żadnych identyfikatorów użytkowników.
    Twoje kontakty, grupy i kanały należą do Ciebie.", + "index-hero-own-part": "Miej w niej udział.", + "index-hero-invest": "Zainwestuj w SimpleX Chat.", + "index-hero-downloads": "Pobrań", + "index-hero-monthly-users": "Użytkowników miesięcznie", + "index-hero-countdown-lead": "Start domen SimpleX", + "index-hero-countdown-done": "Zdobądź domenę SimpleX", "index-hero-download-desktop-btn-title": "Pobierz Aplikację SimpleX na Komputer", "index-testflight-title": "SimpleX iOS beta-wydanie na TestFlight", "index-f-droid-title": "SimpleX app z F-Droid", @@ -300,7 +306,7 @@ "index-roadmap-3-title": "Spraw, aby Twoje społeczności Rosły", "index-roadmap-3-desc": "Narzędzia do promowania Twoich społeczności", "index-directory-h2": "Dołącz do Społeczności SimpleX", - "index-directory-p1": "Ponad 2 miliony osób pobrało aplikacje SimpleX.", + "index-directory-p1": "Ponad 3 miliony osób pobrało aplikacje SimpleX.", "index-directory-p2": "Znajdź swoje kanały i społeczności w katalogu i stwórz własne!", "index-directory-cta": "Zobacz katalog SimpleX", "index-directory-users-group-title": "Grupa użytkowników SimpleX", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 4b663e75ec..22f7a1dc45 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -262,7 +262,13 @@ "navbar-token": "Token", "index-hero-h1": "Seja
    Livre", "index-hero-h2": "Na Sua Rede", - "index-hero-p1": "A primeira rede sem IDs de usuário.
    Seus contatos, grupos e canais pertencem a você.", + "index-hero-p1": "A única rede de mensagens sem nenhum identificador de usuário.
    Seus contatos, grupos e canais pertencem a você.", + "index-hero-own-part": "Seja dono de uma parte dela.", + "index-hero-invest": "Invista no SimpleX Chat.", + "index-hero-downloads": "Downloads", + "index-hero-monthly-users": "Usuários mensais", + "index-hero-countdown-lead": "Lançamento de domínios SimpleX", + "index-hero-countdown-done": "Obtenha um domínio SimpleX", "index-hero-download-desktop-btn-title": "Baixe o aplicativo SimpleX Desktop", "index-testflight-title": "SimpleX iOS - versão beta no TestFlight", "index-f-droid-title": "Aplicativo SimpleX no F-Droid", @@ -300,7 +306,7 @@ "index-roadmap-3-title": "Faça Suas Comunidades Crescerem", "index-roadmap-3-desc": "Ferramentas para promover suas comunidades", "index-directory-h2": "Participe das Comunidades SimpleX", - "index-directory-p1": "Mais de 2 milhões de pessoas baixaram os aplicativos SimpleX.", + "index-directory-p1": "Mais de 3 milhões de pessoas baixaram os aplicativos SimpleX.", "index-directory-p2": "Encontre seus canais e comunidades no diretório e crie os seus próprios!", "index-directory-cta": "Ver diretório do SimpleX", "how-secure-comparison-title": "Comparação da segurança da criptografia de ponta a ponta em diferentes mensageiros", diff --git a/website/langs/ru.json b/website/langs/ru.json index a8bdfa8725..687e5481b2 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -261,7 +261,13 @@ "docs-dropdown-14": "SimpleX для бизнеса", "index-hero-h1": "
    Будь
    Свободен
    ", "index-hero-h2": "В Своей Сети", - "index-hero-p1": "Первая сеть без идентификаторов пользователей.
    Ваши контакты, группы и каналы принадлежат Вам.", + "index-hero-p1": "Единственная сеть обмена сообщениями без идентификаторов пользователей.
    Ваши контакты, группы и каналы принадлежат Вам.", + "index-hero-own-part": "Владейте её частью.", + "index-hero-invest": "Инвестируйте в SimpleX Chat.", + "index-hero-downloads": "Загрузок", + "index-hero-monthly-users": "Пользователей в месяц", + "index-hero-countdown-lead": "Запуск доменов SimpleX", + "index-hero-countdown-done": "Получите домен SimpleX", "index-hero-download-desktop-btn-title": "Загрузить приложение SimpleX для компьютера", "index-testflight-title": "Бета-релиз для iOS на TestFlight", "index-f-droid-title": "Загрузить через F-Droid", @@ -299,7 +305,7 @@ "index-roadmap-3-title": "Поддержка роста Ваших групп", "index-roadmap-3-desc": "Инструменты для продвижения групп", "index-directory-h2": "Вступайте в Группы SimpleX", - "index-directory-p1": "Более 2 миллионов человек скачали приложения SimpleX.", + "index-directory-p1": "Более 3 миллионов человек скачали приложения SimpleX.", "index-directory-p2": "Найдите каналы и сообщества в каталоге и создайте свои!", "index-directory-cta": "Открыть каталог SimpleX", "index-directory-users-group-title": "Группа пользователей SimpleX", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 45cee06f5a..ac7afb074e 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -296,7 +296,7 @@ "index-roadmap-3-title": "促进社区发展", "index-roadmap-3-desc": "用于推广社区的工具", "index-directory-h2": "加入 SimpleX 社区", - "index-directory-p1": "超过 200 万人下载了 SimpleX 应用。", + "index-directory-p1": "超过 300 万人下载了 SimpleX 应用。", "index-directory-p2": "在目录中找到您的频道和社区,并创建您自己的!", "index-directory-cta": "查看 SimpleX 目录", "index-directory-users-group-title": "SimpleX 用户群组", diff --git a/website/src/crowdfunding.html b/website/src/crowdfunding.html index 3b0b293f02..11d28742b9 100644 --- a/website/src/crowdfunding.html +++ b/website/src/crowdfunding.html @@ -65,7 +65,7 @@ templateEngineOverride: njk --cf-area-off: calc((var(--sec-h) - 100svh) / 2); } - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { :root { --cf-grad-light: linear-gradient(30deg, #e8f3ff 0%, #c0e2ff 40%, #e9ffff 80%, #ffefd6 90%); --cf-grad-dark: linear-gradient(30deg, #000000 0%, #131d49 52%, #3f5598 65%, #c3faff 85%, #fff6e0 90%); @@ -379,7 +379,7 @@ templateEngineOverride: njk .cf-pill svg { height: 30px; width: auto; display: block; transform: translateY(-3.5px); } - @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) { .cf-pill { width: 252px; justify-content: center; } } @@ -389,7 +389,7 @@ templateEngineOverride: njk .cf-cta-link:hover, .cf .text-container a.gradient-text:hover { filter: brightness(1.12); } - @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) { .cf-network .text-container h2 { max-width: calc(var(--sec-vwu) * 23.5) !important; } .cf-network .text-container p { max-width: calc(var(--sec-vwu) * 31) !important; } .cf-network .text-container a { max-width: calc(var(--sec-vwu) * 31) !important; } @@ -429,7 +429,7 @@ templateEngineOverride: njk #navbar.on-light > a.logo.logo-light { display: flex; } #navbar.on-light > a.logo.logo-dark { display: none; } - @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) { header#navbar { height: 74px; } header#navbar > a.logo { top: 0; @@ -443,7 +443,7 @@ templateEngineOverride: njk } /* keep the navbar visible on mobile (no slide-in menu here) */ - @media screen and (max-width: 959px) { + @media screen and (max-width: 1015px) { header#navbar { visibility: visible !important; opacity: 1 !important; @@ -451,7 +451,7 @@ templateEngineOverride: njk background: transparent !important; } } - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { header#navbar { height: 54px !important; } header#navbar > a.logo { top: 0 !important; height: 100% !important; align-items: center; transform: translateY(var(--cf-nav-shift)); } [dir="ltr"] header#navbar > a.logo { padding-left: var(--cf-text-pad); } @@ -459,7 +459,7 @@ templateEngineOverride: njk } /* ---- mobile: portrait art on top, text at the bottom (home page system) ---- */ - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { .cf .text-container h2 { font-size: calc(var(--sec-vwu) * 9.4); } .cf .text-container { margin-left: 0 !important; @@ -751,15 +751,15 @@ templateEngineOverride: njk .cf-tag .n { font-weight: 700; font-size: calc(var(--sec-vwu) * 1.5); line-height: 1.35; } .cf-tag .v { font-weight: 500; font-size: calc(var(--sec-vwu) * 1.2); line-height: 1.45; } .cf-tag .v b { font-weight: 700; } - @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) { .cf-tags-art, .cf-art { display: none; } } - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { .cf-tag .n { font-size: 3.54cqh; line-height: 1.3; } } - @media screen and (max-width: 959px) and (orientation: portrait) and (max-aspect-ratio: 62/100) { + @media screen and (max-width: 1015px) and (orientation: portrait) and (max-aspect-ratio: 62/100) { .cf-hero h1 { font-size: calc(var(--sec-vwu) * 9.6); } :root { --cf-hero-text-b: calc(var(--cf-top-base) + max(0px, calc(-1 * var(--cf-area-off))) + 72.8vw); } } @@ -800,7 +800,7 @@ templateEngineOverride: njk .cf-fine-links a { color: #9fb2d8; text-decoration: none; } .cf-fine-links a:hover { color: #ffffff; } - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { /* padding-bottom comes from .safari-ios section.page, which a fit-content footer does not need */ section.page.cf.cf-footer { height: fit-content; min-height: fit-content; padding-bottom: 0; } @@ -935,7 +935,7 @@ templateEngineOverride: njk .cf-modal-panel { padding: 28px 20px; } .cf-form-submit { padding: 0 12px; font-size: 15px; } } - @media screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (max-width: 1015px) and (orientation: landscape) { :root { --cover-bg-w: calc(min((3200 / 1920) * 100vw, (3200 / 1080) * 100svh)); --cover-bg-h: calc(min((1800 / 1920) * 100vw, (1800 / 1080) * 100svh)); @@ -1322,7 +1322,7 @@ templateEngineOverride: njk var hero = block.closest('section'); var leadShort = document.querySelector('.cf-hero .lead-short'); var logos = document.querySelectorAll('#navbar .logo img'); - var narrow = window.matchMedia('(max-width: 959px) and (orientation: portrait)'); + var narrow = window.matchMedia('(max-width: 1015px) and (orientation: portrait)'); function logoBottom() { var bottom = 0; for (var i = 0; i < logos.length; i++) { var r = logos[i].getBoundingClientRect(); if (r.height > 0) bottom = Math.max(bottom, r.bottom); } diff --git a/website/src/css/design3-nav.css b/website/src/css/design3-nav.css index f6f91a8567..1e3883caaa 100644 --- a/website/src/css/design3-nav.css +++ b/website/src/css/design3-nav.css @@ -10,7 +10,7 @@ body.change-nav-color { --nav-color: #001796; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { .logo { background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); @@ -182,7 +182,7 @@ header#navbar ul a { white-space: nowrap; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { header#navbar ul .nav-link a, header#navbar ul .nav-link-text { font-size: 18px; @@ -258,7 +258,7 @@ header#navbar ul a span svg { fill: #fff; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { header#navbar nav#menu>ul>li>a { color: var(--nav-color); } @@ -366,7 +366,7 @@ header#navbar .nav-link:focus-within .sub-menu { fill: white; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { .flag-container>a { color: var(--nav-color) !important; } @@ -427,7 +427,7 @@ header#navbar button.theme-switch-btn svg path { fill: white; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { header#navbar button.theme-switch-btn svg path { fill: var(--nav-color) !important; transition: all 0.3s ease; @@ -497,7 +497,7 @@ button#cross-btn { height: auto; } -@media (min-width: 960px) { +@media (min-width: 1016px) { @media (max-width: 1050px) { header#navbar nav#menu .nav-link.send-file { display: none; @@ -505,7 +505,7 @@ button#cross-btn { } } -@media (max-width: 959px) { +@media (max-width: 1015px) { #mobile-header { position: fixed; top: 0; diff --git a/website/src/css/design3.css b/website/src/css/design3.css index 4b645804eb..6ebbf92198 100644 --- a/website/src/css/design3.css +++ b/website/src/css/design3.css @@ -171,7 +171,7 @@ #fff6e0 90%); } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { :root { --bg-grad: linear-gradient(30deg, #e8f3ff 0%, @@ -225,7 +225,7 @@ body { background: #0a0f2b; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { html { overscroll-behavior: none; } @@ -364,55 +364,104 @@ section.page .area { /* --- COVER --- */ section.cover { + --cover-area-off: calc((var(--sec-h) - 100svh) / 2); + --cover-fade-rgb: 33, 70, 110; background-image: url("/img/design_3/cover-light.webp"); background-size: var(--cover-bg-w) var(--cover-bg-h); - background-position: center; + background-position: calc(50% + var(--sec-vwu) * 8.5) center; background-repeat: no-repeat; + overflow: hidden; z-index: 12; } .dark section.cover { + --cover-fade-rgb: 2, 7, 29; background-image: url("/img/design_3/cover.webp"); } .chrome-ios section.cover, .safari-ios section.cover { - background-position: center calc(50% - calc(110lvh - 100svh) / 2); + --cover-bottom-ext: calc(110lvh - 100svh); + background-position: calc(50% + var(--sec-vwu) * 8.5) calc(50% - calc(110lvh - 100svh) / 2); } .chrome-android section.cover { - background-position: center calc(50% - calc(100lvh - 100svh) / 2); + --cover-bottom-ext: calc(100lvh - 100svh); + background-position: calc(50% + var(--sec-vwu) * 8.5) calc(50% - calc(100lvh - 100svh) / 2); +} + +.cover-art { + position: absolute; + width: var(--sec-w); + height: var(--sec-h); + z-index: 1; + pointer-events: none; +} + +.cover-phone { + position: absolute; + right: calc(var(--sec-vwu) * 4); + bottom: calc(var(--sec-vhu) * -55.56); + height: calc(var(--sec-vhu) * 129.96); + width: auto; +} + +.cover-fade { + position: absolute; + right: calc(var(--sec-vwu) * 4); + width: calc(var(--sec-vhu) * 129.96 * 0.4914); + bottom: var(--cover-area-off); + height: calc(var(--sec-vhu) * 38); + background: linear-gradient(to top, rgb(var(--cover-fade-rgb)) 22%, rgba(var(--cover-fade-rgb), .8) 52%, rgba(var(--cover-fade-rgb), 0) 100%); } section.cover div.content { display: flex; flex-direction: column; justify-content: center; - align-items: center; - text-align: center; + align-items: flex-start; + text-align: start; gap: calc(var(--sec-vhu) * 3.5); width: var(--sec-w); height: var(--sec-h); + padding-left: calc(var(--sec-vwu) * 4); +} + +@media (min-width: 1016px), (max-width: 1015px) and (orientation: landscape) { + [dir="rtl"] section.cover div.content { + padding-left: 0; + padding-right: calc(var(--sec-vwu) * 4); + } + + [dir="rtl"] .cover-phone, + [dir="rtl"] .cover-fade { + left: calc(var(--sec-vwu) * 4); + right: auto; + } + + [dir="rtl"] .cover-stats { + left: 30px; + right: auto; + } } section.cover div.content h1 { font-family: "GT-Walsheim", sans-serif; font-weight: 700; - font-size: calc(var(--sec-vwu) * 11.7); + font-size: calc(var(--sec-vwu) * 10); line-height: 0.9; letter-spacing: -0.025em; color: #ffffff; - text-align: center; } section.cover div.content h1 .small { - font-size: calc(var(--sec-vwu) * 8); + font-size: calc(var(--sec-vwu) * 6.85); line-height: 1; margin-bottom: -10px; } section.cover div.content h1 .medium { - font-size: calc(var(--sec-vwu) * 10); + font-size: calc(var(--sec-vwu) * 8.55); line-height: 1; margin-bottom: -10px; } @@ -431,7 +480,12 @@ section.cover div.content p { font-size: calc(var(--sec-vwu) * 2.14); align-items: center; color: #ffffff; - max-width: calc(var(--sec-vwu) * 53); + max-width: calc(var(--sec-vwu) * 55); + text-wrap: balance; +} + +section.cover div.content p .nowrap { + white-space: nowrap; } section.cover div.content p.invest { @@ -444,39 +498,65 @@ section.cover div.content p.invest a { white-space: nowrap; } -.publications-btns { +html:not(.dark) section.cover div.content p { + font-weight: 300; + max-width: calc(var(--sec-vwu) * 55.2); +} + +html:not(.dark) section.cover div.content p.invest { + font-weight: 400; +} + +html:not(.dark) section.cover div.content p.invest a { + font-weight: 600; +} + +html:not(.dark) section.cover div.content .gradient-text { + background: linear-gradient(90deg, + #ffb55d 0%, + #fff494 50%, + #fbffdd 100%); + -webkit-background-clip: text; + background-clip: text; +} + +.cover-stats { position: absolute; - bottom: 24px; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 12px; -} - -[dir="ltr"] .publications-btns { - left: 30px; -} - -[dir="rtl"] .publications-btns { right: 30px; + bottom: calc(74px + var(--cover-bottom-ext, 0px)); + display: flex; + align-items: center; + gap: 48px; } -.publications-btns img { - width: 32px; +.cover-stat { + display: flex; + align-items: center; + gap: 13px; } -.publications-btns img.kuketz { - width: 38px; +.cover-stat b { + font-family: "Manrope", sans-serif; + font-weight: 700; + font-size: 37px; + line-height: 1.1; + color: #ffffff; } -.publications-btns img.whonix { - width: 30px; +.cover-stat span { + font-family: "Manrope", sans-serif; + font-weight: 400; + font-size: 18px; + color: #ffffff; +} + +.dark .cover-stat span { + color: #dfeaff; } .security-btns { position: absolute; - bottom: 24px; + bottom: calc(24px + var(--cover-bottom-ext, 0px)); display: flex; flex-wrap: wrap; align-items: center; @@ -485,13 +565,11 @@ section.cover div.content p.invest a { } [dir="ltr"] .security-btns { - left: 0; - transform: translateX(calc((100vw / 2) - 50%)); + left: 30px; } [dir="rtl"] .security-btns { - right: 0; - transform: translateX(calc(-1 * ((100vw / 2) - 50%))); + right: 30px; } .security-btns img { @@ -514,26 +592,9 @@ section.cover div.content p.invest a { text-align: right; } -@media screen and (max-width: 1279px) { - .publications-btns { - display: none; - visibility: hidden; - } - - [dir="ltr"] .security-btns { - transform: translateX(0); - left: 16px; - } - - [dir="rtl"] .security-btns { - transform: translateX(0); - right: 16px; - } -} - .socials { position: absolute; - bottom: 22px; + bottom: calc(22px + var(--cover-bottom-ext, 0px)); display: flex; align-items: center; justify-content: center; @@ -541,7 +602,7 @@ section.cover div.content p.invest a { flex-wrap: wrap; } -@media (min-width: 960px) { +@media (min-width: 1016px) { .socials .desktop-app-btn, .socials .apple-store-btn, .socials .google-play-btn { @@ -603,14 +664,7 @@ section.cover div.content p.invest a { gap: 6px; } -@media screen and (max-width: 959px) { - - .publications-btns, - .security-btns, - .socials { - position: static; - } - +@media screen and (max-width: 1015px) and (orientation: portrait) { .socials { min-height: 42px; } @@ -722,7 +776,7 @@ main .section-bg { background-image: url("/img/design_3/section-5-desktop.webp"); } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { .page-2 { background-image: url("/img/design_3/section-2-mobile-light.webp"); background-size: auto calc(var(--cover-bg-h) * 0.84); @@ -967,41 +1021,154 @@ main .section-bg { display: none; } -@media (max-width: 959px) { - section.cover div.content { - gap: calc(var(--sec-vhu) * 2.5); - transform: translateY(calc(var(--sec-vhu) * 3)); +@media (max-width: 1015px) and (orientation: portrait) { + section.cover { + --cover-crest: 10px; + --cover-text-top: calc(var(--cover-crest) + var(--cover-bg-w) * 0.0125); + --cover-text-b: calc(var(--cover-text-top) + var(--cover-bg-w) * 0.19); + --cover-phone-w: 82vw; + --cover-phone-top: calc(var(--cover-text-b) + 16px + var(--cover-area-off)); + --cover-cta-bottom: max(12px, env(safe-area-inset-bottom, 0px)); + --cover-audits-top: calc(100svh - var(--cover-cta-bottom) - 36px); + --cover-socials-top: calc(var(--cover-audits-top) - 66px); + --cover-row-gap: clamp(14px, 4.5vw, 20px); + --cover-row-start: calc(50% - (var(--cover-socials-w, 165px) + var(--cover-row-gap) + var(--cover-stats-w, 141px)) / 2); + --cover-fade-ext: max(0px, calc(110lvh - 100svh)); + --cover-fade-dyn: max(0px, calc(110lvh - 100dvh)); + --cover-fade-base: calc(var(--cover-fade-dyn) + var(--cover-cta-bottom) + 114px); + --cover-fade-ramp: 48px; } - .publications-btns, - .security-btns, - .socials { - margin-top: calc(var(--sec-vhu) * 2); + section.cover, + .chrome-ios section.cover, + .safari-ios section.cover, + .chrome-android section.cover { + background-position: 50% calc(var(--cover-crest) - var(--cover-bg-h) * 0.2678); + } + + .cover-phone { + left: 50%; + right: auto; + transform: translateX(-50%); + top: var(--cover-phone-top); + bottom: auto; + width: var(--cover-phone-w); + height: auto; + } + + .cover-fade { + left: 50%; + right: auto; + transform: translateX(-50%); + width: 100vw; + bottom: calc(var(--cover-area-off) - var(--cover-fade-ext)); + height: calc(var(--cover-fade-base) + var(--cover-fade-ramp)); + background: linear-gradient(to top, rgba(var(--cover-fade-rgb), .9) var(--cover-fade-base), rgba(var(--cover-fade-rgb), 0) 100%); + } + + [dir="ltr"] .socials { + top: var(--cover-socials-top); + bottom: auto; + left: var(--cover-row-start); + right: auto; + } + + [dir="rtl"] .socials { + top: var(--cover-socials-top); + bottom: auto; + left: auto; + right: var(--cover-row-start); + } + + [dir="ltr"] .cover-stats { + top: var(--cover-socials-top); + bottom: auto; + left: calc(var(--cover-row-start) + var(--cover-socials-w, 165px) + var(--cover-row-gap)); + right: auto; + } + + [dir="rtl"] .cover-stats { + top: var(--cover-socials-top); + bottom: auto; + left: auto; + right: calc(var(--cover-row-start) + var(--cover-socials-w, 165px) + var(--cover-row-gap)); + } + + [dir="ltr"] .security-btns, + [dir="rtl"] .security-btns { + top: var(--cover-audits-top); + bottom: auto; + left: 0; + right: 0; + } +} + +@media (max-width: 1015px) and (orientation: landscape) { + section.cover { + --cover-bg-w: calc(min((3200 / 1920) * 100vw, (3200 / 1080) * 100svh)); + --cover-bg-h: calc(min((1800 / 1920) * 100vw, (1800 / 1080) * 100svh)); + --sec-w: calc(min(100vw, (1920 / 1080) * 100svh)); + --sec-h: calc(min((1080 / 1920) * 100vw, 100svh)); + --sec-vwu: calc(var(--sec-w) / 100); + --sec-vhu: calc(var(--sec-h) / 100); + } +} + +@media (max-width: 1015px) and (orientation: landscape) and (max-height: 500px) { + section.cover div.content h1 br { + display: none; + } + + .cover-stats { + gap: 24px; + } + + .cover-stat { + gap: 8px; + } + + .cover-stat b { + font-size: 24px; + } + + .cover-stat span { + font-size: 13px; + } +} + +@media (max-width: 1015px) and (orientation: portrait) { + section.cover div.content { + justify-content: flex-start; + align-items: center; + text-align: center; + gap: calc(var(--sec-vwu) * 3); + margin-top: min(0px, calc(var(--cover-text-top) + var(--cover-area-off))); + padding: max(0px, calc(var(--cover-text-top) + var(--cover-area-off))) 21.6px 0; } section.cover div.content h1 { line-height: 0.85; font-weight: 700; - font-size: calc(var(--sec-vwu) * 30); + font-size: calc(var(--sec-vwu) * 24.75); } section.cover div.content h1 .small { - font-size: calc(var(--sec-vwu) * 16); + font-size: calc(var(--sec-vwu) * 13.2); } section.cover div.content h1 .medium { - font-size: calc(var(--sec-vwu) * 24); + font-size: calc(var(--sec-vwu) * 19.8); } section.cover div.content h2 { font-weight: 500; - font-size: calc(var(--sec-vwu) * 12.85); + font-size: calc(var(--sec-vwu) * 10.6); } section.cover div.content p { font-weight: 400; - font-size: calc(var(--sec-vwu) * 5.5); - max-width: calc(var(--sec-vwu) * 93); + font-size: calc(var(--sec-vwu) * 4.1); + max-width: none; } section.cover div.content p.invest { @@ -1012,6 +1179,40 @@ main .section-bg { font-weight: 600; } + html:not(.dark) section.cover div.content p, + html:not(.dark) section.cover div.content p.invest { + font-weight: 500; + max-width: none; + } + + html:not(.dark) section.cover div.content p.invest a { + font-weight: 700; + } + + .cover-stats { + height: 42px; + gap: clamp(12px, 4vw, 16px); + } + + .cover-stat { + display: block; + text-align: start; + } + + .cover-stat b { + display: block; + font-size: clamp(19px, 5.6vw, 22px); + line-height: 1.05; + } + + .cover-stat span { + display: block; + font-size: clamp(10px, 2.8vw, 11px); + line-height: 1.25; + } +} + +@media (max-width: 1015px) { /* --- MAIN SECTIONS --- */ .page .text-container { justify-content: flex-end; @@ -1108,7 +1309,7 @@ main .section-bg { border-radius: calc(var(--sec-vhu) * 1.8); } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { .page-6 .group-images { width: 100%; height: 10%; @@ -1321,7 +1522,7 @@ main .section-bg { margin: calc(var(--sec-vhu) * 2); } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { .page-6 .group-images>a.group-image:nth-child(1) { --c: 3; --r: 1; @@ -1525,7 +1726,7 @@ section.page.footer { min-height: fit-content; } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { section.page.footer { height: 100lvh; min-height: 100lvh; @@ -1560,7 +1761,7 @@ section.page.footer { } } -@media (min-width: 960px) { +@media (min-width: 1016px) { .footer .container-md, .footer .container-sm, diff --git a/website/src/css/livestream.css b/website/src/css/livestream.css index 831f4a0569..bb8ab8d373 100644 --- a/website/src/css/livestream.css +++ b/website/src/css/livestream.css @@ -126,7 +126,7 @@ main .section-bg { color: #000000; } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { .livestream { background-image: url("/img/design_3/section-4-mobile-light.webp"); background-size: auto calc(var(--cover-bg-h) * 0.78); diff --git a/website/src/fdroid.html b/website/src/fdroid.html index 67e4821e20..c3441d9680 100644 --- a/website/src/fdroid.html +++ b/website/src/fdroid.html @@ -38,6 +38,11 @@ templateEngineOverride: njk 9F 35 8F F2 84 D1 F7 16 56 A2 BF AF 0E 00 5D EA E6 AA 14 14 37 20 E0 89 F1 1F F2 DD CF EB 01 BA

    +

    + {{ "f-droid-page-apk-text" | i18n({}, lang ) | safe }} + aarch64, + armv7 +