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/94] 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/94] 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/94] 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 f8c43bf0efe38873815bd808fc2dc7bde1972dda Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 3 Oct 2026 11:07:23 +0100 Subject: [PATCH 04/94] core: 7.1.0.10 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index fe7a8dfb07..9bd6019b1e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.1.0.9 +version: 7.1.0.10 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 51630d61ef001f45b34440ac4d540a02496c8ae3 Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Sat, 3 Oct 2026 12:05:28 +0000 Subject: [PATCH 05/94] 7.1-beta.6: android 386, desktop 167, ios 362 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++---------- 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 +-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 6a3d4898ee..ccbdaadc5c 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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.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 */; }; 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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.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 = ""; }; 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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.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 */, 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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */, ); path = Libraries; sourceTree = ""; @@ -2139,7 +2139,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2189,7 +2189,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2231,7 +2231,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2251,7 +2251,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2276,7 +2276,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2313,7 +2313,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2350,7 +2350,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2401,7 +2401,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2455,7 +2455,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2489,7 +2489,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index d5375046e0..6224646940 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.1-beta.5 -android.version_code=383 +android.version_name=7.1-beta.6 +android.version_code=386 -desktop.version_name=7.1-beta.5 -desktop.version_code=165 +desktop.version_name=7.1-beta.6 +desktop.version_code=167 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 8a9786d559..f80289d33b 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.5", + "version": "7.1.0-beta.6", "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 4fe1bad959..a278ee27dc 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.1.0-beta.5'; +const RELEASE_TAG = 'v7.1.0-beta.6'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); // A locally built libsimplex, copied into libs/ rather than loaded in place: // the addon's RUNPATH is $ORIGIN/../../libs. diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index a363489b3b..c6c24a1e59 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.1.0b5" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.5" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b6" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.1.0-beta.6" # simplex-chat-libs release tag (no 'v' prefix) From ec1effd5da7bd8884d367e271fed1d1226c5e385 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 3 Oct 2026 15:12:06 +0100 Subject: [PATCH 06/94] website: allowlist raster data URIs in previews (#7643) Co-authored-by: shum --- website/src/js/channel-preview.jsc | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index b65c781a85..2f88e885b9 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -745,8 +745,17 @@ const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent('`; +// image/jpg is not a registered MIME type, but SimpleX apps and core use it for JPEG images. +const DATA_IMAGE_PREFIXES = [ + 'data:image/jpg;base64,', + 'data:image/jpeg;base64,', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/webp;base64,', +]; + function isDataImage(src) { - return typeof src === 'string' && src.startsWith('data:image/'); + return typeof src === 'string' && DATA_IMAGE_PREFIXES.some(prefix => src.startsWith(prefix)); } function tailSvg() { From 1a969a6cb9a80cb08816c38b9345566c47353ebe Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 3 Oct 2026 16:56:58 +0100 Subject: [PATCH 07/94] core, ui: remove database backups after key changes (#7628) * core, ui: remove database backups after key changes * ui: delete database backups on chat start without delaying encryption marker reset Keeping encryptionStartedAt set until chat start made DatabaseErrorView show "The attempt to change database passphrase was not completed" with a Restore button after every successful passphrase change when the passphrase is not stored. Restore the marker handling from master and delete backups unconditionally after chat starts. * another flag --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 ++++ .../ios/Shared/Views/Database/DatabaseEncryptionView.swift | 3 +++ apps/ios/Shared/Views/Database/DatabaseView.swift | 4 ++++ apps/ios/Shared/Views/UserSettings/SettingsView.swift | 3 +++ apps/ios/SimpleXChat/FileUtils.swift | 7 +++++++ .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 7 +++++++ .../common/views/database/DatabaseEncryptionView.kt | 1 + .../chat/simplex/common/views/database/DatabaseView.kt | 5 +++++ src/Simplex/Chat/Archive.hs | 2 ++ tests/ChatTests/Direct.hs | 3 +++ 10 files changed, 39 insertions(+) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 742ef20688..af5f3c3ee9 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2361,6 +2361,10 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws ChatReceiver.shared.start() m.chatRunning = true chatLastStartGroupDefault.set(Date.now) + if shouldDeleteDatabaseBackupsDefault.get() { + deleteDatabaseBackups() + shouldDeleteDatabaseBackupsDefault.set(false) + } } func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? { diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index dbc25e536f..a52fdfd189 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -153,6 +153,9 @@ struct DatabaseEncryptionView: View { try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) } try await apiStorageEncryption(currentKey: currentKey, newKey: newKey) + if currentKey != newKey { + shouldDeleteDatabaseBackupsDefault.set(true) + } encryptionStartedDefault.set(false) initialRandomDBPassphraseGroupDefault.set(false) if migration { diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 051ed9f037..047a87cf71 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -453,6 +453,10 @@ struct DatabaseView: View { ChatReceiver.shared.start() chatLastStartGroupDefault.set(Date.now) AppChatState.shared.set(.active) + if shouldDeleteDatabaseBackupsDefault.get() { + deleteDatabaseBackups() + shouldDeleteDatabaseBackupsDefault.set(false) + } } catch let error { runChat.wrappedValue = false showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error)) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index d10c199e78..6cf9b5abe6 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -49,6 +49,7 @@ let DEFAULT_SHOULD_IMPORT_APP_SETTINGS = "shouldImportAppSettings" let DEFAULT_DEVELOPER_TOOLS = "developerTools" let DEFAULT_ENCRYPTION_STARTED = "encryptionStarted" let DEFAULT_ENCRYPTION_STARTED_AT = "encryptionStartedAt" +let DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS = "shouldDeleteDatabaseBackups" let DEFAULT_ACCENT_COLOR_RED = "accentColorRed" // deprecated, only used for migration let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for migration let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration @@ -207,6 +208,8 @@ let encryptionStartedDefault = BoolDefault(defaults: UserDefaults.standard, forK let encryptionStartedAtDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ENCRYPTION_STARTED_AT) +let shouldDeleteDatabaseBackupsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS) + let connectViaLinkTabDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CONNECT_VIA_LINK_TAB, withDefault: .scan) let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 69bf305147..1641541ef7 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -194,6 +194,13 @@ private func restoreBackupFile(fromPath: String, toPath: String) throws { try fm.copyItem(atPath: fromPath, toPath: toPath) } +public func deleteDatabaseBackups() { + let fm = FileManager.default + let dbPath = getAppDatabasePath().path + try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK) + try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK) +} + public func hasLegacyDatabase() -> Bool { hasDatabaseAtPath(getLegacyDatabasePath()) } 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 97e1f025f3..96515fbde2 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 @@ -32,6 +32,7 @@ import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat +import chat.simplex.common.views.database.deleteDatabaseBackups import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* @@ -215,6 +216,7 @@ class AppPreferences { val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null) val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null) val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null) + val shouldDeleteDatabaseBackups = mkBoolPreference(SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS, false) val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false) val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false) val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null) @@ -487,6 +489,7 @@ class AppPreferences { private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase" private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase" private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt" + private const val SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS = "ShouldDeleteDatabaseBackups" private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized" private const val SHARED_PREFS_SHOULD_IMPORT_APP_SETTINGS = "ShouldImportAppSettings" private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" @@ -686,6 +689,10 @@ object ChatController { } apiStartChat() appPrefs.chatStopped.set(false) + if (appPrefs.shouldDeleteDatabaseBackups.get()) { + deleteDatabaseBackups() + appPrefs.shouldDeleteDatabaseBackups.set(false) + } } catch (e: Throwable) { Log.e(TAG, "failed starting chat $e") throw e diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index b656b8b8da..a9b1bc482b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -434,6 +434,7 @@ suspend fun encryptDatabase( m.controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) } val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) + if (error == null && currentKey.value != newKey.value) appPrefs.shouldDeleteDatabaseBackups.set(true) appPrefs.encryptionStartedAt.set(null) val sqliteError = ((error as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError when { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index c6bbb93294..cc782e7b48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -598,6 +598,11 @@ fun deleteChatDatabaseFilesAndState() { ntfManager.cancelAllNotifications() } +fun deleteDatabaseBackups() { + File(dataDir, "$chatDatabaseFileName.bak").delete() + File(dataDir, "$agentDatabaseFileName.bak").delete() +} + private suspend fun exportArchive( m: ChatModel, progressIndicator: MutableState, diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index cd2d04e78d..42da70d7b9 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -125,12 +125,14 @@ deleteStorage = do liftIO $ closeDBStore `withStores` fs remove `withDBs` fs removeExported `withDBs` fs + removeBackup `withDBs` fs mapM_ removeDir $ filesPath fs mapM_ removeDir $ assetsPath fs mapM_ removeDir =<< chatReadVar tempDirectory where remove f = whenM (doesFileExist f) $ removeFile f removeExported f = remove $ f <> ".exported" + removeBackup f = remove $ f <> ".bak" removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d data StorageFiles = StorageFiles diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 72a72de794..2055eff232 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1489,6 +1489,8 @@ testMaintenanceModeWithFiles ps = withXFTPServer $ do threadDelay 500000 + let backupDBs = [tmpPath ps <> "/alice_chat.db.bak", tmpPath ps <> "/alice_agent.db.bak"] + forM_ backupDBs $ \f -> B.writeFile f "" alice ##> "/_stop" alice <## "chat stopped" alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" @@ -1505,6 +1507,7 @@ testMaintenanceModeWithFiles ps = withXFTPServer $ do alice ##> "/_db import {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" alice <## "ok" B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src + forM_ backupDBs $ \f -> doesFileExist f `shouldReturn` False -- works after full restart withTestChat ps "alice" $ \alice -> testChatWorking alice bob From be05a4662861287bad34ecee0b5868e7901cc4d8 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:47:46 +0400 Subject: [PATCH 08/94] android: StrongBox for new keystore keys, show key storage level (#7637) * android: create new keystore keys in StrongBox * android: show keystore key storage level * ui: save keystore key before marking it stored * android: show keystore storage level in the footer text instead of a separate row * android: return keystore storage label, drop enum --------- Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> --- .../common/platform/Cryptor.android.kt | 44 ++++++++++++++++--- .../DatabaseEncryptionView.android.kt | 3 +- .../chat/simplex/common/platform/Cryptor.kt | 1 + .../views/database/DatabaseEncryptionView.kt | 10 ++++- .../common/views/helpers/DatabaseUtils.kt | 2 + .../common/views/migration/MigrateToDevice.kt | 2 +- .../commonMain/resources/MR/ar/strings.xml | 2 +- .../commonMain/resources/MR/base/strings.xml | 5 ++- .../commonMain/resources/MR/bg/strings.xml | 2 +- .../commonMain/resources/MR/ca/strings.xml | 2 +- .../commonMain/resources/MR/cs/strings.xml | 2 +- .../commonMain/resources/MR/da/strings.xml | 2 +- .../commonMain/resources/MR/de/strings.xml | 2 +- .../commonMain/resources/MR/el/strings.xml | 2 +- .../commonMain/resources/MR/es/strings.xml | 2 +- .../commonMain/resources/MR/fa/strings.xml | 2 +- .../commonMain/resources/MR/fi/strings.xml | 2 +- .../commonMain/resources/MR/fr/strings.xml | 2 +- .../commonMain/resources/MR/hu/strings.xml | 2 +- .../commonMain/resources/MR/in/strings.xml | 2 +- .../commonMain/resources/MR/it/strings.xml | 2 +- .../commonMain/resources/MR/iw/strings.xml | 2 +- .../commonMain/resources/MR/ja/strings.xml | 2 +- .../commonMain/resources/MR/ko/strings.xml | 2 +- .../commonMain/resources/MR/lt/strings.xml | 2 +- .../commonMain/resources/MR/lv/strings.xml | 2 +- .../resources/MR/nb-rNO/strings.xml | 2 +- .../commonMain/resources/MR/nl/strings.xml | 2 +- .../commonMain/resources/MR/pl/strings.xml | 2 +- .../resources/MR/pt-rBR/strings.xml | 2 +- .../commonMain/resources/MR/pt/strings.xml | 2 +- .../commonMain/resources/MR/ro/strings.xml | 2 +- .../commonMain/resources/MR/ru/strings.xml | 2 +- .../commonMain/resources/MR/sk/strings.xml | 2 +- .../commonMain/resources/MR/th/strings.xml | 2 +- .../commonMain/resources/MR/tr/strings.xml | 2 +- .../commonMain/resources/MR/uk/strings.xml | 2 +- .../commonMain/resources/MR/vi/strings.xml | 2 +- .../resources/MR/zh-rCN/strings.xml | 2 +- .../resources/MR/zh-rTW/strings.xml | 2 +- .../common/platform/Cryptor.desktop.kt | 2 + .../DatabaseEncryptionView.desktop.kt | 1 + 42 files changed, 92 insertions(+), 44 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt index d9a5fb59e3..b1a0307cde 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt @@ -1,8 +1,11 @@ package chat.simplex.common.platform import android.annotation.SuppressLint +import android.os.Build import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR @@ -68,15 +71,44 @@ internal class Cryptor: CryptorInterface { keyStore.deleteEntry(alias) } + override fun keyStorage(alias: String): String? { + val secretKey = getSecretKey(alias) ?: return null + val keyInfo = SecretKeyFactory.getInstance(secretKey.algorithm, "AndroidKeyStore").getKeySpec(secretKey, KeyInfo::class.java) as KeyInfo + val storage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + when (keyInfo.securityLevel) { + KeyProperties.SECURITY_LEVEL_STRONGBOX -> MR.strings.keystore_key_storage_strongbox + KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> MR.strings.keystore_key_storage_tee + KeyProperties.SECURITY_LEVEL_SOFTWARE -> MR.strings.keystore_key_storage_software + else -> return null + } + } else { + // isInsideSecureHardware does not distinguish StrongBox, which createSecretKey only requests on API 31+ + @Suppress("DEPRECATION") + if (keyInfo.isInsideSecureHardware) MR.strings.keystore_key_storage_tee else MR.strings.keystore_key_storage_software + } + return generalGetString(storage) + } + private fun createSecretKey(alias: String): SecretKey? { if (keyStore.containsAlias(alias)) return getSecretKey(alias) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + return generateKey(keySpec(alias).setIsStrongBoxBacked(true)) + } catch (e: StrongBoxUnavailableException) { + Log.i(TAG, "StrongBox is unavailable, using default keystore: ${e.message}") + } + } + return generateKey(keySpec(alias)) + } + + private fun keySpec(alias: String): KeyGenParameterSpec.Builder = + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + + private fun generateKey(spec: KeyGenParameterSpec.Builder): SecretKey { val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore") - keyGenerator.init( - KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(BLOCK_MODE) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - ) + keyGenerator.init(spec.build()) return keyGenerator.generateKey() } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt index 1826b2114e..b472e6be3a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter( useKeychain: MutableState, chatDbEncrypted: Boolean?, storedKey: MutableState, + keyStorage: String?, initialRandomDBPassphrase: MutableState, migration: Boolean, ) { @@ -66,7 +67,7 @@ actual fun DatabaseEncryptionFooter( SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) } else if (useKeychain.value) { if (storedKey.value) { - SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) + SectionTextFooter(String.format(generalGetString(MR.strings.keychain_is_storing_securely), keyStorage?.let { " ($it)" } ?: "")) if (initialRandomDBPassphrase.value && !migration) { SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt index 5af941fb42..56585f0739 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt @@ -4,6 +4,7 @@ interface CryptorInterface { fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? fun encryptText(text: String, alias: String): Pair fun deleteKey(alias: String) + fun keyStorage(alias: String): String? } expect val cryptor: CryptorInterface diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index a9b1bc482b..ebf96a9868 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -44,6 +44,7 @@ fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) { val useKeychain = remember { mutableStateOf(appPrefs.storeDBPassphrase.get()) } val initialRandomDBPassphrase = remember { mutableStateOf(appPrefs.initialRandomDBPassphrase.get()) } val storedKey = remember { val key = DatabaseUtils.ksDatabasePassword.get(); mutableStateOf(key != null && key != "") } + val keyStorage = remember(storedKey.value) { if (storedKey.value) DatabaseUtils.ksDatabasePassword.storage() else null } // Do not do rememberSaveable on current key to prevent saving it on disk in clear text val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") } val newKey = rememberSaveable { mutableStateOf("") } @@ -60,6 +61,7 @@ fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) { newKey, confirmNewKey, storedKey, + keyStorage, initialRandomDBPassphrase, progressIndicator, migration, @@ -105,6 +107,7 @@ fun DatabaseEncryptionLayout( newKey: MutableState, confirmNewKey: MutableState, storedKey: MutableState, + keyStorage: String?, initialRandomDBPassphrase: MutableState, progressIndicator: MutableState, migration: Boolean, @@ -196,7 +199,7 @@ fun DatabaseEncryptionLayout( } Column { - DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration) + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, keyStorage, initialRandomDBPassphrase, migration) } SectionBottomSpacer() } @@ -254,6 +257,7 @@ expect fun DatabaseEncryptionFooter( useKeychain: MutableState, chatDbEncrypted: Boolean?, storedKey: MutableState, + keyStorage: String?, initialRandomDBPassphrase: MutableState, migration: Boolean, ) @@ -460,12 +464,13 @@ suspend fun encryptDatabase( if (migration) { appPreferences.storeDBPassphrase.set(useKeychain.value) } - resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) if (useKeychain.value) { DatabaseUtils.ksDatabasePassword.set(new) } else { removePassphraseFromKeyChain(useKeychain, storedKey, migration) } + // DatabaseEncryptionView reads key storage when storedKey changes, so storedKey is updated after the key is saved + resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) } @@ -540,6 +545,7 @@ fun PreviewDatabaseEncryptionLayout() { newKey = remember { mutableStateOf("") }, confirmNewKey = remember { mutableStateOf("") }, storedKey = remember { mutableStateOf(true) }, + keyStorage = stringResource(MR.strings.keystore_key_storage_strongbox), initialRandomDBPassphrase = remember { mutableStateOf(true) }, progressIndicator = remember { mutableStateOf(false) }, migration = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index e584fcc11c..e984ddfb7b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -38,6 +38,8 @@ object DatabaseUtils { passphrase.set(null) initVector.set(null) } + + fun storage(): String? = cryptor.keyStorage(alias) } fun hasAtLeastOneDatabase(rootDir: String): Boolean = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index f92a5e0ce4..54b6758976 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -453,7 +453,7 @@ private fun MutableState.PassphraseEnteringView(currentKey: S } } ) {} - DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, remember { mutableStateOf(false) }, true) + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, null, remember { mutableStateOf(false) }, true) } if (verifyingPassphrase.value) { ProgressView() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index cd4e0a89d1..0578d769c5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -43,7 +43,7 @@ قُبلت المكالمة اسمح بالمكالمات فقط إذا سمحت جهة اتصالك بذلك. اسمح بردود الفعل على الرسائل فقط إذا سمحت جهة اتصالك بذلك. - يتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. + يتم استخدام Android Keystore%s لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد. أجب الاتصال دائمًا 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 20d5185cb5..efe0423aff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1642,6 +1642,9 @@ Save passphrase in Keystore Save passphrase in settings + Secure element + TEE + Software Database encrypted! Error encrypting database Remove passphrase from Keystore? @@ -1658,7 +1661,7 @@ Set passphrase Please enter correct current passphrase. Your chat database is not encrypted - set passphrase to protect it. - Android Keystore is used to securely store passphrase - it allows notification service to work. + Android Keystore%s is used to securely store passphrase - it allows notification service to work. The passphrase is stored in settings as plaintext. Database is encrypted using a random passphrase, you can change it. Please note: you will NOT be able to recover or change passphrase if you lose it.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 5bf3ab7754..055dbc2196 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -78,7 +78,7 @@ Позволи на вашите контакти да изпращат гласови съобщения. Всички ваши контакти ще останат свързани. Актуализацията на профила ще бъде изпратена до вашите контакти. Винаги включен - Android Keystore се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи. + Android Keystore%s се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи. Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено. Приложението може да получава известия само когато работи, няма да се стартира услуга във фонов режим Икона на приложението diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 9763bbaf88..4e9fa56bc0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -125,7 +125,7 @@ Icona aplicació Desenfocar els mitjans Trucades - Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. + Android Keystore%s s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions. No es pot accedir a Keystore per desar la contrasenya de la base de dades Tingueu en compte: NO podreu recuperar ni canviar la contrasenya si la perdeu.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 1d404ef94b..a80fd14a2f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -280,7 +280,7 @@ Aktualizovat přístupovou frázi Zadejte prosím správnou aktuální přístupovou frázi. Databáze chatu není šifrována - nastavte přístupovou frázi pro její ochranu. - K bezpečnému uložení přístupové fráze slouží úložiště klíčů Android - umožňuje fungování služby oznámení. + K bezpečnému uložení přístupové fráze slouží úložiště klíčů Android%s - umožňuje fungování služby oznámení. Upozornění: pokud přístupovou frázi ztratíte, NEBUDE možné ji obnovit ani změnit.]]> Databáze bude zašifrována a přístupová fráze bude uložena v úložišti klíčů. Přístupovou frázi uložte bezpečně, v případě její ztráty ji NEBUDE možné změnit. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index 9706c1b552..0d123e36c2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -147,7 +147,7 @@ Brug altid privat routing. Brug altid relæ og %d andre begivenheder - Android Keystore bruges til sikkert at opbevare adgangssæt - det giver Notification Service mulighed for at arbejde. + Android Keystore%s bruges til sikkert at opbevare adgangssæt - det giver Notification Service mulighed for at arbejde. Android Keystore vil blive brugt til sikkert at gemme adgangssæt, når du genstarter appen eller skifter adgangssætning - det giver mulighed for at modtage meddelelser. En tom chatprofil med det angivne navn oprettes, og appen åbnes som sædvanligt. En ny tilfældig profil deles. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index ccc7732a18..c6157f1eeb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -583,7 +583,7 @@ Datenbank-Passwort aktualisieren Bitte geben Sie das korrekte, aktuelle Passwort ein. Ihre Chat-Datenbank ist nicht verschlüsselt. Bitte legen Sie ein Passwort fest, um sie zu schützen. - Der Android-Keystore wird verwendet, um das Passwort sicher zu speichern. Dies ermöglicht die ordentliche Funktion des Benachrichtigungsdienstes. + Der Android-Keystore%s wird verwendet, um das Passwort sicher zu speichern. Dies ermöglicht die ordentliche Funktion des Benachrichtigungsdienstes. Die Datenbank wird mit einem zufälligen Passwort verschlüsselt, Sie können es ändern. Bitte beachten Sie: Sie können das Passwort NICHT wiederherstellen oder ändern, wenn Sie es vergessen haben oder verlieren.]]> Der Android-Keystore wird verwendet, um das Passwort sicher zu speichern, nachdem Sie die App neu gestartet oder das Passwort geändert haben – dies ermöglicht den Empfang von Benachrichtigungen. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 30b5dad519..caff251b2c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -242,7 +242,7 @@ Κακό μήνυμα hash Θάμπωση των μέσων Βάση δεδομένων συνομιλίας - Το Android Keystore χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί. + Το Android Keystore%s χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί. αποκλεισμένος Αποκλεισμένος από τον διαχειριστή Δεν είναι δυνατή η κλήση επαφής diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 43e516561a..079d9cfc1b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -51,7 +51,7 @@ Se eliminarán todos los chats y mensajes. ¡No puede deshacerse! Aceptar Se permiten mensajes temporales. - Android Keystore se usará para almacenar la frase de contraseña de forma segura - permite que el servicio de notificaciones funcione. + Android Keystore%s se usará para almacenar la frase de contraseña de forma segura - permite que el servicio de notificaciones funcione. Añadir perfil Color Permites que tus contactos eliminan irreversiblemente los mensajes enviados. (24 horas) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 65f1769787..eb187475dd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -994,7 +994,7 @@ رمزنگاری به‌روزرسانی عبارت عبور پایگاه داده تعیین عبارت عبور - از مخزن کلید اندروید برای ذخیره امن عبارت عبور استفاده می‌شود - به سرویس اعلان اجازه عمل می‌دهد. + از مخزن کلید اندروید%s برای ذخیره امن عبارت عبور استفاده می‌شود - به سرویس اعلان اجازه عمل می‌دهد. باید هر بار که برنامه شروع می‌شود عبارت عبور را وارد کنید - در دستگاه ذخیره نمی‌شود. پایگاه داده رمزنگاری شده مخاطب بررسی شد diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 5036d874d7..51a566a284 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -115,7 +115,7 @@ Poistetaanko tiedostot ja media\? %d tiedosto(a), joiden kokonaiskoko on %s Nykyinen tunnuslause… - Android Keystorea käytetään salalauseen turvalliseen tallentamiseen - se mahdollistaa ilmoituspalvelun toiminnan. + Android Keystorea%s käytetään salalauseen turvalliseen tallentamiseen - se mahdollistaa ilmoituspalvelun toiminnan. Tietokanta on salattu satunnaisella tunnuslauseella, voit muuttaa sitä. Tietokantavirhe Keskustelun avaamiseen tarvitaan tietokannan tunnuslause. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index baa98ebf0b..dadd5ad300 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -584,7 +584,7 @@ Nouvelle phrase secrète… Confirmer la nouvelle phrase secrète… Mise à jour de la phrase secrète de la base de données - Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner. + Le Keystore d\'Android%s est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner. Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil. Chiffrer la base de données \? Changer la phrase secrète de la base de données \? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 447dabcf3d..f6f7e48508 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -41,7 +41,7 @@ hivatkozáselőnézet visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. - Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. + Az Android Keystore-t%s a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. Hibás az üzenet kivonata Háttér Megjegyzés: az üzenet- és a fájlátjátszók SOCKS proxyn keresztül kapcsolódnak. A hívások pedig közvetlen kapcsolatot használnak.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 392c935575..29ebaac8d3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -987,7 +987,7 @@ Tema Kirim tanda terima kiriman ke Hal ini dapat terjadi ketika Anda atau koneksi Anda menggunakan cadangan basis data lama. - Android Keystore digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi. + Android Keystore%s digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi. Hapus Enkripsi Pesan diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 9b4e775685..9ae56f9d36 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -256,7 +256,7 @@ Errore nell\'importazione del database della chat Nome completo del gruppo: Backup dei dati dell\'app - L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. + L\'archivio chiavi di Android%s è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. Permetti ai tuoi contatti di inviare messaggi vocali. Database della chat eliminato Icona app diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index e42232f93a..8088ae4f23 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -51,7 +51,7 @@ לאפשר הודעות קוליות\? אפשר לאנשי קשר לשלוח הודעות קוליות. תמיד פעיל - ישנו שימוש ב־Android Keystore כדי לאחסן בבטחה את הסיסמה – דבר המאפשר לשירות ההתראות לעבוד. + ישנו שימוש ב־Android Keystore%s כדי לאחסן בבטחה את הסיסמה – דבר המאפשר לשירות ההתראות לעבוד. Android Keystore יאחסן בבטחה את הסיסמה לאחר הפעלה מחדש של האפליקציה או שינוי הסיסמה – דבר המאפשר קבלת התראות. גיבוי נתוני האפליקציה סמל האפליקציה diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 0789cdcc69..c5cd469d81 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -61,7 +61,7 @@ メンバーへのダイレクトメッセージを許可 音声メッセージの送信を許可 アクティブの時のみに通知が出ます。バックグラウンド通知サービスは起動されません。 - Androidキーストアはパスフレーズの保管に使われます。通知機能に必要です。 + Androidキーストア%sはパスフレーズの保管に使われます。通知機能に必要です。 再起動時とパスフレーズ変更時にAndroidキーストアがパスフレーズの保管に使われます。通知機能に必要です。 通話に応答 アプリのアイコン diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index 880d328df6..01812e23e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -148,7 +148,7 @@ 대화 상대가 허용하는 경우에만 영구적인 메시지 삭제를 허용합니다. (24 시간) 모든 대화 상대가 연결된 상태로 유지됩니다. 항상 켜기 - Android 암호 저장소는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다. + Android 암호 저장소%s는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다. 앱을 다시 시작하거나 암호를 변경한 후 Android 암호 저장소를 사용하여 암호를 안전하게 저장합니다. - 알림을 받을 수 있습니다. 앱이 실행 중일 때만 알림을 받을 수 있으며, 백그라운드 서비스는 시작되지 않습니다. 앱 데이터 백업 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index 87144b710d..1eef42dd4e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -523,7 +523,7 @@ Visi programėlės duomenys bus ištrinti. Sukuriamas tuščias pokalbių profilis nurodytu pavadinimu ir programėlė atveriama kaip įprasta. Programėlė - Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“ – tai įgalina pranešimų tarnybą veikti. + Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“%s – tai įgalina pranešimų tarnybą veikti. Papildoma antrinė spalva Papildomas akcentavimas Leisti siųsti failus ir mediją. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml index b045044925..b58411035a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -1672,7 +1672,7 @@ Iestatīt frāzi Ievadiet pareizo pašreizējo frāzi Datubāze nav šifrēta - Atslēgu glabātuve tiek droši glabāta + Atslēgu glabātuve%s tiek droši glabāta Iestatījumi tiek glabāti parastā tekstā Šifrēts ar nejaušu frāzi diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml index 65614f25aa..859a05933c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -117,7 +117,7 @@ Bruk alltid privat ruting. Bruk alltid relé og %d andre hendelser - Android Keystore brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer. + Android Keystore%s brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer. Android Keystore brukes til å trygt lagre passordet ditt etter at du restarter appen eller bytter passord - det gjør at du kan motta varsler. En tom chat-profil med navnet du har valgt vil bli laget, og appen åpnes som vanlig. En ny tilfeldig profil vil bli delt. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 988d0e73cc..708f938ad9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -79,7 +79,7 @@ Geluid uit Back-up van app gegevens Beantwoord oproep - Android Keychain wordt gebruikt om het wachtwoord veilig op te slaan, hierdoor kan de meldings service werken. + Android Keychain%s wordt gebruikt om het wachtwoord veilig op te slaan, hierdoor kan de meldings service werken. Android Keychain wordt gebruikt om het wachtwoord veilig op te slaan nadat u de app opnieuw hebt opgestart of het wachtwoord heeft gewijzigd, hiermee kunt u meldingen ontvangen. App build: %s App kan alleen meldingen ontvangen wanneer deze actief is, er wordt geen achtergrondservice gestart diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index b725bfab32..942df14f13 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -538,7 +538,7 @@ Aktualizuj Aktualizuj hasło do bazy danych Twoja baza danych czatu nie jest szyfrowana - ustaw hasło, aby ją chronić. - Android Keystore służy do bezpiecznego przechowywania hasła - umożliwia działanie usługi powiadomień. + Android Keystore%s służy do bezpiecznego przechowywania hasła - umożliwia działanie usługi powiadomień. Android Keystore będzie używany do bezpiecznego przechowywania hasła po ponownym uruchomieniu aplikacji lub zmianie hasła - pozwoli to na otrzymywanie powiadomień. Uwaga: NIE będziesz w stanie odzyskać ani zmienić hasła, jeśli je zgubisz.]]> Nie można uzyskać dostępu do Keystore w celu zapisania hasła bazy danych diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index c353ad2a2f..679adb19e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -54,7 +54,7 @@ Chamadas na tela de bloqueio: Áudio ligado Banco de dados do chat importado - O Android Keystore é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione. + O Android Keystore%s é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione. O Android Keystore será usado para armazenar a senha com segurança após você reiniciar o aplicativo ou alterar a senha, permitindo continuar recebendo notificações. Não é possível acessar a Keystore para salvar a senha do banco de dados O chat está parado diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index 8b43d5295d..2c84318687 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -166,7 +166,7 @@ Mensagens de voz proibidas! Mensagem de voz (%1$s) Versão da aplicação: v%s - O Android Keystore é usado para armazenar com segurança a senha - permite que o serviço de notificações funcione. + O Android Keystore%s é usado para armazenar com segurança a senha - permite que o serviço de notificações funcione. O Android Keystore será usado para armazenar com segurança a senha depois de voçê reiniciar a aplicação ou alterar a senha - irá permitir receber notificações. As notificações serão entregues apenas até à aplicação parar! Compilação da aplicação: %s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index 8c7651fecd..f332586376 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -93,7 +93,7 @@ Se alătură deja grupului! Mereu pornit Un nou profil aleatoriu va fi distribuit. - Android Keystore este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări. + Android Keystore%s este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări. și %d alte evenimente Răspunde la apel Android Keystore va fi folosit pentru a stoca în siguranță parola după ce repornești aplicația sau schimbi parola — acest lucru va permite primirea de notificări. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 6f43e046d8..144e81471d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -585,7 +585,7 @@ Поменять пароль Пожалуйста, введите правильный пароль. База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные. - Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. + Android Keystore%s используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. База данных зашифрована случайным паролем, Вы можете его поменять. Внимание: Вы не сможете восстановить или поменять пароль, если потеряете его.]]> Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml index 61b75fcf90..77bd862259 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml @@ -651,7 +651,7 @@ Nastavte prístupovú frázu pre export Aby ste odhalili svoj skrytý profil, zadajte celé heslo do vyhľadávacieho poľa na stránke profilov chatov. Vaše prihlasovacie údaje môžu byť zaslané nešifrované. - Android Keystore je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení. + Android Keystore%s je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení. Android Keystore bude použitý na bezpečné uloženie prístupovej frázy po reštarte aplikácie alebo zmene prístupovej frázy - umožní to fungovanie služby oznámení. Upozornenie: ak stratíte vašu prístupovú frázu, NEBUDE možné ju obnoviť ani zmeniť.]]> Zmeniť prístupovú frázu k databáze? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index 9e782ee24f..73ee88a3ed 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -23,7 +23,7 @@ เน้นสีเพิ่มเติม ไอคอนแอป รหัสผ่านแอป - Android Keystore ใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัย - ซึ่งจะช่วยให้บริการแจ้งเตือนทำงานได้ + Android Keystore%s ใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัย - ซึ่งจะช่วยให้บริการแจ้งเตือนทำงานได้ Android Keystore จะถูกใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัยหลังจากที่คุณรีสตาร์ทแอปหรือเปลี่ยนรหัสผ่าน - ซึ่งจะอนุญาตให้รับบริการแจ้งเตือนได้ รุ่นแอป: %s การสํารองข้อมูลแอป diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 30414e244c..989782053d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -665,7 +665,7 @@ Bağlanma isteklerini otomatik kabul et Uyarı: Bazı verileri kaybedebilirsin! Tüm kişileriniz bağlı kalacaktır. Profil güncellemesi kişilerinize gönderilecektir. - Android Keystore parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar. + Android Keystore%s parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar. Karşılama mesajı Karşılama mesajı Sesli mesajlar yasaktır. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 4f49834c7f..67ed554185 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -25,7 +25,7 @@ Пароль застосунку замінено паролем самознищення. Резервне копіювання даних застосунку Додати на інший пристрій - Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. + Сховище ключів Android%s використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. Адміністратори можуть створювати посилання для приєднання до групи. Збірка додатку: %s Дозволити голосові повідомлення тільки за умови, що ваш контакт дозволяє їх. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index 9c63cff9ee..11e9080687 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -82,7 +82,7 @@ Đã tham gia nhóm rồi! Luôn sử dụng relay và %d sự kiện khác - Android Keystore được sử dụng để lưu trữ passphrase - nó cho phép dịch vụ thông báo hoạt động. + Android Keystore%s được sử dụng để lưu trữ passphrase - nó cho phép dịch vụ thông báo hoạt động. Android Keystore sẽ được sử dụng để lưu trữ passphrase một cách an toàn sau khi bạn khởi động lại ứng dụng hoặc thay đổi passphrase - nó cho phép tiếp nhận thông báo. Tất cả các liên hệ, cuộc hội thoại và tệp của bạn sẽ được mã hóa an toàn và tải lên từng phần tới các XFTP relay được chỉ định. Quản trị viên có thể chặn một thành viên khỏi tất cả. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 75e67cca37..daa77e171e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -89,7 +89,7 @@ 为此删除聊天资料 删除数据库 在你重启应用程序或者更换密码后安卓密钥库系统用来安全地保存密码——来确保收到通知。 - 安卓密钥库系统用来安全地保存密码——来确保通知服务运作。 + 安卓密钥库系统%s用来安全地保存密码——来确保通知服务运作。 外观 应用程序版本 应用程序数据备份 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 5827cead70..c146c778a8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -94,7 +94,7 @@ 備份應用程式資料 應用程式圖示 已匯入對話資料庫 - Android 金鑰庫是用於安全地儲存密碼 - 確保通知推送服務的運作。 + Android 金鑰庫%s是用於安全地儲存密碼 - 確保通知推送服務的運作。 請注意:如果你忘記了密碼你將不能再次復原或修改密碼。]]> 當你重新啟動應用程式或修改密碼後, Android 金鑰庫將用來安全地儲存密碼 - 將允許接收訊息通知。 聊天已停止 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt index 870fde945b..b83f807667 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt @@ -12,4 +12,6 @@ actual val cryptor: CryptorInterface = object : CryptorInterface { override fun deleteKey(alias: String) { // LALAL } + + override fun keyStorage(alias: String): String? = null } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt index 4535857696..0010d63e15 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter( useKeychain: MutableState, chatDbEncrypted: Boolean?, storedKey: MutableState, + keyStorage: String?, initialRandomDBPassphrase: MutableState, migration: Boolean, ) { From a9322ff32cc939f2d70015e0341aed9d4a5943ad Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 4 Oct 2026 12:07:39 +0100 Subject: [PATCH 09/94] android: prevent access to chat list when accepting call in locked state (#7627) * android: prevent access to chat list when accepting call in locked state * android: request auth on resume during a call --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum --- .../chat/simplex/app/views/call/CallActivity.kt | 6 +++++- .../commonMain/kotlin/chat/simplex/common/App.kt | 7 +++++-- .../kotlin/chat/simplex/common/AppLock.kt | 6 +++++- .../chat/simplex/common/platform/NtfManager.kt | 2 ++ apps/multiplatform/spec/README.md | 2 +- apps/multiplatform/spec/client/navigation.md | 8 ++++---- apps/multiplatform/spec/services/calls.md | 16 ++++++++-------- .../multiplatform/spec/services/notifications.md | 10 +++++----- 8 files changed, 35 insertions(+), 22 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt index 34a2c96b6d..771ada0cd6 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -36,6 +36,7 @@ import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.AcceptCallAction +import chat.simplex.common.AppLock import chat.simplex.common.helpers.applyAppLocale import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs @@ -342,7 +343,10 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo chatModel.activeCallInvitation.value = null ntfManager.cancelCallNotification() }, - acceptCall = { cm.acceptIncomingCall(invitation = invitation) }, + acceptCall = { + AppLock.recheckAuthState() + cm.acceptIncomingCall(invitation = invitation) + }, openApp = { val intent = Intent(context, MainActivity::class.java) .setAction(NtfManager.OpenChatAction) 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 338e602a45..9883828950 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 @@ -207,7 +207,7 @@ fun MainScreen() { SwitchingUsersView() } - if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) { + if (unauthorized.value) { LaunchedEffect(Unit) { // With these constrains when user presses back button while on ChatList, activity destroys and shows auth request // while the screen moves to a launcher. Detect it and prevent showing the auth @@ -221,7 +221,8 @@ fun MainScreen() { SplashView(true) ModalManager.fullscreen.showPasscodeInView() } - } else { + } + if (!unauthorized.value || chatModel.activeCallViewIsVisible.value) { if (chatModel.showCallView.value) { if (appPlatform.isAndroid) { LaunchedEffect(Unit) { @@ -235,6 +236,8 @@ fun MainScreen() { ActiveCallView() } } + } + if (!unauthorized.value) { ModalManager.fullscreen.showOneTimePasscodeInView() AlertManager.privacySensitive.showInView() if (onboarding == OnboardingStage.OnboardingComplete) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index f464a4c20a..c38e20aae8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -269,7 +269,11 @@ object AppLock { fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000 fun recheckAuthState() { - if (ChatModel.showCallView.value) return + if (ChatModel.showCallView.value) { + // BiometricPrompt drops a prompt requested while MainActivity is stopped (call on lock screen), so request it again + if (userAuthorized.value == false) runAuthenticate() + return + } val enteredBackgroundVal = enteredBackground.value val delay = ChatController.appPrefs.laLockDelay.get() if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 89f23f3326..bf7a4c83c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -1,5 +1,6 @@ package chat.simplex.common.platform +import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.chatlist.acceptContactRequest @@ -96,6 +97,7 @@ abstract class NtfManager { } fun acceptCallAction(chatId: ChatId) { + AppLock.recheckAuthState() chatModel.clearOverlays.value = true val invitation = chatModel.callInvitations[chatId] if (invitation == null) { diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index d6a7ec20af..c6dbf1abf2 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -128,7 +128,7 @@ Common Module (commonMain) | Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 | | App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L102) | `class AppPreferences` | 102 | | 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#L19) | `abstract class NtfManager` | 19 | +| 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 | diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 67c3b00d88..96dd58052c 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -115,8 +115,8 @@ When onboarding is complete: | `SwitchingUsersView` | User switch in progress | Loading overlay | | Auth gate | `userAuthorized != true` | `AuthView` or `SplashView` + passcode | | Active call | `showCallView == true` | `ActiveCallView` (desktop) or call activity (Android) | -| One-time passcode | Always | `ModalManager.fullscreen.showOneTimePasscodeInView` | -| Privacy alerts | Always | `AlertManager.privacySensitive` | +| One-time passcode | `userAuthorized == true` | `ModalManager.fullscreen.showOneTimePasscodeInView` | +| Privacy alerts | `userAuthorized == true` | `AlertManager.privacySensitive` | | Incoming call | `activeCallInvitation != null` | `IncomingCallAlertView` | | Shared alerts | Always | `AlertManager.shared` | @@ -294,7 +294,7 @@ object AppLock { ### Authentication Flow 1. **MainScreen** checks `unauthorized` (derived: `userAuthorized.value != true`) at line ~135. -2. If unauthorized and not in an active call: +2. If unauthorized, including during an active call: - Launches `AppLock.runAuthenticate()` which triggers platform-specific biometric/passcode prompt. - On Android with system auth finishing during activity destruction, authentication is skipped. 3. If `performLA` preference is set and `laFailed` is true: shows `AuthView` with "Unlock" button. @@ -302,7 +302,7 @@ object AppLock { ### Lock Delay -The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. A call counts as activity: `recheckAuthState()` is a no-op while `showCallView` is true, and `CallManager.endCall()` resets `enteredBackground`, so the delay is counted from the end of the call. +The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. A call counts as activity: `recheckAuthState()` is a no-op while `showCallView` is true, and `CallManager.endCall()` resets `enteredBackground`, so the delay is counted from the end of the call. When a call is accepted from a notification or from the Android lock screen, `recheckAuthState()` is called before `acceptIncomingCall()`, so an expired delay still locks the app. ### Lock Modes diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index 0f68c5a1fd..85303e1dad 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -59,18 +59,18 @@ State transitions are driven by `WCallResponse` messages from the WebRTC layer. ## 3. Android Implementation -### 3.1 CallActivity.kt (464 lines) +### 3.1 CallActivity.kt (468 lines) [`CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) A dedicated `ComponentActivity` that hosts the call UI. Key responsibilities: -- **Intent handling** ([line 64](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L64)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`. -- **Lock screen support** ([line 160](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L160)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings. -- **Picture-in-Picture** ([line 99](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L99)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command. -- **Permission checks** ([line 122](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L122)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions. -- **Service binding** ([line 181](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L181)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions. -- **CallActivityView composable** ([line 208](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L208)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen. +- **Intent handling** ([line 65](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L65)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`. +- **Lock screen support** ([line 161](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L161)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings. +- **Picture-in-Picture** ([line 100](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L100)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command. +- **Permission checks** ([line 123](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L123)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions. +- **Service binding** ([line 182](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L182)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions. +- **CallActivityView composable** ([line 209](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L209)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen. ### 3.2 CallService.kt (207 lines) @@ -169,7 +169,7 @@ An in-app notification banner shown when a call invitation arrives while the app | `CallView.kt` | [`common/src/commonMain/.../views/call/CallView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt) | 28 | `expect fun ActiveCallView()`, delivery receipt waiting | | `CallView.android.kt` | [`common/src/androidMain/.../views/call/CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) | 891 | Android WebView WebRTC, overlay, permissions | | `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 308 | Desktop browser WebRTC via NanoWSD | -| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 464 | Android call Activity, PiP, lock screen | +| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 472 | Android call Activity, PiP, lock screen | | `CallService.kt` | [`android/src/main/java/.../CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) | 207 | Android foreground service for calls | | `CallManager.kt` | [`common/src/commonMain/.../views/call/CallManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) | 119 | Call lifecycle management | | `WebRTC.kt` | [`common/src/commonMain/.../views/call/WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt) | -- | `CallState` enum, `WCallCommand`, `WCallResponse` types | diff --git a/apps/multiplatform/spec/services/notifications.md b/apps/multiplatform/spec/services/notifications.md index a14c4d7d24..b9391dda11 100644 --- a/apps/multiplatform/spec/services/notifications.md +++ b/apps/multiplatform/spec/services/notifications.md @@ -35,16 +35,16 @@ The architecture uses an abstract `NtfManager` in common code with platform-spec [`NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) (139 lines, commonMain) -The global `ntfManager` instance is declared at [line 17](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L17) and initialized by each platform at startup. +The global `ntfManager` instance is declared at [line 18](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L18) and initialized by each platform at startup. ### Concrete methods | Method | Line | Description | |---|---|---| -| `notifyContactConnected` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | Displays "contact connected" notification for a `Contact` | -| `notifyContactRequestReceived` | [L27](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L27) | Shows contact request notification with an "Accept" action button | -| `notifyMessageReceived` | [L38](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L38) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat | -| `acceptContactRequestAction` | [L51](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L51) | Accepts a contact request from a notification action | +| `notifyContactConnected` | [L21](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L21) | Displays "contact connected" notification for a `Contact` | +| `notifyContactRequestReceived` | [L28](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L28) | Shows contact request notification with an "Accept" action button | +| `notifyMessageReceived` | [L39](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L39) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat | +| `acceptContactRequestAction` | [L52](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L52) | Accepts a contact request from a notification action | | `openChatAction` | [L59](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L59) | Opens a specific chat from a notification tap, switching user if needed | | `showChatsAction` | [L74](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L74) | Opens the chat list, switching user if needed | | `acceptCallAction` | [L88](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L88) | Accepts a call invitation from a notification action | From b79223fe5c068d638a50942b336f87069d4c581e Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 4 Oct 2026 18:44:52 +0100 Subject: [PATCH 10/94] core: call media key HKDF, expire stale calls (#7642) * core: call media key HKDF, expire stale calls * core: expire stale calls in bulk SQL * core: reduce call tests * core: remove periodic call cleanup * core: expire calls with DELETE RETURNING * core: skip call item update when none expired --------- Co-authored-by: shum --- docs/protocol/simplex-chat.md | 6 ++ docs/protocol/simplex-chat.schema.json | 11 +++- src/Simplex/Chat.hs | 3 + src/Simplex/Chat/Call.hs | 60 +++++++++++++++++- src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/Library/Commands.hs | 11 ++-- src/Simplex/Chat/Library/Subscriber.hs | 38 +++++++---- src/Simplex/Chat/Store/Profiles.hs | 24 ++++++- .../SQLite/Migrations/chat_query_plans.txt | 12 ++++ tests/ChatTests/Direct.hs | 63 ++++++++++++++++++- tests/ProtocolTests.hs | 15 +++++ 11 files changed, 221 insertions(+), 24 deletions(-) diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 13e1464881..066b465d0d 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -286,6 +286,12 @@ These message are used for WebRTC calls: 4. `x.call.end` message is sent to notify the other party that the call is terminated. +Call messages have their own version, independent of the chat protocol version. The initiating client sends the range of call versions it supports in `callVRange` property of `x.call.inv`. The receiving client chooses the highest version supported by both clients and sends it in `callVersion` property of `x.call.offer`. The initiating client rejects the offer if this version is not in the range it sent. A missing range or version means version 1. + +Media frames are encrypted with AES-256-GCM using the key agreed via X25519 DH keys in `x.call.inv` and `x.call.offer`: +- version 1: the key is the DH shared secret. +- version 2: the key is 32 bytes of HKDF-SHA512 output, using the DH shared secret as input key material, call ID as salt and `SimpleXCallMediaKey` as info. + ## Threat model This threat model complements SMP, XFTP, push notifications and XRCP protocols threat models, as well as the channel-specific threat model: diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 4614ba6929..8a71e715db 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -338,7 +338,13 @@ "callType": {"ref": "callType"} }, "optionalProperties": { - "callDhPubKey": {"ref": "base64url"} + "callDhPubKey": {"ref": "base64url"}, + "callVRange": { + "type": "string", + "metadata": { + "format": "call version range, e.g. \"1-2\", or a single version \"1\"" + } + } } }, "callOffer": { @@ -347,7 +353,8 @@ "rtcSession": {"ref": "webRTCSession"} }, "optionalProperties": { - "callDhPubKey": {"ref": "base64url"} + "callDhPubKey": {"ref": "base64url"}, + "callVersion": {"type": "uint16"} } }, "callAnswer": { diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3e6fb7e229..0522e3c8a6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -29,6 +29,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Time.Clock (getCurrentTime, nominalDay) import Simplex.Chat.Badges (badgeServerCredential, defaultFileSizeLimits) +import Simplex.Chat.Call (supportedCallVRange) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Operators @@ -68,6 +69,7 @@ defaultChatConfig = tbqSize = 1024 }, chatVRange = supportedChatVRange, + callVRange = supportedCallVRange, badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys, badgeServiceAddress = Just $ either error id $ strDecode "https://smp5.simplex.im/a#ooSNWlEZTO2RPE0Ff5ZoybAs5zEhWLMlQrXesnhaZHM", badgeCurrentTime = getCurrentTime, @@ -123,6 +125,7 @@ defaultChatConfig = cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes + callInvitationTTL = 180, -- 3 minutes, the apps stop ringing for older invitations highlyAvailable = False, deliveryWorkerDelay = 0, deliveryBucketSize = 10000, diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 18398ecb3e..f64cd8e736 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,11 +1,13 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DerivingVia #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} @@ -21,6 +23,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) +import Data.Word (Word16) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (Contact, ContactId, User) import Simplex.Messaging.Agent.Store.DB (Binary (..), fromTextField_) @@ -28,6 +31,8 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON) +import Simplex.Messaging.Version +import Simplex.Messaging.Version.Internal data Call = Call { contactId :: ContactId, @@ -39,6 +44,52 @@ data Call = Call } deriving (Show) +-- Call version history: +-- 1 - DH secret is used as media key (also assumed when call messages have no version) +-- 2 - media key is derived from DH secret with HKDF (2026-10-01) + +data CallVersion + +instance VersionScope CallVersion + +type VersionCall = Version CallVersion + +type VersionRangeCall = VersionRange CallVersion + +pattern VersionCall :: Word16 -> VersionCall +pattern VersionCall v = Version v + +initialCallVersion :: VersionCall +initialCallVersion = VersionCall 1 + +callMediaKdfVersion :: VersionCall +callMediaKdfVersion = VersionCall 2 + +-- This should not be used directly in code, instead use `callVRange` from ChatConfig +currentCallVersion :: VersionCall +currentCallVersion = VersionCall 2 + +supportedCallVRange :: VersionRangeCall +supportedCallVRange = mkVersionRange initialCallVersion currentCallVersion + +callInitialVRange :: VersionRangeCall +callInitialVRange = versionToRange initialCallVersion + +newtype CallVersionRange = CallVersionRange {fromCallVRange :: VersionRangeCall} + deriving (Eq, Show) + deriving (FromJSON, ToJSON) via (StrJSON "CallVersionRange" VersionRangeCall) + +callMediaKey :: VersionCall -> CallId -> C.PublicKeyX25519 -> C.PrivateKeyX25519 -> C.Key +callMediaKey v (CallId salt) peerPubKey privKey + | v >= callMediaKdfVersion = C.Key $ C.hkdf salt dhSecret "SimpleXCallMediaKey" callMediaKeySize + | otherwise = C.Key dhSecret + where + dhSecret = C.dhBytes' $ C.dh' peerPubKey privKey + +-- AES-256-GCM key used by the apps for frame encryption +callMediaKeySize :: Int +callMediaKeySize = 32 + isRcvInvitation :: Call -> Bool isRcvInvitation Call {callState} = case callState of CallInvitationReceived {} -> True @@ -68,7 +119,8 @@ data CallState | CallInvitationReceived { peerCallType :: CallType, localDhPubKey :: Maybe C.PublicKeyX25519, - sharedKey :: Maybe C.Key + sharedKey :: Maybe C.Key, + callVersion :: Maybe VersionCall } | CallOfferSent { localCallType :: CallType, @@ -134,7 +186,8 @@ encryptedCall CallType {capabilities = CallCapabilities {encryption}} = encrypti -- | * Types for chat protocol data CallInvitation = CallInvitation { callType :: CallType, - callDhPubKey :: Maybe C.PublicKeyX25519 + callDhPubKey :: Maybe C.PublicKeyX25519, + callVRange :: Maybe CallVersionRange } deriving (Eq, Show) @@ -149,7 +202,8 @@ data CallCapabilities = CallCapabilities data CallOffer = CallOffer { callType :: CallType, rtcSession :: WebRTCSession, - callDhPubKey :: Maybe C.PublicKeyX25519 + callDhPubKey :: Maybe C.PublicKeyX25519, + callVersion :: Maybe VersionCall } deriving (Eq, Show) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 446d4f1f9e..be3025bd25 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -144,6 +144,7 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, chatVRange :: VersionRangeChat, + callVRange :: VersionRangeCall, -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation badgePublicKeys :: Map Int BBSPublicKey, -- Nothing until the badge service is deployed @@ -174,6 +175,7 @@ data ChatConfig = ChatConfig cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds + callInvitationTTL :: NominalDiffTime, deliveryWorkerDelay :: Int64, -- microseconds deliveryBucketSize :: Int, webPreviewConfig :: Maybe WebPreviewConfig, diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b7b6139ded..bf9326871b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -351,7 +351,9 @@ startReceiveUserFiles user = do restoreCalls :: CM' () restoreCalls = do - savedCalls <- fromRight [] <$> runExceptT (withFastStore' getCalls) + ttl <- asks (callInvitationTTL . config) + cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime + savedCalls <- fromRight [] <$> runExceptT (withFastStore' $ \db -> expireCalls db cutoffTs >> getCalls db) let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls calls <- asks currentCalls atomically $ writeTVar calls callsMap @@ -1509,7 +1511,8 @@ processChatCommand cxt nm = \case callId <- atomically $ CallId <$> C.randomBytes 16 g callUUID <- UUID.toText <$> liftIO V4.nextRandom dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing - let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} + ChatConfig {callVRange = callVR} <- asks config + let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair, callVRange = Just $ CallVersionRange callVR} callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} (msg, _) <- sendDirectContactMessage user ct (XCallInv callId invitation) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) @@ -1537,9 +1540,9 @@ processChatCommand cxt nm = \case APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> -- party accepting call withCurrentCall contactId $ \user ct call@Call {callId, chatItemId, callState} -> case callState of - CallInvitationReceived {peerCallType, localDhPubKey, sharedKey} -> do + CallInvitationReceived {peerCallType, localDhPubKey, sharedKey, callVersion} -> do let callDhPubKey = if encryptedCall callType then localDhPubKey else Nothing - offer = CallOffer {callType, rtcSession, callDhPubKey} + offer = CallOffer {callType, rtcSession, callDhPubKey, callVersion} callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XCallOffer callId offer) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index bac8ae4da8..19b78dc2ae 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3010,15 +3010,23 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- to party accepting call xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> CM () - xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg@RcvMessage {sharedMsgId_} msgMeta = do + xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey, callVRange = peerCallVRange} msg@RcvMessage {sharedMsgId_} msgMeta = do if featureAllowed SCFCalls forContact ct then do + ChatConfig {callVRange = callVR} <- asks config + case callVR `compatibleVersion` maybe callInitialVRange fromCallVRange peerCallVRange of + Just (Compatible callVersion) -> createCallInvitation callVersion + Nothing -> messageError "x.call.inv: incompatible call version" + else featureRejected CFCalls + where + brokerTs = metaBrokerTs msgMeta + createCallInvitation callVersion = do g <- asks random dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing (ci, cInfo) <- saveCallItem CISCallPending callUUID <- UUID.toText <$> liftIO V4.nextRandom - let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) - callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} + let sharedKey = callMediaKey callVersion callId <$> callDhPubKey <*> (snd <$> dhKeyPair) + callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey, callVersion = Just callVersion} call' = Call {contactId, callId, callUUID, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} calls <- asks currentCalls -- theoretically, the new call invitation for the current contact can mark the in-progress call as ended @@ -3029,9 +3037,6 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CEvtCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci} toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] - else featureRejected CFCalls - where - brokerTs = metaBrokerTs msgMeta saveCallItem status = saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do let content = ciContentNoParse $ CIRcvChatFeatureRejected f @@ -3040,18 +3045,25 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- to party initiating call xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> CM () - xCallOffer ct callId CallOffer {callType, rtcSession, callDhPubKey} msg = do + xCallOffer ct callId CallOffer {callType, rtcSession, callDhPubKey, callVersion = peerCallVersion} msg = do + ChatConfig {callVRange = callVR} <- asks config msgCurrentCall ct callId "x.call.offer" msg $ \call -> case callState call of - CallInvitationSent {localCallType, localDhPrivKey} -> do - let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey) - callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} - askConfirmation = encryptedCall localCallType && not (encryptedCall callType) - toView CEvtCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} - pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) + CallInvitationSent {localCallType, localDhPrivKey} + | callVersion `isCompatible` callVR -> do + let sharedKey = callMediaKey callVersion callId <$> callDhPubKey <*> localDhPrivKey + callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} + askConfirmation = encryptedCall localCallType && not (encryptedCall callType) + toView CEvtCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} + pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) + | otherwise -> do + messageError "x.call.offer: unsupported call version" + pure (Just call, Nothing) _ -> do msgCallStateError "x.call.offer" call pure (Just call, Nothing) + where + callVersion = fromMaybe initialCallVersion peerCallVersion -- to party accepting call xCallAnswer :: Contact -> CallId -> CallAnswer -> RcvMessage -> CM () diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 6498279f77..c3d9f2c0bd 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -77,6 +77,7 @@ module Simplex.Chat.Store.Profiles createCall, deleteCalls, getCalls, + expireCalls, createCommand, setCommandConnId, deleteCommand, @@ -103,6 +104,7 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime) import Simplex.Chat.Badges (LocalBadge, localBadgeToRow) import Simplex.Chat.Call import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct @@ -126,7 +128,7 @@ import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) #if defined(dbPostgres) -import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple (In (..), Only (..), Query, (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) #else import Database.SQLite.Simple (Only (..), Query, (:.) (..)) @@ -1084,6 +1086,26 @@ getCalls db = toCall :: (ContactId, CallId, Text, ChatItemId, CallState, UTCTime) -> Call toCall (contactId, callId, callUUID, chatItemId, callState, callTs) = Call {contactId, callId, callUUID, chatItemId, callState, callTs} +-- only received call invitations are stored, so their chat items are pending and become missed +expireCalls :: DB.Connection -> UTCTime -> IO () +expireCalls db cutoffTs = do + itemIds :: [ChatItemId] <- map fromOnly <$> DB.query db "DELETE FROM calls WHERE call_ts < ? RETURNING chat_item_id" (Only cutoffTs) + currentTs <- getCurrentTime + let content = CIRcvCall CISCallMissed 0 + contentText = ciContentToText content + unless (null itemIds) $ +#if defined(dbPostgres) + DB.execute + db + "UPDATE chat_items SET item_content = ?, item_text = ?, updated_at = ? WHERE chat_item_id IN ?" + (content, contentText, currentTs, In itemIds) +#else + DB.executeMany + db + "UPDATE chat_items SET item_content = ?, item_text = ?, updated_at = ? WHERE chat_item_id = ?" + (map (content,contentText,currentTs,) itemIds) +#endif + createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId createCommand db User {userId} connId commandFunction = do currentTs <- getCurrentTime 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 046d73936e..ec488dec3b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -6647,6 +6647,10 @@ Error: SQLite3 returned ErrorError while attempting to perform prepare "explain Query: DELETE FROM app_settings Plan: +Query: DELETE FROM calls WHERE call_ts < ? RETURNING chat_item_id +Plan: +SCAN calls + Query: DELETE FROM calls WHERE user_id = ? AND contact_id = ? Plan: SEARCH calls USING INDEX idx_calls_contact_id (contact_id=?) @@ -7494,6 +7498,10 @@ Query: SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND loc Plan: SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +Query: SELECT count(1) FROM calls +Plan: +SCAN calls USING COVERING INDEX idx_calls_contact_id + Query: SELECT count(1) FROM chat_items WHERE chat_item_id > ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid>?) @@ -7804,6 +7812,10 @@ Query: UPDATE badge_purchases SET next_wake_at = ? WHERE badge_purchase_id = ? Plan: SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE chat_items SET item_content = ?, item_text = ?, updated_at = ? WHERE chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + 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/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 2055eff232..af1b140d2f 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -33,7 +33,7 @@ import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), PresetServ import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) +import Simplex.Chat.Types (ContactId, VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -105,6 +105,8 @@ chatDirectTests = do it "connect, fully asynchronous (when clients are never simultaneously online)" $ testFullAsyncFast describe "webrtc calls api" $ do it "negotiate call" testNegotiateCall + it "negotiate call between current and v1 clients" testNegotiateCallV1 + it "mark expired call invitation missed on restore" testExpireCallInvitation describe "maintenance mode" $ do it "stop chat stops all threads, start chat restarts them" testStopStartChat #if !defined(dbPostgres) @@ -1369,6 +1371,65 @@ testNegotiateCall = ws@(_ : _) | "(0" `isPrefixOf` last ws -> unwords $ init ws _ -> s +testCfgCallV1 :: ChatConfig +testCfgCallV1 = testCfg {callVRange = callInitialVRange} + +currentCall :: TestCC -> ContactId -> IO Call +currentCall cc ctId = M.lookup ctId <$> readTVarIO (currentCalls $ chatController cc) >>= maybe (fail "no current call") pure + +callRowCount :: TestCC -> IO Int +callRowCount cc = do + [Only n] <- withCCTransaction cc $ \db -> DB.query_ db "SELECT count(1) FROM calls" + pure n + +inviteToCall :: HasCallStack => TestCC -> TestCC -> IO () +inviteToCall alice bob = do + alice ##> ("/_call invite @2 " <> serialize testCallType) + alice <## "ok" + bob <## "alice wants to connect with you via WebRTC video call (e2e encrypted)" + repeatM_ 3 $ getTermLine bob + +testNegotiateCallV1 :: TestParams -> IO () +testNegotiateCallV1 = + runTestCfg2 testCfg testCfgCallV1 $ \alice bob -> do + connectUsers alice bob + inviteToCall alice bob + Call {callState = CallInvitationSent {localDhPrivKey = Just alicePrivKey}} <- currentCall alice 2 + Call {callState = CallInvitationReceived {localDhPubKey = Just bobPubKey, sharedKey = Just bobKey, callVersion}} <- currentCall bob 2 + callVersion `shouldBe` Just initialCallVersion + bobKey `shouldBe` C.Key (C.dhBytes' $ C.dh' bobPubKey alicePrivKey) + bob ##> ("/_call offer @2 " <> serialize testWebRTCCallOffer) + bob <## "ok" + alice <## "bob accepted your WebRTC video call (e2e encrypted)" + repeatM_ 3 $ getTermLine alice + Call {callState = CallOfferReceived {sharedKey = aliceKey}} <- currentCall alice 2 + aliceKey `shouldBe` Just bobKey + alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) + alice <## "ok" + bob <## "alice continued the WebRTC call" + repeatM_ 3 $ getTermLine bob + bob ##> "/_call end @2" + bob <## "ok" + alice <## "call with bob ended" + +testExpireCallInvitation :: HasCallStack => TestParams -> IO () +testExpireCallInvitation ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do + connectUsers alice bob + inviteToCall alice bob + callRowCount bob `shouldReturn` 1 + withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" + Call {callState = CallInvitationReceived {}} <- currentCall bob 2 + callRowCount bob `shouldReturn` 1 + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "incoming call: calling...")]) + withTestChatCfg ps testCfg {callInvitationTTL = 0} "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" + M.member 2 <$> readTVarIO (currentCalls $ chatController bob) `shouldReturn` False + callRowCount bob `shouldReturn` 0 + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "incoming call: missed")]) + testStopStartChat :: HasCallStack => TestParams -> IO () testStopStartChat ps = withNewTestChat ps "bob" bobProfile $ \bob -> diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 0c166fe71c..e341a210a7 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -15,6 +15,7 @@ import Data.List (isInfixOf) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) +import Simplex.Chat.Call import Simplex.Chat.Library.Internal (decodeLinkUserData, encodeShortLinkData) import Simplex.Chat.Protocol import Simplex.Chat.Types @@ -420,3 +421,17 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.ok" $ "{\"v\":\"9\",\"event\":\"x.ok\",\"params\":{}}" ==# XOk + it "x.call.inv without call version range" $ + "{\"v\":\"9\",\"event\":\"x.call.inv\",\"params\":{\"callId\":\"AQIDBA==\",\"invitation\":{\"callType\":{\"media\":\"video\",\"capabilities\":{\"encryption\":true}}}}}" + #==# XCallInv (CallId "\1\2\3\4") CallInvitation {callType = defaultCallType, callDhPubKey = Nothing, callVRange = Nothing} + it "x.call.inv with call version range" $ + "{\"v\":\"9\",\"event\":\"x.call.inv\",\"params\":{\"callId\":\"AQIDBA==\",\"invitation\":{\"callType\":{\"media\":\"video\",\"capabilities\":{\"encryption\":true}},\"callVRange\":\"1-2\"}}}" + #==# XCallInv (CallId "\1\2\3\4") CallInvitation {callType = defaultCallType, callDhPubKey = Nothing, callVRange = Just $ CallVersionRange $ mkVersionRange (VersionCall 1) (VersionCall 2)} + it "x.call.offer without call version" $ + "{\"v\":\"9\",\"event\":\"x.call.offer\",\"params\":{\"callId\":\"AQIDBA==\",\"offer\":{\"callType\":{\"media\":\"video\",\"capabilities\":{\"encryption\":true}},\"rtcSession\":{\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"}}}}" + #==# XCallOffer (CallId "\1\2\3\4") CallOffer {callType = defaultCallType, rtcSession = testRTCSession, callDhPubKey = Nothing, callVersion = Nothing} + it "x.call.offer with call version" $ + "{\"v\":\"9\",\"event\":\"x.call.offer\",\"params\":{\"callId\":\"AQIDBA==\",\"offer\":{\"callType\":{\"media\":\"video\",\"capabilities\":{\"encryption\":true}},\"rtcSession\":{\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"},\"callVersion\":2}}}" + #==# XCallOffer (CallId "\1\2\3\4") CallOffer {callType = defaultCallType, rtcSession = testRTCSession, callDhPubKey = Nothing, callVersion = Just $ VersionCall 2} + where + testRTCSession = WebRTCSession {rtcSession = "{}", rtcIceCandidates = "[]"} From 86671a1699b3a6170cff38f80ecc11092182f56e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:45:57 +0000 Subject: [PATCH 11/94] core, ui: confirm before connecting a call answered without e2e encryption (#7647) Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 57 +++++++++++++++---- .../chat/simplex/common/model/SimpleXAPI.kt | 50 ++++++++++++---- .../commonMain/resources/MR/base/strings.xml | 2 + src/Simplex/Chat/Library/Subscriber.hs | 2 +- 4 files changed, 86 insertions(+), 25 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index af5f3c3ee9..be2cde7cd6 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2914,23 +2914,19 @@ func processReceivedMsg(_ res: ChatEvent) async { m.callInvitations[invitation.contact.id] = invitation } activateCall(invitation) - case let .callOffer(_, contact, callType, offer, sharedKey, _): + case let .callOffer(_, contact, callType, offer, sharedKey, askConfirmation): await withCall(contact) { call in await MainActor.run { call.callState = .offerReceived call.sharedKey = sharedKey } - let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) - let iceServers = getIceServers() - logger.debug(".callOffer useRelay \(useRelay)") - logger.debug(".callOffer iceServers \(String(describing: iceServers))") - await m.callCommand.processCommand(.offer( - offer: offer.rtcSession, - iceCandidates: offer.rtcIceCandidates, - media: callType.media, aesKey: sharedKey, - iceServers: iceServers, - relay: useRelay - )) + if askConfirmation { + showUnencryptedCallAlert(call) { + Task { await processCallOffer(callType, offer, sharedKey) } + } + } else { + await processCallOffer(callType, offer, sharedKey) + } } case let .callAnswer(_, contact, answer): await withCall(contact) { call in @@ -3067,6 +3063,43 @@ func processReceivedMsg(_ res: ChatEvent) async { logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") } } + + func processCallOffer(_ callType: CallType, _ offer: WebRTCSession, _ sharedKey: String?) async { + let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) + let iceServers = getIceServers() + logger.debug(".callOffer useRelay \(useRelay)") + logger.debug(".callOffer iceServers \(String(describing: iceServers))") + await m.callCommand.processCommand(.offer( + offer: offer.rtcSession, + iceCandidates: offer.rtcIceCandidates, + media: callType.media, aesKey: sharedKey, + iceServers: iceServers, + relay: useRelay + )) + } + + func showUnencryptedCallAlert(_ call: Call, onContinue: @escaping () -> Void) { + DispatchQueue.main.async { + showAlert( + NSLocalizedString("Call is not encrypted", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("%@ accepted the call without end-to-end encryption.", comment: "alert message"), call.contact.displayName), + actions: {[ + UIAlertAction(title: NSLocalizedString("End call", comment: "alert action"), style: .destructive) { _ in + // the call may have ended, or a new one started with the same contact, while the alert was shown + guard m.activeCall === call else { return } + if let uuid = call.callUUID { + CallController.shared.endCall(callUUID: uuid) + } else { + CallController.shared.endCall(call: call) {} + } + }, + UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in + if m.activeCall === call { onContinue() } + } + ]} + ) + } + } } func switchToLocalSession() { 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 96515fbde2..e3bea95c03 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 @@ -3371,20 +3371,13 @@ object ChatController { chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId)) } is CR.CallOffer -> { - // TODO askConfirmation? - // TODO check encryption is compatible withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, hasSharedKey = r.sharedKey != null) - val useRelay = appPrefs.webrtcPolicyRelay.get() - val iceServers = getIceServers() - chatModel.callCommand.add(WCallCommand.Offer( - offer = r.offer.rtcSession, - iceCandidates = r.offer.rtcIceCandidates, - media = r.callType.media, - aesKey = r.sharedKey, - iceServers = iceServers, - relay = useRelay - )) + if (r.askConfirmation) { + showUnencryptedCallAlert(call) { processCallOffer(r) } + } else { + processCallOffer(r) + } } } is CR.CallAnswer -> { @@ -3640,6 +3633,39 @@ object ChatController { } } + private fun processCallOffer(r: CR.CallOffer) { + val useRelay = appPrefs.webrtcPolicyRelay.get() + val iceServers = getIceServers() + chatModel.callCommand.add(WCallCommand.Offer( + offer = r.offer.rtcSession, + iceCandidates = r.offer.rtcIceCandidates, + media = r.callType.media, + aesKey = r.sharedKey, + iceServers = iceServers, + relay = useRelay + )) + } + + private fun showUnencryptedCallAlert(call: Call, onContinue: () -> Unit) { + // the call may have ended, or a new one started with the same contact, while the alert was shown + fun offerPending(): Boolean { + val c = chatModel.activeCall.value + return c != null && c.remoteHostId == call.remoteHostId && c.contact.id == call.contact.id && c.callState == CallState.OfferReceived + } + val endCall = { if (offerPending()) withBGApi { chatModel.callManager.endCall(call) } } + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.call_not_encrypted_title), + text = generalGetString(MR.strings.call_not_encrypted_desc).format(call.contact.displayName), + confirmText = generalGetString(MR.strings.call_service_notification_end_call), + onConfirm = { endCall() }, + dismissText = generalGetString(MR.strings.continue_to_next_step), + onDismiss = { if (offerPending()) onContinue() }, + onDismissRequest = { endCall() }, + destructive = true, + parseHtml = false + ) + } + suspend fun leaveGroup(rh: Long?, groupId: Long) { val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != 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 efe0423aff..8a8aad0cfa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1415,6 +1415,8 @@ no e2e encryption contact has e2e encryption contact has no e2e encryption + Call is not encrypted + %s accepted the call without end-to-end encryption. peer-to-peer via relay Hang up diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 19b78dc2ae..71337a6b0e 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3053,7 +3053,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | callVersion `isCompatible` callVR -> do let sharedKey = callMediaKey callVersion callId <$> callDhPubKey <*> localDhPrivKey callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} - askConfirmation = encryptedCall localCallType && not (encryptedCall callType) + askConfirmation = encryptedCall localCallType && isNothing sharedKey toView CEvtCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) | otherwise -> do From 905df659d3f397c23d0c43ea0f3727c0dd65cbb7 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 5 Oct 2026 09:46:55 +0100 Subject: [PATCH 12/94] ios: show error when random database passphrase generation fails (#7621) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Shared/Views/Database/DatabaseErrorView.swift | 5 +++++ .../Shared/Views/Migration/MigrateFromDevice.swift | 2 ++ .../Shared/Views/Migration/MigrateToDevice.swift | 4 +++- apps/ios/SimpleX SE/ShareModel.swift | 5 +++++ apps/ios/SimpleXChat/API.swift | 14 ++++++++++---- apps/ios/SimpleXChat/KeyChain.swift | 4 ++-- apps/ios/SimpleXChat/Notifications.swift | 2 +- apps/ios/spec/database.md | 1 + 8 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index f7f253a617..4c4df931cb 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -118,6 +118,9 @@ struct DatabaseErrorView: View { case .errorKeychain: titleText("Keychain error") errorView(Text("Cannot access keychain to save database password")) + case .errorKeyGeneration: + titleText("Database error") + errorView(Text("Cannot generate random database passphrase")) case .invalidConfirmation: // this can only happen if incorrect parameter is passed titleText("Invalid migration confirmation") @@ -217,6 +220,8 @@ struct DatabaseErrorView: View { ) case .errorKeychain: am.showAlertMsg(title: "Keychain error") + case .errorKeyGeneration: + am.showAlertMsg(title: "Database error", message: "Cannot generate random database passphrase") case let .errorSQL(_, error): am.showAlert(Alert( title: Text("Database error"), diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 2ff376701c..c262d1165a 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -709,6 +709,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: alert.wrappedValue = .wrongPassphrase() case .errorKeychain: alert.wrappedValue = .keychainError() + case .errorKeyGeneration: + alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message")) case let .errorSQL(_, error): alert.wrappedValue = .databaseError(message: error) case let .unknown(error): diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index cb3832b727..4181f6e73f 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -539,7 +539,7 @@ struct MigrateToDevice: View { Task { do { if !hasChatCtrl() { - chatInitControllerRemovingDatabases() + try chatInitControllerRemovingDatabases() } else if ChatModel.shared.chatRunning == true { // cannot delete storage if chat is running try await stopChatAsync() @@ -735,6 +735,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: alert.wrappedValue = .wrongPassphrase() case .errorKeychain: alert.wrappedValue = .keychainError() + case .errorKeyGeneration: + alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message")) case let .errorSQL(_, error): alert.wrappedValue = .databaseError(message: error) case let .unknown(error): diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index ea1bbbd46f..bd0354ce51 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -247,6 +247,11 @@ class ShareModel: ObservableObject { title: "Keychain error", message: "Cannot access keychain to save database password" ) + case .errorKeyGeneration: + ErrorAlert( + title: "Database error", + message: "Cannot generate random database passphrase" + ) case .invalidConfirmation: ErrorAlert("Invalid migration confirmation") case let .unknown(json): diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index bc23e53011..3b860d524b 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -32,7 +32,12 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio } else if useKeychain { if !hasDatabase() { logger.debug("chatMigrateInit generating a random DB key") - dbKey = randomDatabasePassword() + guard let key = randomDatabasePassword() else { + let result = (false, DBMigrationResult.errorKeyGeneration) + migrationResult = result + return result + } + dbKey = key initialRandomDBPassphraseGroupDefault.set(true) } else if let key = kcDatabasePassword.get() { dbKey = key @@ -56,7 +61,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) { let dbPath = url.path - let dbKey = key ?? randomDatabasePassword() + guard let dbKey = key ?? randomDatabasePassword() else { return (.errorKeyGeneration, nil) } logger.debug("chatInitTemporaryDatabase path: \(dbPath)") var temporaryController: chat_ctrl? = nil var cPath = dbPath.cString(using: .utf8)! @@ -66,14 +71,14 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation return (dbMigrationResult(dataFromCString(cjson)), temporaryController) } -public func chatInitControllerRemovingDatabases() { +public func chatInitControllerRemovingDatabases() throws { let dbPath = getAppDatabasePath().path let fm = FileManager.default // Remove previous databases, otherwise, can be .errorNotADatabase with nil controller try? fm.removeItem(atPath: dbPath + CHAT_DB) try? fm.removeItem(atPath: dbPath + AGENT_DB) - let dbKey = randomDatabasePassword() + guard let dbKey = randomDatabasePassword() else { throw RuntimeError("Cannot generate random database passphrase") } logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)") var cPath = dbPath.cString(using: .utf8)! var cKey = dbKey.cString(using: .utf8)! @@ -353,6 +358,7 @@ public enum DBMigrationResult: Decodable, Equatable { case errorMigration(dbFile: String, migrationError: MigrationError) case errorSQL(dbFile: String, migrationSQLError: String) case errorKeychain + case errorKeyGeneration case unknown(json: String) } diff --git a/apps/ios/SimpleXChat/KeyChain.swift b/apps/ios/SimpleXChat/KeyChain.swift index 50a5946462..7806595ecf 100644 --- a/apps/ios/SimpleXChat/KeyChain.swift +++ b/apps/ios/SimpleXChat/KeyChain.swift @@ -37,7 +37,7 @@ public struct KeyChainItem { } } -func randomDatabasePassword() -> String { +func randomDatabasePassword() -> String? { var keyData = Data(count: 32) let status = keyData.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!) @@ -46,7 +46,7 @@ func randomDatabasePassword() -> String { return keyData.base64EncodedString() } else { logger.error("randomDatabasePassword: error \(status)") - return "" + return nil } } diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index a40e8eda99..552805e381 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -138,7 +138,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> title = NSLocalizedString("Encrypted message: no passphrase", comment: "notification") case .errorMigration: title = NSLocalizedString("Encrypted message: database migration error", comment: "notification") - case .errorSQL: + case .errorSQL, .errorKeyGeneration: title = NSLocalizedString("Encrypted message: database error", comment: "notification") case .errorKeychain: title = NSLocalizedString("Encrypted message: keychain error", comment: "notification") diff --git a/apps/ios/spec/database.md b/apps/ios/spec/database.md index cb08abe2aa..14fee1cb1c 100644 --- a/apps/ios/spec/database.md +++ b/apps/ios/spec/database.md @@ -134,6 +134,7 @@ Migration results are decoded in Swift as `DBMigrationResult`: - `.errorMigration(dbFile:, migrationError:)` -- migration failed - `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration - `.errorKeychain` -- keychain access failed +- `.errorKeyGeneration` -- random database key generation failed - `.unknown(json:)` -- unrecognized response --- From 680790695ecc58661a30ab8bfbd8f7858a19073b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 5 Oct 2026 11:45:16 +0100 Subject: [PATCH 13/94] core: update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 897de4e90b..2641ca72c1 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 38fdc0c544ac950c0662ebb16f278a31805fb81a + tag: 4ab5376a6e7032579d90ec17a634a2bf85104176 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0ef8572f59..6591228fae 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."38fdc0c544ac950c0662ebb16f278a31805fb81a" = "1z7wa93vs62if34qkiyjlz63j4b2nlfa9qiay09c1cg0g5yblyqp"; + "https://github.com/simplex-chat/simplexmq.git"."4ab5376a6e7032579d90ec17a634a2bf85104176" = "0n6vf6ikccrjif32mq0nkivyz12f1psxccbp9zrcc0nqgmgihwvc"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From df95de8a2c2e2872357f8bf53f6cb335bc845e99 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 7 Oct 2026 08:12:02 +0100 Subject: [PATCH 14/94] tests, fix: execute in parallel, send connected events after creating chat items, fix race when merging multiple members to one contact (#7500) * tests: execute in parallel * remove race in terminal * fix some tests * stop chat controller on exit * fix more tests * fix tests * query plans * update simplexmq * update direct-sqlcipher * fix tests * query plans * ios: update core * fix tests * update simplexmq and direct-sqlcipher, fixes * simplexmq * update simplexmq * parallel channel tests * update tests * fix tests * fix tests * query plans * fix test * more tests in CI * use ram drive in CI * fix tests, fix directory deleting voice file before it is uploaded * fix query * fix query to use index * link contact and channel owner profile when connecting via a signed link * update simplexmq, fix ghc 8.10.7 * fix tests * avoid double stop * query plans * fix more test races * more test fixes * fix queries * fixes * more fixes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .github/workflows/build.yml | 8 + apps/simplex-directory-service/Main.hs | 2 +- .../src/Directory/Events.hs | 7 + .../src/Directory/Service.hs | 13 +- cabal.project | 4 +- plans/2026-09-13-parallel-tests.md | 81 +++ scripts/nix/sha256map.nix | 4 +- simplex-chat.cabal | 3 +- src/Simplex/Chat/Core.hs | 28 +- src/Simplex/Chat/Library/Subscriber.hs | 17 +- src/Simplex/Chat/Store/Direct.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 28 +- .../SQLite/Migrations/agent_query_plans.txt | 165 +++++- .../SQLite/Migrations/chat_query_plans.txt | 26 +- src/Simplex/Chat/Types.hs | 7 + tests/Bots/BadgeService/BotTests.hs | 112 +++-- .../BadgeService/GroupIntegrationTests.hs | 7 +- tests/Bots/BadgeService/WebTests.hs | 43 +- tests/Bots/BroadcastTests.hs | 16 +- tests/Bots/DirectoryTests.hs | 469 +++++++++--------- tests/ChatClient.hs | 163 ++++-- tests/ChatTests/ChatRelays.hs | 4 +- tests/ChatTests/DBUtils/Postgres.hs | 1 + tests/ChatTests/DBUtils/SQLite.hs | 1 + tests/ChatTests/Direct.hs | 258 +++++----- tests/ChatTests/Files.hs | 413 ++++++++------- tests/ChatTests/Forward.hs | 110 ++-- tests/ChatTests/Groups.hs | 430 +++++++++------- tests/ChatTests/Local.hs | 19 +- tests/ChatTests/Names.hs | 22 +- tests/ChatTests/Profiles.hs | 321 ++++++------ tests/ChatTests/Utils.hs | 43 +- tests/RemoteTests.hs | 54 +- tests/SchemaDump.hs | 7 +- tests/Test.hs | 52 +- 35 files changed, 1773 insertions(+), 1167 deletions(-) create mode 100644 plans/2026-09-13-parallel-tests.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d92e2507c9..c20fce8c25 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -360,6 +360,14 @@ jobs: sudo chmod -R 777 dist-newstyle ~/.cabal sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + - name: Mount RAM disk for tests + if: matrix.should_run == true && matrix.arch == 'x86_64' + shell: bash + run: | + mkdir -p tests/tmp + sudo mount -t tmpfs -o size=512M tmpfs tests/tmp + sudo chown $(id -u):$(id -g) tests/tmp + - name: Run tests if: matrix.should_run == true && matrix.arch == 'x86_64' timeout-minutes: 120 diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index f771199dea..228c9329c2 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -12,4 +12,4 @@ main = do opts@DirectoryOpts {runCLI} <- welcomeGetOpts if runCLI then directoryServiceCLI opts - else directoryService opts terminalChatConfig + else newServiceState opts >>= directoryService opts terminalChatConfig diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index bc84ab86e1..7d6fc609b0 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -66,6 +66,7 @@ data DirectoryEvent | DEItemEditIgnored Contact | DEItemDeleteIgnored Contact | DEContactCommand Contact ChatItemId ADirectoryCmd + | DEVoiceUploadEnded FilePath | DELogChatResponse Text deriving (Show) @@ -110,11 +111,17 @@ crDirectoryEvent_ = \case where ciId = chatItemId' ci err = ADC SDRUser DCUnknownCommand + CEvtSndFileCompleteXFTP {chatItem, fileTransferMeta} -> voiceUploadEnded (Just chatItem) fileTransferMeta + CEvtSndFileError {chatItem_, fileTransferMeta} -> voiceUploadEnded chatItem_ fileTransferMeta CEvtMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage CEvtChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) _ -> Nothing where pending m = memberStatus m == GSMemPendingApproval + voiceUploadEnded :: Maybe AChatItem -> FileTransferMeta -> Maybe DirectoryEvent + voiceUploadEnded ci_ FileTransferMeta {filePath} = case ci_ of + Just (AChatItem _ SMDSnd _ ChatItem {content = CISndMsgContent MCVoice {}}) -> Just $ DEVoiceUploadEnded filePath + _ -> Nothing data DirectoryRole = DRUser | DRAdmin | DRSuperUser diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index f10cbf44fd..76c3f43400 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -12,7 +12,9 @@ {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Service - ( welcomeGetOpts, + ( ServiceState (..), + welcomeGetOpts, + newServiceState, directoryService, directoryServiceCLI, ) @@ -245,9 +247,8 @@ directoryCommands = where idParam = Just "" -directoryService :: DirectoryOpts -> ChatConfig -> IO () -directoryService opts cfg = do - env@ServiceState {eventQ} <- newServiceState opts +directoryService :: DirectoryOpts -> ChatConfig -> ServiceState -> IO () +directoryService opts cfg env@ServiceState {eventQ} = do let chatHooks = defaultChatHooks { preStartHook = Just $ directoryPreStartHook opts, @@ -340,6 +341,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o SDRUser -> deUserCommand ct ciId cmd SDRAdmin -> deAdminCommand ct ciId cmd SDRSuperUser -> deSuperUserCommand ct ciId cmd + DEVoiceUploadEnded filePath -> void (try $ removeFile filePath :: IO (Either SomeException ())) DELogChatResponse r -> logInfo r where groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt $ simplexChatContact cReq) strEncodeTxt sLnk_ @@ -651,9 +653,8 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o case voiceResult of Right r -> case lines r of (filePath : durationStr : _) - | not (null filePath), Just duration <- readMaybe durationStr -> do + | not (null filePath), Just duration <- readMaybe durationStr -> sendComposedMessageFile cc sendRef Nothing (MCVoice "" duration) (CF.plain filePath) - void (try $ removeFile filePath :: IO (Either SomeException ())) _ -> logError "voice captcha generator: unexpected output" Left e -> logError $ "voice captcha generator error: " <> tshow e diff --git a/cabal.project b/cabal.project index 2641ca72c1..ed446d492c 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 4ab5376a6e7032579d90ec17a634a2bf85104176 + tag: c0a1b7bd2d79c4af5163d9a863cb0780f556d7a9 source-repository-package type: git @@ -31,7 +31,7 @@ source-repository-package source-repository-package type: git location: https://github.com/simplex-chat/direct-sqlcipher.git - tag: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9 + tag: 2330df3dc8c4b660674d3c02d76bb86f66c2abee source-repository-package type: git diff --git a/plans/2026-09-13-parallel-tests.md b/plans/2026-09-13-parallel-tests.md new file mode 100644 index 0000000000..461c970b84 --- /dev/null +++ b/plans/2026-09-13-parallel-tests.md @@ -0,0 +1,81 @@ +# Parallel test execution + +Every test gets its own ports and its own directory, so hspec runs the tree +concurrently in one process with `parallel` and `--jobs`. + +## Decisions + +- `TestParams` gains `portBase :: Int`. A pool of bases `[7000, 7010 .. 8990]` + is created in `main`; `testBracket` takes a base for the test and returns it + after. Ports of a test: SMP `base + 1`, XFTP `base + 2`, second SMP `base + 3`, + remote host `base + 4`. +- `TestCC` carries `testParams`. `class HasTestParams` with instances for + `TestParams` and `TestCC` lets helpers take either, so closure tests reach + the environment through a client: `tmpFile bob "test.jpg"`, `smpPort alice`, + `withXFTPServer alice`. +- Client configs keep the literal ports 7001, 7002 and 7003 as the logical + layout. `startTestChat_` maps them to the test's ports in `smpServers`, + `xftpServers` and `shortLinkPresetServers`. Expected output uses the real + ports through `smpServerStr`, `xftpServerStr`, `smpPort`, `xftpPort`, + `smpPort2`. +- Servers take the test: `smpServerCfg ps`, `withSmpServer ps`, + `withSmpServerAndNames ps`, `xftpServerConfig ps`, `withXFTPServer ps`. + The XFTP server stays under test control, since tests exercise its restarts. + Its files and store log are under the test directory. +- `tests/tmp` is created once around the whole run (`withTmpFiles` in `main`) + and each test works in `withTempDirectory "tests/tmp"`. Paths in tests are + `tmpDir` and `tmpFile` of the client or params. +- `xftpCLI` runs the test binary as a subprocess with the first argument + `xftp-cli`, because `withArgs` and `capture_` are process-global. +- Test suite `ghc-options` gain `-rtsopts "-with-rtsopts=-N"`. +- `parallel` wraps the tree. Sequential remain: schema dump, API docs, + "Save query plans" (last item, reads the maps after every test). +- Postgres: the database is created once (`beforeAll_`/`afterAll_`); the + client schema prefix includes the test directory name. +- Bots started through `directoryService`, `badgeService` and + `simplexChatCore` take `coreOptions` and the config from `testPortsCfg`. +- `TestTerminal` wraps the virtual terminal and writes the cursor row to + `termQ` on `PutLn`. +- The "no output left" check runs in the bracket body; `stopTestChat` stops + the client in a separate thread with a 60 s limit and keeps the store open + after the limit. +- Per-test limit 180 s; the "channels" group is `sequential`; `@@@` waits + 500 ms. TTL and timed-message tests use multi-second TTLs; ratchet + desynchronization helpers turn receipts off. + +## Status + +Implemented; the suite builds. Runs so far: file tests, 39 examples in 111 s +with `-j 8` on 4 cores at -O0, one timing failure per run, a different test +each time. "error receiving file" (`testXFTPRcvError`) fails intermittently +even alone with `chat db error: SERcvFileInvalidDescrPart`: `appendRcvFD` +receives a description part for a description that is already complete, so +the part arrives twice. The first block stops bob right after alice's upload +completes, without waiting for bob to process the description, which is the +likely source of the duplicate after his restart. The race predates this +change; `-N` and load make it more likely. + +Direct, schema dump, protocol and names groups: 178 examples in 331 s with +`-j 6`, 11 failures. Two are environmental on this machine: the schema dump +cannot overwrite root-owned `chat_schema.sql`, and `encrypt/decrypt database` +needs `direct-sqlcipher +openssl`, which CI sets and the local build lacks. +The rest pass alone and fail under load: expiration and timed-message tests +with second-scale TTLs, the client-timeout retry test, and cases where a +line already consumed reappears at the end of the test, which points at the +window diff in `readTerminalOutput` under batched terminal updates. + +Profile tests at the default job count (4 cores): 105 examples in 439 s, +2 failures, both timed-message tests with second-scale TTLs. + +## Mechanics + +- `tests/ChatTests/DBUtils/*.hs`: `portBase` field. +- `tests/ChatClient.hs`: `HasTestParams`, port and path helpers, server + configs and brackets as functions of the test, port mapping in + `startTestChat_`, `withTmpFiles` without removal per test. +- `tests/Test.hs`: port pool, `xftp-cli` subcommand, `parallel`, `withTmpFiles` + around `hspec`. +- `tests/SchemaDump.hs`: `withTmpFiles` wrappers removed. +- `tests/ChatTests/Utils.hs`: `xftpCLI` subprocess. +- Test modules: literal `tests/tmp` paths, server ports and XFTP brackets + rewritten through the helpers. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 6591228fae..1795f6e865 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."4ab5376a6e7032579d90ec17a634a2bf85104176" = "0n6vf6ikccrjif32mq0nkivyz12f1psxccbp9zrcc0nqgmgihwvc"; + "https://github.com/simplex-chat/simplexmq.git"."c0a1b7bd2d79c4af5163d9a863cb0780f556d7a9" = "0l10gc46l1i1wwalapg4wa0y1x3j4l4465242701k9lvvgywjh9z"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; + "https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9bd6019b1e..daa16691e6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -779,7 +779,7 @@ test-suite simplex-chat-test default-extensions: StrictData -- add -fhpc to ghc-options below to run tests with coverage - ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded + ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts "-with-rtsopts=-N" build-depends: QuickCheck ==2.14.* , aeson ==2.2.* @@ -842,5 +842,6 @@ test-suite simplex-chat-test build-depends: bytestring ==0.10.* , hspec ==2.7.* + , hspec-core ==2.7.* , process >=1.6 && <1.6.18 , text >=1.2.4.0 && <1.3 diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index ccf6f15bdb..942b07dea0 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -14,7 +14,7 @@ module Simplex.Chat.Core where import Control.Concurrent (forkIO) -import Control.Exception (mask, onException, throwTo) +import Control.Exception (fromException, mask, onException, throwTo) import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -93,16 +93,22 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha runSimplexChat :: ChatConfig -> ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatRelay, chatRelayServer, headless, maintenance}} u cc@ChatController {config = ChatConfig {chatHooks}} chat | maintenance = wait =<< async (chat u cc) - | otherwise = flip finally (stopChatController cc) $ do - a1 <- runReaderT (startChatController True True False) cc - when (chatRelay && not testView) $ askCreateRelayAddress cc u chatRelayServer headless - forM_ (postStartHook chatHooks) ($ cc) - -- throwTo waits while the callback is masked, so an outside interrupt cancels it from a forked thread. - -- A /_stop sent from the callback ends a1 first, which leaves the callback running. - mask $ \restore -> do - a2 <- asyncWithUnmask $ \unmask -> unmask (chat u cc) - let cancelCallback = poll a1 >>= \r -> when (isNothing r) $ void $ forkIO $ throwTo (asyncThreadId a2) AsyncCancelled - restore (waitEither_ a1 a2) `onException` cancelCallback + | otherwise = do + a1 <- runReaderT (startChatController True True False) cc `onException` stopChatController cc + flip finally (stopUnlessCancelled a1) $ do + when (chatRelay && not testView) $ askCreateRelayAddress cc u chatRelayServer headless + forM_ (postStartHook chatHooks) ($ cc) + -- throwTo waits while the callback is masked, so an outside interrupt cancels it from a forked thread. + -- A /_stop sent from the callback ends a1 first, which leaves the callback running. + mask $ \restore -> do + a2 <- asyncWithUnmask $ \unmask -> unmask (chat u cc) + let cancelCallback = poll a1 >>= \r -> when (isNothing r) $ void $ forkIO $ throwTo (asyncThreadId a2) AsyncCancelled + restore (waitEither_ a1 a2) `onException` cancelCallback + where + stopUnlessCancelled a1 = + poll a1 >>= \case + Just (Left e) | fromException e == Just AsyncCancelled -> pure () + _ -> stopChatController cc sendChatCmdStr :: ChatController -> String -> IO (Either ChatError ChatResponse) sendChatCmdStr cc s = runReaderT (execChatCommand CSLocal (encodeUtf8 $ T.pack s) 0) cc diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 71337a6b0e..3f0e53fd8a 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -659,7 +659,6 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag ct' = ct {activeConn = Just conn'} :: Contact -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) let createE2EItem = createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ Just pqEnc) Nothing -- TODO [short links] get contact request by contactRequestId, check encryption (UserContactRequest.pqSupport)? when (directOrUsed ct') $ case (preparedContact ct', contactRequestId' ct') of @@ -674,6 +673,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag withStore' (\db -> getContactRequest' db user connReqId) >>= \case Just UserContactRequest {pqSupport} | CR.pqSupportToEnc pqSupport == pqEnc -> pure () _ -> createE2EItem + toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) when (contactConnInitiated conn') $ do probeMatchingMembers ct' (contactConnIncognito ct') withStore' $ \db -> resetContactConnInitiated db user conn' @@ -884,7 +884,6 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag pure gInfo {membership = membership {memberStatus = GSMemConnected}} else pure gInfo pure (m {memberStatus = GSMemConnected}, gInfo') - toView $ CEvtUserJoinedGroup user gInfo' m' when (isRelay membership) $ do cc <- ask atomically $ channelProfileUpdated cc groupId groupProfile @@ -898,10 +897,14 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag let prepared = preparedGroup gInfo'' unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo'' memberConnectedChatItem gInfo'' scopeInfo m'' + toView $ CEvtUserJoinedGroup user gInfo' m' let welcomeMsgId_ = (\PreparedGroup {welcomeSharedMsgId = mId} -> mId) <$> prepared unless (memberPending membership || isJust welcomeMsgId_) $ maybeCreateGroupDescrLocal gInfo'' m'' ) - (memberConnectedChatItem gInfo'' scopeInfo m'') + ( do + memberConnectedChatItem gInfo'' scopeInfo m'' + toView $ CEvtUserJoinedGroup user gInfo' m' + ) where firstConnectedHost | useRelays' gInfo = do @@ -2897,7 +2900,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag pure m' Just mContactId -> do mCt <- withStore $ \db -> getContact db cxt user mContactId - if canUpdateProfile mCt + if contactUpdatableFromMember mCt then do (m', ct') <- withStore $ \db -> updateContactMemberProfile db cxt user m mCt p' unless (muteEventInChannel gInfo m') $ do @@ -2906,12 +2909,6 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag toView $ CEvtContactUpdated user mCt ct' pure m' else pure m - where - canUpdateProfile ct - | not (contactActive ct) = True - | otherwise = case contactConn ct of - Nothing -> True - Just conn -> not (connReady conn) || (authErrCounter conn >= 1) | otherwise = pure m where diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 0e68221056..76029b4a4f 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -1169,6 +1169,6 @@ getDirectChatTTL db ctId = getUserContactsToExpire :: DB.Connection -> User -> Int64 -> IO [ContactId] getUserContactsToExpire db User {userId} globalTTL = - map fromOnly <$> DB.query db ("SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId) + map fromOnly <$> DB.query db ("SELECT contact_id FROM contacts WHERE user_id = ? AND (chat_item_ttl > 0" <> cond <> ")") (Only userId) where cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index f9cb76782a..66a5dc7783 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -3105,10 +3105,10 @@ associateContactWithMemberRecord db [sql| UPDATE group_members - SET contact_id = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND group_member_id = ? + SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE (user_id = ? AND group_id = ? AND group_member_id = ?) OR contact_id = ? |] - (contactId, currentTs, userId, groupId, groupMemberId) + (contactId, memLDN, memProfileId, currentTs, userId, groupId, groupMemberId, contactId) DB.execute db [sql| @@ -3412,7 +3412,16 @@ setMemberContactStartedConnection db Contact {contactId} = do (BI True, currentTs, contactId) updateMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db cxt user@User {userId} m p' = do +updateMemberProfile db cxt user m@GroupMember {memberContactId} p' = case memberContactId of + Nothing -> updateUnlinkedMemberProfile db cxt user m p' + Just ctId -> do + ct <- getContact db cxt user ctId + if contactUpdatableFromMember ct + then fst <$> updateContactMemberProfile db cxt user m ct p' + else pure m + +updateUnlinkedMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember +updateUnlinkedMemberProfile db cxt user@User {userId} m p' = do currentTs <- liftIO getCurrentTime badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' let memberProfile = toLocalProfile profileId p' localAlias currentTs badgeVerified Nothing @@ -3494,8 +3503,7 @@ createNewUnknownGroupMember db cxt user@User {userId, userContactId} GroupInfo { createLinkOwnerMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe ContactId -> MemberId -> C.PublicKeyEd25519 -> ExceptT StoreError IO GroupMember createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do currentTs <- liftIO getCurrentTime - let memberProfile = profileFromName $ nameFromMemberId memberId - (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs + (localDisplayName, profileId) <- maybe (newOwnerProfile currentTs) contactNameAndProfile contactId_ indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3515,6 +3523,12 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI getGroupMemberById db cxt user groupMemberId where VersionRange minV maxV = vr cxt + newOwnerProfile currentTs = do + (ldn, pId, _) <- createNewMemberProfile_ db cxt user (profileFromName $ nameFromMemberId memberId) currentTs + pure (ldn, pId) + contactNameAndProfile ctId = do + Contact {localDisplayName = ldn, profile = LocalProfile {profileId = pId}} <- getContact db cxt user ctId + pure (ldn, pId) -- Intro refreshes only profile / status / peer version. Role and key stay owner-authoritative -- (the owner-signed roster for members/moderators/admins, link data for owners), so taking either from @@ -3650,7 +3664,7 @@ getGroupChatTTL db gId = getUserGroupsToExpire :: DB.Connection -> User -> Int64 -> IO [GroupId] getUserGroupsToExpire db User {userId} globalTTL = - map fromOnly <$> DB.query db ("SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId) + map fromOnly <$> DB.query db ("SELECT group_id FROM groups WHERE user_id = ? AND (chat_item_ttl > 0" <> cond <> ")") (Only userId) where cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" 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 833a1ae602..7c2e56c2b7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -293,6 +293,16 @@ Query: Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: + SELECT user_id FROM users u + WHERE u.deleted = ? + AND NOT EXISTS (SELECT c.conn_id FROM connections c WHERE c.user_id = u.user_id) + +Plan: +SCAN u +CORRELATED SCALAR SUBQUERY 1 +SEARCH c USING COVERING INDEX idx_connections_user (user_id=?) + Query: SELECT user_id FROM users u WHERE u.user_id = ? @@ -515,6 +525,23 @@ Query: Plan: SEARCH conn_confirmations USING INDEX idx_conn_confirmations_conn_id (conn_id=?) +Query: + SELECT conn_id, ( + SELECT processed_ratchet_key_hash_id + FROM processed_ratchet_key_hashes + WHERE conn_id = c.conn_id + ORDER BY processed_ratchet_key_hash_id DESC + LIMIT 1 OFFSET ? + ) AS max_excess_id + FROM processed_ratchet_key_hashes c + GROUP BY conn_id + HAVING COUNT(*) > ? + +Plan: +SCAN c USING COVERING INDEX idx_processed_ratchet_key_hashes_conn_id +CORRELATED SCALAR SUBQUERY 1 +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_conn_id (conn_id=?) + Query: SELECT conn_id, ratchet_state, sender_key, e2e_snd_pub_key, sender_conn_info, smp_reply_queues, smp_client_version FROM conn_confirmations @@ -587,6 +614,15 @@ 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 = ? @@ -611,6 +647,39 @@ Query: Plan: SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) +Query: + DELETE FROM encrypted_rcv_message_hashes + WHERE encrypted_rcv_message_hash_id IN ( + SELECT encrypted_rcv_message_hash_id + FROM encrypted_rcv_message_hashes + WHERE created_at < ? + ORDER BY created_at ASC + LIMIT ? + ) + +Plan: +SEARCH encrypted_rcv_message_hashes USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_created_at (created_at 0 Plan: SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) @@ -7382,11 +7390,7 @@ Query: SELECT chat_item_id FROM chat_items WHERE group_scope_tag = 'member_suppo Plan: SCAN chat_items -Query: SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND item_text LIKE '!2 SB-8H8V3-PF8CV-PQMA2-A54M3!%' -Plan: -SCAN chat_items - -Query: SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND item_text LIKE '!2 SB-Y14GX-Z83KW-0E3PK-DEN7X!%' +Query: SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND item_text LIKE ? || '%' Plan: SCAN chat_items @@ -7684,6 +7688,10 @@ Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_member Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT next_wake_at, badge_purchase_id FROM badge_purchases +Plan: +SCAN badge_purchases + Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? Plan: SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) @@ -7756,6 +7764,10 @@ Query: SELECT shared_msg_id FROM chat_items WHERE shared_msg_id IS NOT NULL ORDE Plan: SCAN chat_items +Query: SELECT short_descr FROM contact_profiles WHERE display_name = ? LIMIT 1 +Plan: +SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) + Query: SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1 Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 282d6b2fda..ce5879538b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -316,6 +316,13 @@ contactActive Contact {contactStatus} = contactStatus == CSActive contactDeleted :: Contact -> Bool contactDeleted Contact {contactStatus} = contactStatus == CSDeleted || contactStatus == CSDeletedByUser +contactUpdatableFromMember :: Contact -> Bool +contactUpdatableFromMember ct + | not (contactActive ct) = True + | otherwise = case contactConn ct of + Nothing -> True + Just conn -> not (connReady conn) || (authErrCounter conn >= 1) + contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode =<< activeConn diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 2f2dade67d..129824fed8 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -23,7 +23,8 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import ChatClient import ChatTests.DBUtils import ChatTests.Utils -import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (async, cancel) import Control.Concurrent.STM (TQueue, atomically, readTMVar) import Control.Monad (forM_, void, when) import Control.Exception (finally) @@ -50,8 +51,9 @@ import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) import Simplex.Chat.Bot.Store (withDB') import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatError (..), ChatErrorType (..), ChatResponse (CRCustomChatResponse)) import Simplex.Chat.Core (sendChatCmdStr) -import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) @@ -131,16 +133,16 @@ testIssuerKeyIdx :: Int testIssuerKeyIdx = 1 mkBadgeServiceOpts :: TestParams -> BBSSecretKey -> BadgeServiceOpts -mkBadgeServiceOpts TestParams {tmpPath = ps} secretKey = +mkBadgeServiceOpts ps secretKey = BadgeServiceOpts { coreOptions = - testCoreOpts + coreOpts { dbOptions = (dbOptions testCoreOpts) #if defined(dbPostgres) - {dbSchemaPrefix = "client_" <> serviceDbPrefix} + {dbSchemaPrefix = testSchemaPrefix ps serviceDbPrefix} #else - {dbFilePrefix = ps serviceDbPrefix} + {dbFilePrefix = tmpPath ps serviceDbPrefix} #endif }, serviceName = badgeBotName, @@ -151,6 +153,8 @@ mkBadgeServiceOpts TestParams {tmpPath = ps} secretKey = issuerKey = Right (Just BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey}), testing = True } + where + (_, ChatOpts {coreOptions = coreOpts}) = testPortsCfg ps testCfg testOpts -- | The clock tracks real time plus a test-controlled offset rather than freezing it, so a sleeping worker still waits the correct real duration. newtype TestClock = TestClock (IORef NominalDiffTime) @@ -190,7 +194,9 @@ withBadgeServiceEnv ps test = do let opts = mkBadgeServiceOpts ps sk svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk, badgeCurrentTime = testClockTime clock} withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () - runBadgeService svcCfg opts $ \_ -> pure () + -- First start: badge service takes the CreateMyAddress branch. + runBadgeService ps svcCfg opts $ \_ -> pure () + -- Reopen the DB to read the link the service created. bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do bs <## "subscribed 1 connections on server localhost" bs ##> "/sa" @@ -201,7 +207,8 @@ 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 + -- Second start: badge service takes the ShowMyAddress branch, then serves the test body. + runBadgeService ps 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} @@ -227,14 +234,16 @@ issueCodeAs cc badgeType months status = _ -> error $ "unexpected issue response: " <> T.unpack response 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 +-- | The post-start hook fills serviceCC once the address exists, so waiting on it is the service +-- being ready. A fixed delay here raced with startup and left the address output of one start +-- arriving during the next test. +runBadgeService :: TestParams -> ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () +runBadgeService ps cfg opts action = do env <- newServiceState - t <- forkIO $ badgeService opts cfg env + t <- async $ badgeService opts (fst $ testPortsCfg ps cfg testOpts) env ready <- timeout 30000000 $ atomically $ readTMVar $ serviceCC env - when (isNothing ready) $ killThread t >> error "badge service did not start" - action env `finally` killThread t + when (isNothing ready) $ cancel t >> error "badge service did not start" + action env `finally` (cancel t >> atomically (readTMVar $ serviceCC env) >>= disposeAgentClient . smpAgent) codeArg :: BadgeCode -> String codeArg = T.unpack . formatBadgeCode @@ -501,11 +510,14 @@ testMultiUseRepeatAfterExpiry ps = redeemFirstBadge alice code rows <- ledgerRows (chatController alice) "badge_ledger" setClockAt bsClock $ dueAtOf rows - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "badge alert: support_ended " + ] waitShownBadge (chatController alice) Nothing -- The repeat uses the same purchase key, so the service returns the credential it already issued. alice ##> ("/_redeem_badge_code 1 " <> codeArg code) @@ -787,6 +799,20 @@ redeemFirstBadge alice code = do alice <## "badge redeemed" alice <## "supporter badge - active" alice <##. "expires " + waitWakeArmed (chatController alice) + +waitWakeArmed :: HasCallStack => ChatController -> IO () +waitWakeArmed ChatController {chatStore} = loop (100 :: Int) + where + loop i = do + rows :: [(Maybe UTCTime, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT next_wake_at, badge_purchase_id FROM badge_purchases" + case rows of + [(Just _, _)] -> pure () + _ + | i == 0 -> error $ "expected one badge purchase with a wake, got " <> show rows + | otherwise -> threadDelay 50000 >> loop (i - 1) testWorkerRenews :: HasCallStack => TestParams -> IO () testWorkerRenews ps = @@ -987,11 +1013,14 @@ testWorkerRetiresExpired ps = bob <## currentChatVRangeInfo rows <- ledgerRows (chatController alice) "badge_ledger" setClockAt bsClock $ dueAtOf rows - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "badge alert: support_ended " + ] waitShownBadge (chatController alice) Nothing alice ##> "/p" alice <## "user profile: alice (Alice)" @@ -1033,11 +1062,14 @@ testEndedAlert ps = rows <- ledgerRows (chatController alice) "badge_ledger" let endsAt = dueAtOf rows setClockAt bsClock endsAt - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "badge alert: support_ended " + ] pure endsAt withTestChatCfg ps bsClientCfg "alice" $ \alice -> do alice <##. "badge alert: support_ended " @@ -1186,9 +1218,7 @@ testNoCredentialMonthsRanOut ps = rows' <- ledgerRows (chatController alice) "badge_ledger" map (\(_, ch, m, _, _, t) -> (ch, m, t)) rows' `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-2, 0, Just "support")] alice ##> "/_app activate" - alice <## "ok" - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice <### ["ok", StartsWith "1: supporter", StartsWith "badge alert: support_ended "] waitShownBadge (chatController alice) Nothing -- The service issuing nothing while the ledger still owes a month is a fault the client cannot @@ -1315,20 +1345,22 @@ testSnoozedAlertReturns ps = rows <- ledgerRows (chatController alice) "badge_ledger" let endsAt = dueAtOf rows setClockAt bsClock endsAt - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "badge alert: support_ended " + ] alice ##> ("/_badge ack 1 1 support_ended on " <> T.unpack (safeDecodeUtf8 $ strEncode endsAt)) alice <##. "1: supporter" alice ##> "/p" alice <## "user profile: alice (Alice)" alice <## "use /p [] to change it" setClockAt bsClock $ addUTCTime (nominalDay + 60) endsAt - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice <### ["/_app activate", "ok", StartsWith "badge alert: support_ended "] testRenewalKeepsProfileEdits :: HasCallStack => TestParams -> IO () testRenewalKeepsProfileEdits ps = diff --git a/tests/Bots/BadgeService/GroupIntegrationTests.hs b/tests/Bots/BadgeService/GroupIntegrationTests.hs index 1b80adcd13..8b4cbc249a 100644 --- a/tests/Bots/BadgeService/GroupIntegrationTests.hs +++ b/tests/Bots/BadgeService/GroupIntegrationTests.hs @@ -235,6 +235,7 @@ withOwnerJoined ps cc gid action = withNewTestChat ps "alice" aliceProfile $ \alice -> do joinGroup cc alice waitMemberRole cc gid "alice" "owner" + drainUntil alice ["#" <> groupName <> ": " <> botName <> " changed your role from member to owner"] r <- action alice drainConsole alice pure r @@ -1214,11 +1215,13 @@ deleteItem cc citemId = do -- The member must have received the message before the command can find it. moderateBotItem :: HasCallStack => TestCC -> Text -> IO () moderateBotItem member body = do - void (pollUntil (queryFirst (chatController member) received) :: IO Int64) + void (pollUntil received :: IO Int64) send member ("\\\\ #" <> groupName <> " @" <> botName <> " " <> T.unpack header) where header = T.takeWhile (/= '\n') body - received = "SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND item_text LIKE '" <> T.unpack header <> "%'" + received = + withTransaction (chatStore $ chatController member) $ \db -> + listToMaybe . map fromOnly <$> DB.query db "SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND item_text LIKE ? || '%'" (Only header) waitModeratedItem :: HasCallStack => ChatController -> Int64 -> IO (Int, Maybe Int64, Text) waitModeratedItem cc citemId = diff --git a/tests/Bots/BadgeService/WebTests.hs b/tests/Bots/BadgeService/WebTests.hs index b83f4f8f05..15203c75ac 100644 --- a/tests/Bots/BadgeService/WebTests.hs +++ b/tests/Bots/BadgeService/WebTests.hs @@ -25,7 +25,7 @@ import Bots.BadgeService.FakeStripe (FakeStripe (..), fakeIntentStatus, setInten import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.Async (async, wait) import qualified Control.Concurrent.Async as Async -import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) +import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, putMVar, takeMVar, withMVar) import Control.Concurrent.STM (atomically, modifyTVar', readTVarIO) import qualified Control.Exception as E import Control.Monad (forM_, join, replicateM, replicateM_, void, when) @@ -81,10 +81,9 @@ import UnliftIO.Temporary (withTempDirectory) #if defined(dbPostgres) import BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations) -import ChatClient (testDBConnectInfo, testDBConnstr) +import ChatClient (testDBConnstr) import Database.PostgreSQL.Simple (Only (..)) import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations -import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropDatabaseAndUser) #else import BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations) import Data.String (fromString) @@ -96,21 +95,23 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations #if defined(dbPostgres) withServiceStore :: (DBStore -> IO a) -> IO a -withServiceStore action = - E.bracket_ - (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) - (dropDatabaseAndUser testDBConnectInfo) - $ do - Right st <- createDBStore serviceDBOpts badgeServiceSchemaMigrations (MigrationConfig MCError Nothing) - action st `E.finally` closeDBStore st +withServiceStore action = do + n <- atomicModifyIORef' serviceSchemaCounter (\i -> (i + 1, i)) + Right st <- createDBStore (serviceDBOpts n) badgeServiceSchemaMigrations (MigrationConfig MCError Nothing) + action st `E.finally` closeDBStore st where - serviceDBOpts = + serviceDBOpts :: Int -> DBOpts + serviceDBOpts n = DBOpts { connstr = BC.pack testDBConnstr, - schema = "sx_badge_service_web_test", + schema = "sx_badge_service_web_test_" <> BC.pack (show n), poolSize = 4, createSchema = True } + +serviceSchemaCounter :: IORef Int +serviceSchemaCounter = unsafePerformIO $ newIORef 0 +{-# NOINLINE serviceSchemaCounter #-} #else withServiceStore :: (DBStore -> IO a) -> IO a withServiceStore action = do @@ -1466,11 +1467,17 @@ testHoldReportsTheRowNotTheWake = bounded "hold reports the row" $ withWebApp $ testHold :: Int testHold = 2000000 +awaitWaitingCount :: WebEnv -> Int -> IO () +awaitWaitingCount env n = do + parked <- waitingCount (weWaiters env) + when (parked /= n) $ threadDelay 10000 >> awaitWaitingCount env n + testHoldTimeoutReportsAnUnpublishedChange :: IO () testHoldTimeoutReportsAnUnpublishedChange = bounded "hold timeout" $ withWebAppHolding testHold $ \env client -> do iid <- seedOpenInvoice env started <- getCurrentTime held <- async $ webGet client (invoicePath iid <> "?wait=open") + awaitWaitingCount env 1 threadDelay 50000 expireRow (weStore env) iid r <- wait held @@ -2532,7 +2539,7 @@ testCadenceFollowsTheWaiters = bounded "cadence" $ withStubPoller raceHold $ \_ waitingCount (weWaiters env) `shouldReturn` 0 passDelayNow poller `shouldReturn` (pIdleSeconds * 1000000) held <- async $ webGet client (invoicePath iid <> "?wait=open") - threadDelay 100000 + awaitWaitingCount env 1 passDelayNow poller `shouldReturn` (pWaitingSeconds * 1000000) waitingCount (weWaiters env) `shouldReturn` 1 markPaidAndPublish env iid @@ -2661,9 +2668,13 @@ seedPastWindow st i providerRef provider = do passListing :: IORef StubState -> PollerEnv -> IO () passListing ref poller = runOnePass poller >> clearCalls ref +stderrCaptureLock :: MVar () +stderrCaptureLock = unsafePerformIO $ newMVar () +{-# NOINLINE stderrCaptureLock #-} + -- hDuplicateTo changes stderr's buffering, so put the original setting back. capturingStderr :: IO () -> IO Text -capturingStderr action = do +capturingStderr action = withMVar stderrCaptureLock $ \_ -> do createDirectoryIfMissing True "tests/tmp" withTempDirectory "tests/tmp" "badge-stderr" $ \dir -> do let path = dir "stderr.log" @@ -2674,7 +2685,7 @@ capturingStderr action = do T.readFile path loggedError :: Text -> Text -> Bool -loggedError message = any (\l -> "[ERROR " `T.isPrefixOf` l && message `T.isInfixOf` l) . T.lines +loggedError message = any (\l -> "[ERROR " `T.isInfixOf` l && message `T.isInfixOf` l) . T.lines failCancels :: IORef StubState -> IO () failCancels ref = atomicModifyIORef' ref $ \s -> (s {ssCancelError = Just (ProviderError "refused")}, ()) @@ -3467,7 +3478,7 @@ testHeldWaitWakesOnAPaymentThatDoesNotSettle :: IO () testHeldWaitWakesOnAPaymentThatDoesNotSettle = bounded "funded wakes a hold" $ withCheckout $ \_ env client -> do iid <- seedOpenInvoice env held <- async $ webGet client (invoicePath iid <> "?wait=open") - threadDelay 100000 + awaitWaitingCount env 1 waitingCount (weWaiters env) `shouldReturn` 1 settleOrder (weStore env) (weWaiters env) iid (SigFunded (rcv 500 (Just "0.00050000")) PaidInPart) someCreated `shouldReturn` Right ISOpen diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 9edbb3cb73..1def872096 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -14,7 +14,7 @@ import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (bracket) import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Core -import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import Test.Hspec hiding (it) @@ -26,11 +26,11 @@ broadcastBotTests :: SpecWith TestParams broadcastBotTests = do it "should broadcast message" testBroadcastMessages -withBroadcastBot :: BroadcastBotOpts -> IO () -> IO () -withBroadcastBot opts test = +withBroadcastBot :: TestParams -> BroadcastBotOpts -> IO () -> IO () +withBroadcastBot ps opts test = bracket (forkIO bot) killThread (\_ -> threadDelay 500000 >> test) where - bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts + bot = simplexChatCore (fst $ testPortsCfg ps testCfg testOpts) (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} @@ -39,11 +39,11 @@ mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts mkBotOpts ps publishers = BroadcastBotOpts { coreOptions = - testCoreOpts + coreOpts { dbOptions = (dbOptions testCoreOpts) #if defined(dbPostgres) - {dbSchemaPrefix = "client_" <> botDbPrefix} + {dbSchemaPrefix = testSchemaPrefix ps botDbPrefix} #else {dbFilePrefix = tmpPath ps botDbPrefix} #endif @@ -54,6 +54,8 @@ mkBotOpts ps publishers = welcomeMessage = defaultWelcomeMessage publishers, prohibitedMessage = defaultWelcomeMessage publishers } + where + (_, ChatOpts {coreOptions = coreOpts}) = testPortsCfg ps testCfg testOpts botDbPrefix :: FilePath botDbPrefix = "broadcast_bot" @@ -67,7 +69,7 @@ testBroadcastMessages ps = do bc_bot ##> "/ad" getContactLink bc_bot True let botOpts = mkBotOpts ps [KnownContact 2 "alice"] - withBroadcastBot botOpts $ + withBroadcastBot ps botOpts $ withTestChat ps "alice" $ \alice -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 74688e9b4f..22d73f72ea 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -10,7 +10,9 @@ import ChatClient import ChatTests.DBUtils import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils -import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (async, cancel) +import Control.Concurrent.STM (atomically, peekTQueue, tryReadTMVar) import Control.Exception (finally) import Control.Monad (forM_, when, void) import qualified Data.Aeson as J @@ -21,17 +23,19 @@ import Directory.Options import Directory.Service import System.Directory (emptyPermissions, setOwnerExecutable, setOwnerReadable, setOwnerWritable, setPermissions) import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (smpAgent)) import qualified Simplex.Chat.Markdown as MD -import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (memberSupportVoiceVersion) import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Version import NameResolver import System.FilePath (()) +import System.Timeout (timeout) import Test.Hspec hiding (it) directoryServiceTests :: SpecWith TestParams @@ -113,16 +117,16 @@ directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts -mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = +mkDirectoryOpts ps superUsers ownersGroup webFolder = DirectoryOpts { coreOptions = - testCoreOpts + coreOpts { dbOptions = (dbOptions testCoreOpts) #if defined(dbPostgres) - {dbSchemaPrefix = "client_" <> serviceDbPrefix} + {dbSchemaPrefix = testSchemaPrefix ps serviceDbPrefix} #else - {dbFilePrefix = ps serviceDbPrefix} + {dbFilePrefix = tmpPath ps serviceDbPrefix} #endif }, @@ -149,6 +153,8 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = knocking = False, testing = True } + where + (_, ChatOpts {coreOptions = coreOpts}) = testPortsCfg ps testCfg testOpts serviceDbPrefix :: FilePath serviceDbPrefix = "directory_service" @@ -175,16 +181,18 @@ testDirectoryService ps = bob ##> "/mr PSA 'SimpleX Directory' admin" -- putStrLn "*** discover service joins group and sends the registration for approval" bob <## "#PSA: you changed the role of 'SimpleX Directory' to admin" - bob <# "'SimpleX Directory'> Joining the group PSA…" - bob <## "#PSA: 'SimpleX Directory' joined the group" + bob + <### [ WithTime "'SimpleX Directory'> Joining the group PSA…", + "#PSA: 'SimpleX Directory' joined the group" + ] bob <# "'SimpleX Directory'> Joined the group PSA. Registration is pending approval — it may take up to 48 hours." bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." bob <## "Captcha verification is enabled. Use /'filter 1' to change it." notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" Nothing 1 1 -- putStrLn "*** update profile before approval - new approval code" updateGroupProfile bob "Welcome!" - groupUpdatedHidden superUser bob "PSA" "" - notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" (Just "Welcome!") 1 2 + groupUpdatedHidden superUser bob "PSA" "" $ + notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" (Just "Welcome!") 1 2 -- putStrLn "*** try approving with the old registration code" bob #> "@'SimpleX Directory' /approve 1:PSA 1" bob <# "'SimpleX Directory'> > /approve 1:PSA 1" @@ -470,8 +478,8 @@ testSearchByLink ps = memberGroupListing superUser bob 1 "privacy" "Privacy" 2 "active" -- content change hides the group from user search, admin still finds it by link setWelcomeMessage bob [] "Welcome!" - groupUpdatedHidden superUser bob "privacy" "" - notifySuperUser_ superUser bob "privacy" "Privacy" (Just "Welcome!") 1 1 + groupUpdatedHidden superUser bob "privacy" "" $ + notifySuperUser_ superUser bob "privacy" "Privacy" (Just "Welcome!") 1 1 bob #> ("@'SimpleX Directory' " <> link) bob <# ("'SimpleX Directory'> > " <> link) bob <## " No groups found." @@ -624,9 +632,11 @@ testInviteOwnerAfterLeavingOwnersGroup ps = superUser <## "#owners: new member bob is connected" -- owner leaves owners' group; GroupMember row keeps status GSMemLeft leaveGroup "owners" bob - superUser <## "#owners: bob left the group (signed)" -- owners' group has no GroupReg, so directory service notifies admins on contact left - superUser <# "'SimpleX Directory'> Error: contact left, group: 1 owners, group registration not found" + superUser + <### [ "#owners: bob left the group (signed)", + WithTime "'SimpleX Directory'> Error: contact left, group: 1 owners, group registration not found" + ] -- super-user re-invites via /invite — must send a fresh invitation, not "already a member" superUser #> "@'SimpleX Directory' /invite 2:privacy" superUser <# "'SimpleX Directory'> > /invite 2:privacy" @@ -648,9 +658,7 @@ testDelistedOwnerLeaves ps = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group owner left)." - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupNotFound cath "privacy" testDelistedOwnerRemoved :: HasCallStack => TestParams -> IO () @@ -661,14 +669,17 @@ testDelistedOwnerRemoved ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - removeMember "privacy" cath bob - bob <# "'SimpleX Directory'> You are removed from the group ID 1 (privacy)." - bob <## "" - bob <## "The group is no longer listed in the directory." + cath ##> "/rm privacy bob" + cath <## "#privacy: you removed bob from the group (signed)" + bob + <### [ "#privacy: cath removed you from the group (signed)", + "use /d #privacy to delete the group", + WithTime "'SimpleX Directory'> You are removed from the group ID 1 (privacy).", + "", + "The group is no longer listed in the directory." + ] superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group owner is removed)." - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupNotFound cath "privacy" testNotDelistedMemberLeaves :: HasCallStack => TestParams -> IO () @@ -800,9 +811,7 @@ testDelistedRoleChanges ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupFoundN 3 cath "privacy" -- de-listed if service role changed bob ##> "/mr privacy 'SimpleX Directory' member" @@ -816,28 +825,34 @@ testDelistedRoleChanges ps = -- re-listed if service role changed back without profile changes cath ##> "/mr privacy 'SimpleX Directory' admin" cath <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)" - bob <## "#privacy: cath changed the role of 'SimpleX Directory' from member to admin (signed)" - bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." - bob <## "" - bob <## "The group is listed in the directory again." + bob + <### [ "#privacy: cath changed the role of 'SimpleX Directory' from member to admin (signed)", + WithTime "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin.", + "", + "The group is listed in the directory again." + ] superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is listed (SimpleX Directory role is changed to admin)." groupFoundN 3 cath "privacy" -- de-listed if owner role changed cath ##> "/mr privacy bob admin" cath <## "#privacy: you changed the role of bob to admin (signed)" - bob <## "#privacy: cath changed your role from owner to admin (signed)" - bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to admin." - bob <## "" - bob <## "The group is no longer listed in the directory." + bob + <### [ "#privacy: cath changed your role from owner to admin (signed)", + WithTime "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to admin.", + "", + "The group is no longer listed in the directory." + ] superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (user role is set to admin)." groupNotFound cath "privacy" -- re-listed if owner role changed back without profile changes cath ##> "/mr privacy bob owner" cath <## "#privacy: you changed the role of bob to owner (signed)" - bob <## "#privacy: cath changed your role from admin to owner (signed)" - bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to owner." - bob <## "" - bob <## "The group is listed in the directory again." + bob + <### [ "#privacy: cath changed your role from admin to owner (signed)", + WithTime "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to owner.", + "", + "The group is listed in the directory again." + ] superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is listed (user role is set to owner)." groupFoundN 3 cath "privacy" @@ -849,9 +864,7 @@ testNotDelistedMemberRoleChanged ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" bob <## "#privacy: you changed the role of cath to member (signed)" @@ -872,7 +885,7 @@ testNotSentApprovalBadRoles ps = bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)" bob ##> "/gp privacy privacy Privacy!" bob <## "description changed to: Privacy!" - groupUpdatedHidden superUser bob "privacy" "" + groupUpdatedHidden superUser bob "privacy" "" $ pure () bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" bob ##> "/mr privacy 'SimpleX Directory' admin" bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)" @@ -896,6 +909,7 @@ testNotApprovedBadRoles ps = notifySuperUser superUser bob "privacy" "Privacy" 1 bob ##> "/mr privacy 'SimpleX Directory' member" bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)" + serviceRoleIs superUser "member" let approve = "/approve 1:privacy 1" superUser #> ("@'SimpleX Directory' " <> approve) superUser <# ("'SimpleX Directory'> > " <> approve) @@ -909,6 +923,16 @@ testNotApprovedBadRoles ps = notifySuperUser superUser bob "privacy" "Privacy" 1 void $ approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" + where + serviceRoleIs superUser expectedRole = go (50 :: Int) + where + go n = do + superUser #> "@'SimpleX Directory' /x /ms privacy" + superUser <# "'SimpleX Directory'> > /x /ms privacy" + l <- getTermLine superUser + superUser <## "bob (Bob): owner, host, connected" + let expected = " 'SimpleX Directory': " <> expectedRole <> ", you, connected" + if l == expected || n == 0 then l `shouldBe` expected else threadDelay 100000 >> go (n - 1) testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () testRegOwnerChangedProfile ps = @@ -924,12 +948,9 @@ testRegOwnerChangedProfile ps = bob <## "It is hidden from the directory until approved." cath <## "bob updated group #privacy: (signed)" cath <## "description changed to: Privacy and Security" - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupNotFound cath "privacy" - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated." - reapproveGroup 3 superUser bob + reapproveGroup 3 superUser bob "" groupFoundN 3 cath "privacy" testAnotherOwnerChangedProfile :: HasCallStack => TestParams -> IO () @@ -940,18 +961,17 @@ testAnotherOwnerChangedProfile ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" cath ##> "/gp privacy privacy Privacy and Security" cath <## "description changed to: Privacy and Security" - bob <## "cath updated group #privacy: (signed)" - bob <## "description changed to: Privacy and Security" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!" - bob <## "It is hidden from the directory until approved." + bob + <### [ "cath updated group #privacy: (signed)", + "description changed to: Privacy and Security", + WithTime "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!", + "It is hidden from the directory until approved." + ] groupNotFound cath "privacy" - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." - reapproveGroup 3 superUser bob + reapproveGroup 3 superUser bob " by cath" groupFoundN 3 cath "privacy" testNotConnectedOwnerChangedProfile :: HasCallStack => TestParams -> IO () @@ -966,13 +986,14 @@ testNotConnectedOwnerChangedProfile ps = addCathAsOwner bob cath cath ##> "/gp privacy privacy Privacy and Security" cath <## "description changed to: Privacy and Security" - bob <## "cath updated group #privacy: (signed)" - bob <## "description changed to: Privacy and Security" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!" - bob <## "It is hidden from the directory until approved." + bob + <### [ "cath updated group #privacy: (signed)", + "description changed to: Privacy and Security", + WithTime "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!", + "It is hidden from the directory until approved." + ] groupNotFound dan "privacy" - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." - reapproveGroup 3 superUser bob + reapproveGroup 3 superUser bob " by cath" groupFoundN 3 dan "privacy" testRegOwnerRemovedLink :: HasCallStack => TestParams -> IO () @@ -985,8 +1006,8 @@ testRegOwnerRemovedLink ps = addCathAsOwner bob cath -- setting the welcome message requires re-approval setWelcomeMessage bob [cath] "Welcome!" - groupUpdatedHidden superUser bob "privacy" "" - reapproveGroup_ 3 superUser bob (Just "Welcome!") + groupUpdatedHidden superUser bob "privacy" "" $ reapprovalRequested 3 superUser (Just "Welcome!") + void $ approveRegistration_ superUser bob "privacy" 1 1 1 -- adding the link keeps the group listed gLink <- getGroupLinkFromBot bob setWelcomeMessage bob [cath] ("Welcome! Link to join the group privacy: " <> gLink) @@ -994,9 +1015,7 @@ testRegOwnerRemovedLink ps = -- removing the link keeps the group listed setWelcomeMessage bob [cath] "Welcome!" groupUpdatedListed superUser bob "privacy" "" - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupFoundWelcome 3 cath "privacy" "Welcome!" testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () @@ -1007,20 +1026,20 @@ testAnotherOwnerRemovedLink ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" -- setting the welcome message requires re-approval - setWelcomeMessage cath [bob] "Welcome!" - groupUpdatedHidden superUser bob "privacy" " by cath" - reapproveGroup_ 3 superUser bob (Just "Welcome!") + welcomeUpdatedBy bob cath "Welcome!" "It is hidden from the directory until approved." + noticeAndRequest superUser "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." $ reapprovalRequested 3 superUser (Just "Welcome!") + void $ approveRegistration_ superUser bob "privacy" 1 1 1 -- another owner adds the link - the group remains listed gLink <- getGroupLinkFromBot bob - setWelcomeMessage cath [bob] ("Welcome! Link to join the group privacy: " <> gLink) - groupUpdatedListed superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath ("Welcome! Link to join the group privacy: " <> gLink) "The group is listed in directory." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath - only link or whitespace changes." + superUser <## "The group remained listed in directory." -- another owner removes the link - the group remains listed - setWelcomeMessage cath [bob] "Welcome!" - groupUpdatedListed superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath "Welcome!" "The group is listed in directory." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath - only link or whitespace changes." + superUser <## "The group remained listed in directory." groupFoundWelcome 3 cath "privacy" "Welcome!" testNotConnectedOwnerRemovedLink :: HasCallStack => TestParams -> IO () @@ -1034,17 +1053,19 @@ testNotConnectedOwnerRemovedLink ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath -- setting the welcome message requires re-approval - setWelcomeMessage cath [bob] "Welcome!" - groupUpdatedHidden superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath "Welcome!" "It is hidden from the directory until approved." + noticeAndRequest superUser "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." $ reapprovalRequested 3 superUser (Just "Welcome!") groupNotFound dan "privacy" - reapproveGroup_ 3 superUser bob (Just "Welcome!") + void $ approveRegistration_ superUser bob "privacy" 1 1 1 -- the not connected owner adds the link - the group remains listed gLink <- getGroupLinkFromBot bob - setWelcomeMessage cath [bob] ("Welcome! Link to join the group privacy: " <> gLink) - groupUpdatedListed superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath ("Welcome! Link to join the group privacy: " <> gLink) "The group is listed in directory." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath - only link or whitespace changes." + superUser <## "The group remained listed in directory." -- the not connected owner removes the link - the group remains listed - setWelcomeMessage cath [bob] "Welcome!" - groupUpdatedListed superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath "Welcome!" "The group is listed in directory." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath - only link or whitespace changes." + superUser <## "The group remained listed in directory." groupFoundWelcome 3 dan "privacy" "Welcome!" testDuplicateAskConfirmation :: HasCallStack => TestParams -> IO () @@ -1123,8 +1144,8 @@ testDuplicateProhibitWhenUpdated ps = cath <## "changed to #security (Security)" cath <# "'SimpleX Directory'> The group ID 1 (security) is updated!" cath <## "It is hidden from the directory until approved." - superUser <# "'SimpleX Directory'> The group ID 2 (security) is updated." - notifySuperUser_ superUser cath "security" "Security" Nothing 2 2 + noticeAndRequest superUser "'SimpleX Directory'> The group ID 2 (security) is updated." $ + notifySuperUser_ superUser cath "security" "Security" Nothing 2 2 void $ approveRegistration_ superUser cath "security" 2 1 2 groupFound bob "security" groupFound cath "security" @@ -1157,13 +1178,13 @@ testDuplicateProhibitApproval ps = testListUserGroups :: HasCallStack => Bool -> TestParams -> IO () testListUserGroups promote ps = - withDirectoryServiceCfgOwnersGroup ps testCfg False (Just "./tests/tmp/web") $ \superUser dsLink -> + withDirectoryServiceCfgOwnersGroup ps testCfg False (Just webDir) $ \superUser dsLink -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" - checkListings ["privacy"] [] + checkListings webDir ["privacy"] [] connectUsers bob cath fullAddMember "privacy" "Privacy" bob cath GRMember joinGroup "privacy" cath bob @@ -1171,9 +1192,9 @@ testListUserGroups promote ps = cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'" cath <## "use @'SimpleX Directory' to send messages" registerGroupId superUser bob "security" "Security" 2 2 - checkListings ["privacy", "security"] [] + checkListings webDir ["privacy", "security"] [] registerGroupId superUser cath "anonymity" "Anonymity" 3 1 - checkListings ["privacy", "security", "anonymity"] [] + checkListings webDir ["privacy", "security", "anonymity"] [] listUserGroup cath "anonymity" "Anonymity" -- with de-listed group groupFound cath "anonymity" @@ -1183,41 +1204,39 @@ testListUserGroups promote ps = cath <## "" cath <## "The group is no longer listed in the directory." superUser <# "'SimpleX Directory'> The group ID 3 (anonymity) is de-listed (SimpleX Directory role is changed to member)." - checkListings ["privacy", "security"] [] + checkListings webDir ["privacy", "security"] [] groupNotFound cath "anonymity" listGroups superUser bob cath when promote $ do superUser #> "@'SimpleX Directory' /promote 1:privacy on" superUser <# "'SimpleX Directory'> > /promote 1:privacy on" superUser <## " Group promotion enabled." - checkListings ["privacy", "security"] ["privacy"] + checkListings webDir ["privacy", "security"] ["privacy"] bob ##> "/gp privacy privacy" bob <## "description removed" cath <## "bob updated group #privacy: (signed)" cath <## "description removed" - groupUpdatedHidden superUser bob "privacy" "" - superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" - superUser <## "privacy" - superUser <## "3 members" - superUser <## "" - superUser <## "To approve send:" - superUser <# "'SimpleX Directory'> /approve 1:privacy 1 promote=on" - checkListings ["security"] [] + groupUpdatedHidden superUser bob "privacy" "" $ do + superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" + superUser <## "privacy" + superUser <## "3 members" + superUser <## "" + superUser <## "To approve send:" + superUser <# "'SimpleX Directory'> /approve 1:privacy 1 promote=on" + checkListings webDir ["security"] [] superUser #> "@'SimpleX Directory' /approve 1:privacy 1" superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" superUser <## " Group approved (promoted)!" void $ groupApprovedNotification bob "privacy" 1 - checkListings ["privacy", "security"] ["privacy"] - -checkListings :: HasCallStack => [T.Text] -> [T.Text] -> IO () -checkListings listed promoted = do - threadDelay 100000 - checkListing listingFileName listed - checkListing promotedFileName promoted + checkListings webDir ["privacy", "security"] ["privacy"] where - checkListing f expected = do - Just (DirectoryListing gs) <- J.decodeFileStrict $ "./tests/tmp/web/data" f - map groupName gs `shouldBe` expected + webDir = tmpFile ps "web" + +checkListings :: HasCallStack => FilePath -> [T.Text] -> [T.Text] -> IO () +checkListings webDir listed promoted = + ((,) <$> readListing listingFileName <*> readListing promotedFileName) `shouldEventuallyReturn` (listed, promoted) + where + readListing f = maybe [] (\(DirectoryListing gs) -> map groupName gs) <$> J.decodeFileStrict (webDir "data" f) groupName DirectoryEntry {displayName} = displayName testAlwaysCaptcha :: HasCallStack => TestParams -> IO () @@ -1648,14 +1667,14 @@ withDirectoryServiceOpts ps modOpts test = do ds ##> "/ad" getContactLink ds True let opts = modOpts $ mkDirectoryOpts ps [KnownContact 2 "alice"] Nothing Nothing - runDirectory testCfg opts $ + runDirectory ps testCfg opts $ withTestChatCfg ps testCfg "super_user" $ \superUser -> do superUser <## "subscribed 1 connections on server localhost" test superUser dsLink withDirectoryServiceVoiceCaptcha :: HasCallStack => TestParams -> FilePath -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceVoiceCaptcha ps voiceScript = - withDirectoryServiceOpts ps (\o -> o {voiceCaptchaGenerator = Just voiceScript}) +withDirectoryServiceVoiceCaptcha ps voiceScript test = + withXFTPServer ps $ withDirectoryServiceOpts ps (\o -> o {voiceCaptchaGenerator = Just voiceScript}) test testRestoreDirectory :: HasCallStack => TestParams -> IO () testRestoreDirectory ps = do @@ -1735,11 +1754,14 @@ groupListing_ su owner_ gId n fn count status = do su <## ("Status: " <> status) su <## ("/'role " <> show gId <> "', /'filter " <> show gId <> "'") -reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> IO () -reapproveGroup count superUser bob = reapproveGroup_ count superUser bob Nothing +reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> String -> IO () +reapproveGroup count superUser bob byMember = do + noticeAndRequest superUser ("'SimpleX Directory'> The group ID 1 (privacy) is updated" <> byMember <> ".") $ + reapprovalRequested count superUser Nothing + void $ approveRegistration_ superUser bob "privacy" 1 1 1 -reapproveGroup_ :: HasCallStack => Int -> TestCC -> TestCC -> Maybe String -> IO () -reapproveGroup_ count superUser bob welcome_ = do +reapprovalRequested :: HasCallStack => Int -> TestCC -> Maybe String -> IO () +reapprovalRequested count superUser welcome_ = do superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" superUser <##. "privacy (" forM_ welcome_ $ \welcome -> do @@ -1749,10 +1771,6 @@ reapproveGroup_ count superUser bob welcome_ = do superUser <## "" superUser <## "To approve send:" superUser <# "'SimpleX Directory'> /approve 1:privacy 1" - superUser #> "@'SimpleX Directory' /approve 1:privacy 1" - superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" - superUser <## " Group approved!" - void $ groupApprovedNotification bob "privacy" 1 addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () addCathAsOwner bob cath = do @@ -1806,18 +1824,19 @@ withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False Nothi withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> Maybe FilePath -> (TestCC -> String -> IO ()) -> IO () withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test = do let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] (if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing) webFolder - runDirectory cfg opts $ + runDirectory ps cfg opts $ withTestChatCfg ps cfg "super_user" $ \superUser -> do if createOwnersGroup then superUser <## "subscribed 2 connections on server localhost" else superUser <## "subscribed 1 connections on server localhost" test superUser dsLink -runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () -runDirectory cfg opts action = do - t <- forkIO $ directoryService opts cfg +runDirectory :: TestParams -> ChatConfig -> DirectoryOpts -> IO () -> IO () +runDirectory ps cfg opts action = do + env <- newServiceState opts + t <- async $ directoryService opts (fst $ testPortsCfg ps cfg testOpts) env threadDelay 500000 - action `finally` killThread t + action `finally` (cancel t >> atomically (tryReadTMVar $ serviceCC env) >>= mapM_ (disposeAgentClient . smpAgent)) registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 @@ -1846,6 +1865,21 @@ groupAccepted u n ugId = do u <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." u <## ("Captcha verification is enabled. Use /'filter " <> show ugId <> "' to change it.") +channelJoinedByDirectory :: HasCallStack => TestCC -> TestCC -> IO () +channelJoinedByDirectory owner relay = + concurrentlyN_ + [ do + relay <## "'SimpleX Directory': accepting request to join group #news..." + relay <## "#news: 'SimpleX Directory' joined the group", + owner + <### [ WithTime "'SimpleX Directory'> Joining the channel news…", + "#news: relay introduced 'SimpleX Directory_1' in the channel", + WithTime "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours.", + WithTime "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them.", + "Captcha verification is enabled. Use /'filter 1' to change it." + ] + ] + completeRegistration :: TestCC -> TestCC -> String -> String -> Int -> IO String completeRegistration su u n fn gId = completeRegistrationId su u n fn gId gId @@ -1898,11 +1932,22 @@ groupApprovedNotification u n ugId = do u <## ("/'link " <> show ugId <> "' - to view group link.") dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine u -groupUpdatedHidden :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () -groupUpdatedHidden superUser u n byMember = do +groupUpdatedHidden :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () -> IO () +groupUpdatedHidden superUser u n byMember approvalRequest = do u <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> "!") u <## "It is hidden from the directory until approved." - superUser <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> ".") + noticeAndRequest superUser ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> ".") approvalRequest + +noticeAndRequest :: HasCallStack => TestCC -> String -> IO () -> IO () +noticeAndRequest su notice request = do + noticeFirst <- (notice ==) . dropTime <$> peekTermLine su + if noticeFirst + then su <# notice >> request + else request >> su <# notice + +peekTermLine :: HasCallStack => TestCC -> IO String +peekTermLine cc = + 20000000 `timeout` atomically (peekTQueue $ termQ cc) >>= maybe (error "no output for 20 seconds") pure groupUpdatedListed :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () groupUpdatedListed superUser u n byMember = do @@ -1922,6 +1967,18 @@ setWelcomeMessage u others welcome = do m <## "welcome message changed to:" m <## welcome +welcomeUpdatedBy :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () +welcomeUpdatedBy owner updater welcome status = do + uName <- userName updater + setWelcomeMessage updater [] welcome + owner + <### [ ConsoleString (uName <> " updated group #privacy: (signed)"), + "welcome message changed to:", + ConsoleString welcome, + WithTime ("'SimpleX Directory'> The group ID 1 (privacy) is updated by " <> uName <> "!"), + ConsoleString status + ] + connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do u ##> ("/c " <> dsLink) @@ -1935,6 +1992,23 @@ u `connectVia` dsLink = do u <## "" u <## "[Directory rules](https://simplex.chat/docs/directory.html)." +connectViaMerged :: HasCallStack => TestCC -> String -> String -> IO () +connectViaMerged u dsLink merged = do + u ##> ("/c " <> dsLink) + u <## "connection request sent!" + u .<## ": contact is connected" + u + <### [ EndsWith "> Welcome to SimpleX Directory!", + "", + "🔍 Send search string to find groups - try security.", + "/help - how to submit your group or channel.", + "/new - recent groups.", + "", + "[Directory rules](https://simplex.chat/docs/directory.html).", + ConsoleString merged, + "use @'SimpleX Directory' to send messages" + ] + joinGroup :: String -> TestCC -> TestCC -> IO () joinGroup gName member host = do let gn = "#" <> gName @@ -2040,11 +2114,12 @@ testCaptchaTooManyAttempts ps = pure () cath #> "#privacy (support) wrong" cath <# "#privacy (support) 'SimpleX Directory'> Too many failed attempts, you can't join group." - -- member removal produces multiple messages - _ <- getTermLine cath - _ <- getTermLine cath - _ <- getTermLine cath - pure () + let removed = "#privacy: 'SimpleX Directory' removed you from the group (signed)" + line <- getTermLine cath + when (line /= removed) $ do + line `shouldContain` "error: connection authorization failed" + cath <## removed + cath <## "use /d #privacy to delete the group" testCaptchaUnknownCommand :: HasCallStack => TestParams -> IO () testCaptchaUnknownCommand ps = @@ -2113,17 +2188,7 @@ testRegisterChannelViaCard ps = _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON -- directory bot validates and joins via relay - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - -- owner sends a message to trigger member introduction - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <##. "Link to join channel: " @@ -2146,15 +2211,15 @@ testRegisterChannelViaCard ps = bob <## "It is hidden from the directory until approved." relay <## "bob updated group #news: (signed)" relay <## "description changed to: News and Updates" - superUser <# "'SimpleX Directory'> The channel ID 1 (news) is updated." - superUser <# ("'SimpleX Directory'> bob submitted the channel ID 1:") - superUser <## "news (News and Updates)" - superUser <##. "Link to join channel: " - superUser <## "You need SimpleX Chat app v6.5 to join." - superUser <## "2 subscribers" - superUser <## "" - superUser <## "To approve send:" - superUser <# "'SimpleX Directory'> /approve 1:news 1" + noticeAndRequest superUser "'SimpleX Directory'> The channel ID 1 (news) is updated." $ do + superUser <# ("'SimpleX Directory'> bob submitted the channel ID 1:") + superUser <## "news (News and Updates)" + superUser <##. "Link to join channel: " + superUser <## "You need SimpleX Chat app v6.5 to join." + superUser <## "2 subscribers" + superUser <## "" + superUser <## "To approve send:" + superUser <# "'SimpleX Directory'> /approve 1:news 1" -- re-approve after profile update let approve2 = "/approve 1:news 1" superUser #> ("@'SimpleX Directory' " <> approve2) @@ -2178,7 +2243,7 @@ testRegisterChannelViaCard ps = -- owner sets a name; directory verifies name<->link consistency and shows the verified name to the admin testDirectoryChannelName :: HasCallStack => TestParams -> IO () -testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> +testDirectoryChannelName ps = withSmpServerAndNames ps $ \reg -> withDirectoryServiceCfg ps testCfg $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> withRelay ps $ \relay -> do @@ -2194,16 +2259,7 @@ testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay -- the directory verified the name against the channel link and shows it to the admin superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" @@ -2219,7 +2275,7 @@ testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> -- registry re-pointed to a different link after the owner set the name: directory verification fails testDirectoryChannelNameNotVerified :: HasCallStack => TestParams -> IO () -testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg -> +testDirectoryChannelNameNotVerified ps = withSmpServerAndNames ps $ \reg -> withDirectoryServiceCfg ps testCfg $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> withRelay ps $ \relay -> do @@ -2237,16 +2293,7 @@ testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg -> bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <## "SimpleX name: #news (NOT verified - will not be shown)" @@ -2297,16 +2344,7 @@ testDeleteChannelRegistration ps = bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <##. "Link to join channel: " @@ -2343,16 +2381,7 @@ testReregistrationAlreadyListed ps = bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <##. "Link to join channel: " @@ -2391,7 +2420,7 @@ testLinkCheckUpdatesCount ps = do ds ##> "/ad" getContactLink ds True let opts = (mkDirectoryOpts ps [KnownContact 2 "alice"] Nothing Nothing) {linkCheckInterval = 1} - runDirectory testCfg opts $ + runDirectory ps testCfg opts $ withTestChatCfg ps testCfg "super_user" $ \superUser -> do superUser <## "subscribed 1 connections on server localhost" withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> @@ -2404,16 +2433,7 @@ testLinkCheckUpdatesCount ps = do bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <##. "Link to join channel: " @@ -2429,25 +2449,24 @@ testLinkCheckUpdatesCount ps = do bob <# ("'SimpleX Directory'> The channel ID 1 (news) is approved and listed in directory - please moderate it!") bob <## "Please note: if you change the channel profile it will be hidden from directory until it is re-approved." -- link check updates count (bot joined) - threadDelay 1000000 - bob #> "@'SimpleX Directory' news" - bob <# "'SimpleX Directory'> > news" - bob <## " Found 1 group(s)." - bob <# "'SimpleX Directory'> news" - bob <##. "Link to join channel: " - bob <## "You need SimpleX Chat app v6.5 to join." - bob <## "2 subscribers" + searchSubscribers bob "2 subscribers" -- second subscriber joins memberJoinChannel "news" [relay] [bob] shortLink fullLink cath -- link check updates count again - threadDelay 1000000 - bob #> "@'SimpleX Directory' news" - bob <# "'SimpleX Directory'> > news" - bob <## " Found 1 group(s)." - bob <# "'SimpleX Directory'> news" - bob <##. "Link to join channel: " - bob <## "You need SimpleX Chat app v6.5 to join." - bob <## "3 subscribers" + searchSubscribers bob "3 subscribers" + where + searchSubscribers bob expected = go (20 :: Int) + where + go n = do + threadDelay 500000 + bob #> "@'SimpleX Directory' news" + bob <# "'SimpleX Directory'> > news" + bob <## " Found 1 group(s)." + bob <# "'SimpleX Directory'> news" + bob <##. "Link to join channel: " + bob <## "You need SimpleX Chat app v6.5 to join." + countLine <- getTermLine bob + if countLine == expected || n == 0 then countLine `shouldBe` expected else go (n - 1) testGetCaptchaStr :: HasCallStack => TestParams -> IO () testGetCaptchaStr _ps = do diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d321e37b72..fa6d0f37d3 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -58,7 +58,7 @@ import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConf import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) -import Simplex.Messaging.Protocol (ProtocolType (..)) +import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..)) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) import NameResolver (NameRegistry, resolverNamesConfig, withNameResolver) @@ -67,7 +67,7 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Server (ServerCredentials (..), mkTransportServerConfig) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal -import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) +import System.Directory (createDirectoryIfMissing, listDirectory, removePathForcibly) import System.FilePath (()) import qualified System.Terminal as C import System.Terminal.Internal (Command (..), Terminal (..), VirtualTerminal (..), VirtualTerminalSettings (..), withVirtualTerminal) @@ -75,8 +75,11 @@ import System.Timeout (timeout) import Test.Hspec (Expectation, HasCallStack, shouldReturn) #if defined(dbPostgres) import qualified Data.ByteString.Char8 as B +import Data.String (fromString) import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) +import qualified Database.PostgreSQL.Simple as PSQL import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) +import System.FilePath (takeFileName) #else import Data.ByteArray (ScrubbedBytes) import qualified Data.Map.Strict as M @@ -105,8 +108,68 @@ testDBConnectInfo = } #endif -serverPort :: ServiceName -serverPort = "7001" +class HasTestParams a where + testParams :: a -> TestParams + +instance HasTestParams TestParams where + testParams = id + +instance HasTestParams TestCC where + testParams TestCC {ccParams} = ccParams + +tmpDir :: HasTestParams a => a -> FilePath +tmpDir = tmpPath . testParams + +tmpFile :: HasTestParams a => a -> FilePath -> FilePath +tmpFile p f = tmpDir p f + +testPort :: HasTestParams a => Int -> a -> ServiceName +testPort offset p = show $ portBase (testParams p) + offset + +smpTestPort :: HasTestParams a => a -> ServiceName +smpTestPort = testPort 1 + +xftpTestPort :: HasTestParams a => a -> ServiceName +xftpTestPort = testPort 2 + +smpTestPort2 :: HasTestParams a => a -> ServiceName +smpTestPort2 = testPort 3 + +remoteTestPort :: HasTestParams a => a -> ServiceName +remoteTestPort = testPort 4 + +testServerKeyHash :: String +testServerKeyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=" + +smpServerStr :: HasTestParams a => a -> String +smpServerStr p = "smp://" <> testServerKeyHash <> ":server_password@localhost:" <> smpTestPort p + +smpServer2Str :: HasTestParams a => a -> String +smpServer2Str p = "smp://" <> testServerKeyHash <> ":server_password@localhost:" <> smpTestPort2 p + +xftpServerStr :: HasTestParams a => a -> String +xftpServerStr p = "xftp://" <> testServerKeyHash <> ":server_password@localhost:" <> xftpTestPort p + +mapTestPort :: TestParams -> ServiceName -> ServiceName +mapTestPort ps = \case + "7001" -> smpTestPort ps + "7002" -> xftpTestPort ps + "7003" -> smpTestPort2 ps + port -> port + +mapServerPort :: (ServiceName -> ServiceName) -> ProtocolServer p -> ProtocolServer p +mapServerPort f srv@ProtocolServer {port} = srv {port = f port} + +mapServerAuthPort :: (ServiceName -> ServiceName) -> ProtoServerWithAuth p -> ProtoServerWithAuth p +mapServerAuthPort f (ProtoServerWithAuth srv auth) = ProtoServerWithAuth (mapServerPort f srv) auth + +testPortsCfg :: TestParams -> ChatConfig -> ChatOpts -> (ChatConfig, ChatOpts) +testPortsCfg ps cfg@ChatConfig {shortLinkPresetServers} opts@ChatOpts {coreOptions = co@CoreChatOpts {smpServers, xftpServers}} = + ( cfg {shortLinkPresetServers = L.map (mapServerPort f) shortLinkPresetServers}, + opts {coreOptions = co {smpServers = map (mapServerAuthPort f) smpServers, xftpServers = map (mapServerAuthPort f) xftpServers}} + ) + where + f = mapTestPort ps testOpts :: ChatOpts testOpts = @@ -199,7 +262,8 @@ data TestCC = TestCC { chatController :: ChatController, chatAsync :: Async (), termQ :: TQueue String, - printOutput :: Bool + printOutput :: Bool, + ccParams :: TestParams } data TestTerminal = TestTerminal VirtualTerminal (TQueue String) @@ -310,8 +374,17 @@ startTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix = do createDatabase :: TestParams -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) #if defined(dbPostgres) -createDatabase _params CoreChatOpts {dbOptions} dbPrefix = do - createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} (MigrationConfig MCError Nothing) +createDatabase ps CoreChatOpts {dbOptions} dbPrefix = do + createChatDatabase dbOptions {dbSchemaPrefix = testSchemaPrefix ps dbPrefix} (MigrationConfig MCError Nothing) + +testSchemaPrefix :: HasTestParams a => a -> String -> String +testSchemaPrefix p dbPrefix = "client_" <> takeFileName (tmpDir p) <> "_" <> dbPrefix + +dropTestSchemas :: HasTestParams a => a -> IO () +dropTestSchemas p = + bracket (PSQL.connect testDBConnectInfo) PSQL.close $ \db -> do + schemas <- PSQL.query db "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE ?" (PSQL.Only $ testSchemaPrefix p "%") + forM_ schemas $ \(PSQL.Only schema) -> PSQL.execute_ db $ fromString $ "DROP SCHEMA " <> (schema :: String) <> " CASCADE" insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users DEFAULT VALUES") @@ -324,7 +397,8 @@ insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) V #endif startTestChat_ :: TestParams -> ChatDatabase -> ChatConfig -> ChatOpts -> String -> User -> IO TestCC -startTestChat_ TestParams {tmpPath, printOutput} db cfg opts@ChatOpts {coreOptions = CoreChatOpts {maintenance}} dbPrefix user = do +startTestChat_ ps@TestParams {tmpPath, printOutput} db cfg_ opts_ dbPrefix user = do + let (cfg, opts@ChatOpts {coreOptions = CoreChatOpts {maintenance}}) = testPortsCfg ps cfg_ opts_ termQ <- newTQueueIO t <- withVirtualTerminal termSettings $ pure . (`TestTerminal` termQ) ct <- newChatTerminal t opts @@ -332,7 +406,7 @@ startTestChat_ TestParams {tmpPath, printOutput} db cfg opts@ChatOpts {coreOptio void $ execChatCommand' (SetTempFolder (tmpPath dbPrefix)) 0 `runReaderT` cc chatAsync <- async $ runSimplexChat cfg opts user cc $ \_u cc' -> runChatTerminal ct cc' opts unless maintenance $ atomically $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry - pure TestCC {chatController = cc, chatAsync, termQ, printOutput} + pure TestCC {chatController = cc, chatAsync, termQ, printOutput, ccParams = ps} stopTestChat :: TestParams -> TestCC -> IO () stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync} = do @@ -433,8 +507,20 @@ enableNamesRole TestCC {chatController = cc} = do withTmpFiles :: IO () -> IO () withTmpFiles = bracket_ - (createDirectoryIfMissing False "tests/tmp") - (removeDirectoryRecursive "tests/tmp") + (createDirectoryIfMissing False "tests/tmp" >> clearTmp) + clearTmp + where + clearTmp = listDirectory "tests/tmp" >>= mapM_ (removePathForcibly . ("tests/tmp" )) + +newPortBases :: IO (TVar [Int]) +newPortBases = newTVarIO [7000, 7010 .. 8990] + +withPortBase :: TVar [Int] -> (Int -> IO a) -> IO a +withPortBase bases = bracket takeBase (\b -> atomically $ modifyTVar' bases (b :)) + where + takeBase = atomically $ readTVar bases >>= \case + b : bs -> writeTVar bases bs $> b + [] -> retry testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> TestParams -> IO () testChatN cfg opts ps test params = @@ -456,7 +542,7 @@ getTermLine = getTermLine' Nothing getTermLine' :: HasCallStack => Maybe String -> TestCC -> IO String getTermLine' expected cc@TestCC {printOutput} = - 5000000 `timeout` atomically (readTQueue $ termQ cc) >>= \case + 20000000 `timeout` atomically (readTQueue $ termQ cc) >>= \case Just s -> do -- remove condition to always echo virtual terminal -- when True $ do @@ -469,7 +555,7 @@ getTermLine' expected cc@TestCC {printOutput} = let expectedMsg = case expected of Just e -> ", expected: " <> show e Nothing -> "" - error $ name <> ": no output for 5 seconds" <> expectedMsg + error $ name <> ": no output for 20 seconds" <> expectedMsg userName :: TestCC -> IO [Char] userName TestCC {chatController = ChatController {currentUser}} = @@ -546,10 +632,10 @@ testChatCfg5 cfg p1 p2 p3 p4 p5 test = testChatN cfg testOpts [p1, p2, p3, p4, p concurrentlyN_ :: [IO a] -> IO () concurrentlyN_ = mapConcurrently_ id -smpServerCfg :: ServerConfig STMMsgStore -smpServerCfg = +smpServerCfg :: HasTestParams a => a -> ServerConfig STMMsgStore +smpServerCfg p = ServerConfig - { transports = [(serverPort, transport @TLS, False)], + { transports = [(smpTestPort p, transport @TLS, False)], tbqSize = 4, msgQueueQuota = 16, maxJournalMsgCount = 24, @@ -601,33 +687,30 @@ smpServerCfg = persistentServerStoreCfg :: FilePath -> ServerStoreCfg STMMsgStore persistentServerStoreCfg tmp = SSCMemory $ Just StorePaths {storeLogFile = tmp <> "/smp-server-store.log", storeMsgsFile = Just $ tmp <> "/smp-server-messages.log"} -withSmpServer :: IO () -> IO () -withSmpServer = withSmpServer' smpServerCfg +withSmpServer :: HasTestParams a => a -> IO b -> IO b +withSmpServer p = withSmpServer' (smpServerCfg p) withSmpServer' :: ServerConfig STMMsgStore -> IO a -> IO a withSmpServer' cfg = serverBracket (\started -> runSMPServerBlocking started cfg Nothing) -- | SMP server with a local names resolver attached; the action gets the resolver -- registry to map names to the addresses it creates. -withSmpServerAndNames :: (NameRegistry -> IO a) -> IO a -withSmpServerAndNames action = +withSmpServerAndNames :: HasTestParams a => a -> (NameRegistry -> IO b) -> IO b +withSmpServerAndNames p action = withNameResolver $ \port reg -> - withSmpServer' smpServerCfg {namesConfig = Just (resolverNamesConfig port)} (action reg) + withSmpServer' (smpServerCfg p) {namesConfig = Just (resolverNamesConfig port)} (action reg) -xftpTestPort :: ServiceName -xftpTestPort = "7002" +xftpServerFiles :: HasTestParams a => a -> FilePath +xftpServerFiles p = tmpFile p "xftp-server-files" -xftpServerFiles :: FilePath -xftpServerFiles = "tests/tmp/xftp-server-files" - -xftpServerConfig :: XFTPServerConfig STMFileStore -xftpServerConfig = +xftpServerConfig :: HasTestParams a => a -> XFTPServerConfig STMFileStore +xftpServerConfig p = XFTPServerConfig - { xftpPort = xftpTestPort, + { xftpPort = xftpTestPort p, fileIdSize = 16, - serverStoreCfg = XSCMemory $ Just "tests/tmp/xftp-server-store.log", - storeLogFile = Just "tests/tmp/xftp-server-store.log", - filesPath = xftpServerFiles, + serverStoreCfg = XSCMemory $ Just storeLog, + storeLogFile = Just storeLog, + filesPath = xftpServerFiles p, fileSizeQuota = Nothing, allowedChunkSizes = [kb 64, kb 128, kb 256, mb 1, mb 4], allowNewFiles = True, @@ -651,23 +734,25 @@ xftpServerConfig = information = Nothing, logStatsInterval = Nothing, logStatsStartTime = 0, - serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log", + serverStatsLogFile = tmpFile p "xftp-server-stats.daily.log", serverStatsBackupFile = Nothing, prometheusInterval = Nothing, - prometheusMetricsFile = "tests/xftp-server-metrics.txt", + prometheusMetricsFile = tmpFile p "xftp-server-metrics.txt", controlPort = Nothing, transportConfig = mkTransportServerConfig True (Just alpnSupportedXFTPhandshakes) False, responseDelay = 0 } + where + storeLog = tmpFile p "xftp-server-store.log" -withXFTPServer :: IO () -> IO () -withXFTPServer = withXFTPServer' xftpServerConfig +withXFTPServer :: HasTestParams a => a -> IO b -> IO b +withXFTPServer p = withXFTPServer' (xftpServerConfig p) -withXFTPServer' :: XFTPServerConfig STMFileStore -> IO () -> IO () -withXFTPServer' cfg = +withXFTPServer' :: XFTPServerConfig STMFileStore -> IO a -> IO a +withXFTPServer' cfg@XFTPServerConfig {filesPath} = serverBracket ( \started -> do - createDirectoryIfMissing False xftpServerFiles + createDirectoryIfMissing False filesPath runXFTPServerBlocking started cfg ) diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index a555fcc9ef..baf8d10d16 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -548,9 +548,7 @@ testWebPreviewMultipleChannels ps = do relay <# "#ch1> msg in ch1" alice #> "#ch2 msg in ch2" relay <# "#ch2> msg in ch2" - threadDelay 2000000 - files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir - length files `shouldBe` 2 + (length . filter (\f -> takeExtension f == ".json") <$> listDirectory webDir) `shouldEventuallyReturn` 2 testWebPreviewChannelDeleted :: HasCallStack => TestParams -> IO () testWebPreviewChannelDeleted ps = diff --git a/tests/ChatTests/DBUtils/Postgres.hs b/tests/ChatTests/DBUtils/Postgres.hs index 2b160379bb..76791c6ce8 100644 --- a/tests/ChatTests/DBUtils/Postgres.hs +++ b/tests/ChatTests/DBUtils/Postgres.hs @@ -2,5 +2,6 @@ module ChatTests.DBUtils.Postgres where data TestParams = TestParams { tmpPath :: FilePath, + portBase :: Int, printOutput :: Bool } diff --git a/tests/ChatTests/DBUtils/SQLite.hs b/tests/ChatTests/DBUtils/SQLite.hs index 2de94882cd..3c71dd8ce9 100644 --- a/tests/ChatTests/DBUtils/SQLite.hs +++ b/tests/ChatTests/DBUtils/SQLite.hs @@ -6,6 +6,7 @@ import Simplex.Messaging.TMap (TMap) data TestParams = TestParams { tmpPath :: FilePath, + portBase :: Int, printOutput :: Bool, chatQueryStats :: TMap Query SlowQueryStats, agentQueryStats :: TMap Query SlowQueryStats diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index af1b140d2f..b95d738589 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -15,7 +15,7 @@ import ChatClient import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) -import Control.Concurrent.Async (concurrently_, poll) +import Control.Concurrent.Async (concurrently_, poll, wait) import Control.Monad (forM_, void, (>=>)) import Data.Aeson (ToJSON) import qualified Data.Aeson as J @@ -274,8 +274,8 @@ testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile tes bob <## "disconnected 1 connections on server localhost" alice <## "disconnected 1 connections on server localhost" serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], msgQueueQuota = 2, serverStoreCfg = persistentServerStoreCfg tmp } @@ -309,12 +309,13 @@ testRetryConnectingClientTimeout ps = do bob <## "invitation link: ok to connect" _sLinkData <- getTermLine bob bob ##> ("/_connect 1 " <> inv) - bob <## "smp agent error: BROKER {brokerAddress = \"smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7003\", brokerErr = TIMEOUT}" + bob <## ("smp agent error: BROKER {brokerAddress = \"smp://" <> testServerKeyHash <> "@localhost:" <> smpTestPort2 ps <> "\", brokerErr = TIMEOUT}") pure inv - logFile <- readFile $ tmp <> "/smp-server-store.log" - logFile `shouldContain` "SECURE" + -- TODO enable with slow_servers SMP response delay, the client may drop SKEY before sending + -- logFile <- readFile $ tmp <> "/smp-server-store.log" + -- logFile `shouldContain` "SECURE" withSmpServer' serverCfg' $ do withTestChatCfgOpts ps cfg' opts' "alice" $ \alice -> do @@ -338,8 +339,8 @@ testRetryConnectingClientTimeout ps = do where tmp = tmpPath ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], msgQueueQuota = 2, serverStoreCfg = persistentServerStoreCfg tmp } @@ -1137,9 +1138,9 @@ testGetSetSMPServers = alice ##> "/_servers 1" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" + alice <## (" " <> smpServerStr alice) alice <## " XFTP servers" - alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" + alice <## (" " <> xftpServerStr alice) alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok") alice ##> "/smp" alice <## "Your servers" @@ -1162,27 +1163,27 @@ testTestSMPServerConnection :: HasCallStack => TestParams -> IO () testTestSMPServerConnection = testChat aliceProfile $ \alice -> do - alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001" + alice ##> ("/smp test smp://" <> testServerKeyHash <> "@localhost:" <> smpTestPort alice) alice <## "SMP server test passed" -- to test with password: -- alice <## "SMP server test failed at CreateQueue, error: SMP AUTH" -- alice <## "Server requires authorization to create queues, check password" - alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" + alice ##> ("/smp test " <> smpServerStr alice) alice <## "SMP server test passed" - alice ##> "/smp test smp://LcJU@localhost:7001" - alice <## "SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:7001\", brokerErr = NETWORK {networkError = NEUnknownCAError}}" + alice ##> ("/smp test smp://LcJU@localhost:" <> smpTestPort alice) + alice <## ("SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:" <> smpTestPort alice <> "\", brokerErr = NETWORK {networkError = NEUnknownCAError}}") alice <## "Certificate fingerprint in SMP server address does not match server certificate" testGetSetXFTPServers :: HasCallStack => TestParams -> IO () testGetSetXFTPServers = testChat aliceProfile $ - \alice -> withXFTPServer $ do + \alice -> withXFTPServer alice $ do alice ##> "/_servers 1" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" + alice <## (" " <> smpServerStr alice) alice <## " XFTP servers" - alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" + alice <## (" " <> xftpServerStr alice) alice #$> ("/xftp xftp://1234-w==@xftp1.example.im", id, "ok") alice ##> "/xftp" alice <## "Your servers" @@ -1203,16 +1204,16 @@ testGetSetXFTPServers = testTestXFTPServer :: HasCallStack => TestParams -> IO () testTestXFTPServer = testChat aliceProfile $ - \alice -> withXFTPServer $ do - alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002" + \alice -> withXFTPServer alice $ do + alice ##> ("/xftp test xftp://" <> testServerKeyHash <> "@localhost:" <> xftpTestPort alice) alice <## "XFTP server test passed" -- to test with password: -- alice <## "XFTP server test failed at CreateFile, error: XFTP AUTH" -- alice <## "Server requires authorization to upload files, check password" - alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" + alice ##> ("/xftp test " <> xftpServerStr alice) alice <## "XFTP server test passed" - alice ##> "/xftp test xftp://LcJU@localhost:7002" - alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK {networkError = NEUnknownCAError}}" + alice ##> ("/xftp test xftp://LcJU@localhost:" <> xftpTestPort alice) + alice <## ("XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:" <> xftpTestPort alice <> "\", brokerErr = NETWORK {networkError = NEUnknownCAError}}") alice <## "Certificate fingerprint in XFTP server address does not match server certificate" testOperators :: HasCallStack => TestParams -> IO () @@ -1272,11 +1273,9 @@ testAsyncAcceptingOffline withShortLink ps = do bob <## "confirmation sent!" withTestChat ps "alice" $ \alice -> do withTestChat ps "bob" $ \bob -> do - alice <## "subscribed 1 connections on server localhost" - bob <## "subscribed 1 connections on server localhost" concurrently_ - (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") + (bob <### ["subscribed 1 connections on server localhost", "alice (Alice): contact is connected"]) + (alice <### ["subscribed 1 connections on server localhost", "bob (Bob): contact is connected"]) testFullAsyncFast :: HasCallStack => TestParams -> IO () testFullAsyncFast ps = do @@ -1290,11 +1289,9 @@ testFullAsyncFast ps = do bob <## "confirmation sent!" threadDelay 250000 withTestChat ps "alice" $ \alice -> do - alice <## "subscribed 1 connections on server localhost" - alice <## "bob (Bob): contact is connected" + alice <### ["subscribed 1 connections on server localhost", "bob (Bob): contact is connected"] withTestChat ps "bob" $ \bob -> do - bob <## "subscribed 1 connections on server localhost" - bob <## "alice (Alice): contact is connected" + bob <### ["subscribed 1 connections on server localhost", "alice (Alice): contact is connected"] testCallType :: CallType testCallType = CallType {media = CMVideo, capabilities = CallCapabilities {encryption = True}} @@ -1339,7 +1336,7 @@ testNegotiateCall = alice <## "bob accepted your WebRTC video call (e2e encrypted)" repeatM_ 3 $ getTermLine alice threadDelay 100000 - alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: accepted")]) + (alice ##> "/_get chat @2 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` (chatFeatures <> [(1, "outgoing call: accepted")]) -- alice confirms call by sending WebRTC answer alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) alice <## "ok" @@ -1348,7 +1345,7 @@ testNegotiateCall = bob <## "alice continued the WebRTC call" repeatM_ 3 $ getTermLine bob threadDelay 100000 - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "incoming call: connecting...")]) + (bob ##> "/_get chat @2 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (chatFeatures <> [(0, "incoming call: connecting...")]) -- participants can update calls as connected alice ##> "/_call status @2 connected" alice <## "ok" @@ -1364,7 +1361,7 @@ testNegotiateCall = threadDelay 100000 bob #$> ("/_get chat @2 count=100", callChat, chatFeatures <> [(0, "incoming call: ended")]) alice <## "call with bob ended" - alice #$> ("/_get chat @2 count=100", callChat, chatFeatures <> [(1, "outgoing call: ended")]) + (alice ##> "/_get chat @2 count=100" >> callChat <$> getTermLine alice) `shouldEventuallyReturn` (chatFeatures <> [(1, "outgoing call: ended")]) where callChat = map (fmap noDuration) . chat noDuration s = case words s of @@ -1402,6 +1399,7 @@ testNegotiateCallV1 = bob <## "ok" alice <## "bob accepted your WebRTC video call (e2e encrypted)" repeatM_ 3 $ getTermLine alice + (show . callStateTag . callState <$> currentCall alice 2) `shouldEventuallyReturn` "CSTCallOfferReceived" Call {callState = CallOfferReceived {sharedKey = aliceKey}} <- currentCall alice 2 aliceKey `shouldBe` Just bobKey alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) @@ -1437,12 +1435,12 @@ testStopStartChat ps = connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" - alice #$> ("/_ttl 1 4", id, "ok") - alice ##> "/_set prefs @2 {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": 2}}" + alice #$> ("/_ttl 1 12", id, "ok") + alice ##> "/_set prefs @2 {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": 6}}" alice <## "you updated preferences for bob:" - alice <## "Disappearing messages: enabled (you allow: yes (2 sec), contact allows: yes)" + alice <## "Disappearing messages: enabled (you allow: yes (6 sec), contact allows: yes)" bob <## "alice updated preferences for you:" - bob <## "Disappearing messages: enabled (you allow: yes (2 sec), contact allows: yes (2 sec))" + bob <## "Disappearing messages: enabled (you allow: yes (6 sec), contact allows: yes (6 sec))" alice #> "@bob hi timed" bob <# "alice> hi timed" let ChatController {agentAsync, cleanupManagerAsync, expireCIThreads, timedItemThreads} = chatController alice @@ -1460,13 +1458,13 @@ testStopStartChat ps = M.null <$> readTVarIO expireCIThreads `shouldReturn` True M.null <$> readTVarIO timedItemThreads `shouldReturn` True alice ##> "/_start" - alice <## "chat started" - alice <## "subscribed 1 connections on server localhost" + alice <### ["chat started", "subscribed 1 connections on server localhost"] bob #> "@alice hello" alice <# "bob> hello" + threadDelay 3000000 alice <### ["timed message deleted: hi timed", "timed message deleted: hello"] bob <### ["timed message deleted: hi timed", "timed message deleted: hello"] - threadDelay 3000000 + threadDelay 8000000 alice #$> ("/_get chat @2 count=100", chat, [(1, "chat banner")]) Just (a1', _) <- readTVarIO agentAsync (a1' == a1) `shouldBe` False @@ -1487,14 +1485,13 @@ testMaintenanceMode ps = do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" - alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice ##> ("/_db export {\"archivePath\": \"" <> archive <> "\"}") alice <## "error: chat not stopped" alice ##> "/_stop" alice <## "chat stopped" alice ##> "/_start" - alice <## "chat started" -- chat works after start - alice <## "subscribed 1 connections on server localhost" + alice <### ["chat started", "subscribed 1 connections on server localhost"] alice #> "@bob hi again" bob <# "alice> hi again" bob #> "@alice hello" @@ -1502,32 +1499,38 @@ testMaintenanceMode ps = do -- export / delete / import alice ##> "/_stop" alice <## "chat stopped" - alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice ##> ("/_db export {\"archivePath\": \"" <> archive <> "\"}") alice <## "ok" - doesFileExist "./tests/tmp/alice-chat.zip" `shouldReturn` True - alice ##> "/_db import {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + doesFileExist archive `shouldReturn` True + alice ##> ("/_db import {\"archivePath\": \"" <> archive <> "\"}") alice <## "ok" -- cannot start chat after import alice ##> "/_start" alice <## "error: chat store changed, please restart chat" -- works after full restart withTestChat ps "alice" $ \alice -> testChatWorking alice bob + where + archive = tmpFile ps "alice-chat.zip" testChatWorking :: HasCallStack => TestCC -> TestCC -> IO () testChatWorking alice bob = do alice <## "subscribed 1 connections on server localhost" + testChatMessages alice bob + +testChatMessages :: HasCallStack => TestCC -> TestCC -> IO () +testChatMessages alice bob = do alice #> "@bob hello again" bob <# "alice> hello again" bob #> "@alice hello too" alice <# "bob> hello too" testMaintenanceModeWithFiles :: HasCallStack => TestParams -> IO () -testMaintenanceModeWithFiles ps = withXFTPServer $ do +testMaintenanceModeWithFiles ps = withXFTPServer ps $ do withNewTestChat ps "bob" bobProfile $ \bob -> do withNewTestChatOpts ps testOpts {coreOptions = testCoreOpts {maintenance = True}} "alice" aliceProfile $ \alice -> do alice ##> "/_start" alice <## "chat started" - alice ##> "/_files_folder ./tests/tmp/alice_files" + alice ##> ("/_files_folder " <> aliceFiles) alice <## "ok" connectUsers alice bob @@ -1545,7 +1548,7 @@ testMaintenanceModeWithFiles ps = withXFTPServer $ do alice <## "completed receiving file 1 (test.jpg) from bob" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/alice_files/test.jpg" + dest <- B.readFile (aliceFiles "test.jpg") dest `shouldBe` src threadDelay 500000 @@ -1554,7 +1557,7 @@ testMaintenanceModeWithFiles ps = withXFTPServer $ do forM_ backupDBs $ \f -> B.writeFile f "" alice ##> "/_stop" alice <## "chat stopped" - alice ##> "/_db export {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice ##> ("/_db export {\"archivePath\": \"" <> archive <> "\"}") alice <## "ok" let exportedDBs = [tmpPath ps <> "/alice_chat.db.exported", tmpPath ps <> "/alice_agent.db.exported"] forM_ exportedDBs $ \f -> B.writeFile f "" @@ -1563,14 +1566,17 @@ testMaintenanceModeWithFiles ps = withXFTPServer $ do -- cannot start chat after delete alice ##> "/_start" alice <## "error: chat store changed, please restart chat" - doesDirectoryExist "./tests/tmp/alice_files" `shouldReturn` False + doesDirectoryExist aliceFiles `shouldReturn` False forM_ exportedDBs $ \f -> doesFileExist f `shouldReturn` False - alice ##> "/_db import {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + alice ##> ("/_db import {\"archivePath\": \"" <> archive <> "\"}") alice <## "ok" - B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src + B.readFile (aliceFiles "test.jpg") `shouldReturn` src forM_ backupDBs $ \f -> doesFileExist f `shouldReturn` False -- works after full restart withTestChat ps "alice" $ \alice -> testChatWorking alice bob + where + aliceFiles = tmpFile ps "alice_files" + archive = tmpFile ps "alice-chat.zip" #if !defined(dbPostgres) testDatabaseEncryption :: HasCallStack => TestParams -> IO () @@ -1596,8 +1602,8 @@ testDatabaseEncryption ps = do alice <## "error: chat store changed, please restart chat" withTestChatOpts ps (getTestOpts True "mykey") "alice" $ \alice -> do alice ##> "/_start" - alice <## "chat started" - testChatWorking alice bob + alice <### ["chat started", "subscribed 1 connections on server localhost"] + testChatMessages alice bob alice ##> "/_stop" alice <## "chat stopped" alice ##> "/db test key wrongkey" @@ -1612,8 +1618,8 @@ testDatabaseEncryption ps = do alice <## "ok" withTestChatOpts ps (getTestOpts True "anotherkey") "alice" $ \alice -> do alice ##> "/_start" - alice <## "chat started" - testChatWorking alice bob + alice <### ["chat started", "subscribed 1 connections on server localhost"] + testChatMessages alice bob alice ##> "/_stop" alice <## "chat stopped" alice ##> "/db decrypt anotherkey" @@ -1753,6 +1759,7 @@ testConnSyncExtraAgentConns ps = do alice ##> "/_connections diff" alice <## "no difference between agent and chat connections" + threadDelay 1000000 -- deleting connection record in chat db void $ withCCTransaction alice $ \db -> DB.execute_ db "DELETE FROM connections WHERE contact_id = (SELECT contact_id FROM contacts WHERE local_display_name = 'cath')" @@ -1779,9 +1786,7 @@ testConnSyncExtraAgentConns ps = do alice <## "subscribed 1 connections on server localhost" threadDelay 100000 - agentConnCount <- withCCAgentTransaction alice $ \db -> - DB.query_ db "SELECT count(1) FROM connections" :: IO [[Int]] - agentConnCount `shouldBe` [[1]] + (withCCAgentTransaction alice $ \db -> DB.query_ db "SELECT count(1) FROM connections" :: IO [[Int]]) `shouldEventuallyReturn` [[1]] alice <##> bob @@ -1789,10 +1794,12 @@ testSubscribeAppNSE :: HasCallStack => TestParams -> IO () testSubscribeAppNSE ps = withNewTestChat ps "bob" bobProfile $ \bob -> do withNewTestChat ps "alice" aliceProfile $ \alice -> do + let ChatController {agentAsync} = chatController alice + Just (_, Just subscribed) <- readTVarIO agentAsync + wait subscribed withTestChatOpts ps testOpts {coreOptions = testCoreOpts {maintenance = True}} "alice" $ \nseAlice -> do alice ##> "/_app suspend 1" - alice <## "ok" - alice <## "chat suspended" + alice <### ["ok", "chat suspended"] nseAlice ##> "/_start main=off" nseAlice <## "chat started" threadDelay 100000 @@ -1802,11 +1809,13 @@ testSubscribeAppNSE ps = bob <## "connection request sent!" (nseAlice "/_app activate" - alice <## "ok" - alice <## "subscribed 1 connections on server localhost" - alice <## "bob (Bob) wants to connect to you!" - alice <## "to accept: /ac bob" - alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice + <### [ "ok", + "subscribed 1 connections on server localhost", + "bob (Bob) wants to connect to you!", + "to accept: /ac bob", + "to reject: /rc bob (the sender will NOT be notified)" + ] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" concurrently_ @@ -2032,7 +2041,7 @@ testServiceRequestResponse = alice ##> "/_stop" alice <## "chat stopped" alice ##> "/_start main=on snd_files=on service_requests=on" - alice <## "chat started" + alice <### ["chat started", "subscribed 1 connections on server localhost"] concurrently_ ( do bob ##> ("/_service_request 1 " <> sLink <> " {\"ping\":1}") @@ -2057,7 +2066,7 @@ testSignedServiceRequest = alice ##> "/_stop" alice <## "chat stopped" alice ##> "/_start main=on snd_files=on service_requests=on" - alice <## "chat started" + alice <### ["chat started", "subscribed 1 connections on server localhost"] g <- C.newRandom (pub, priv :: C.PrivateKeyEd25519) <- atomically $ C.generateKeyPair g let signKey = B.unpack $ strEncode $ C.StoredPrivateKey priv @@ -2343,17 +2352,17 @@ testUsersDifferentCIExpirationTTL ps = do -- set ttl for first user alice ##> "/user alice" showActiveUser alice "alice (Alice)" - alice #$> ("/_ttl 1 2", id, "ok") + alice #$> ("/_ttl 1 10", id, "ok") -- set ttl for second user alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/_ttl 2 4", id, "ok") + alice #$> ("/_ttl 2 25", id, "ok") -- first user messages alice ##> "/user alice" showActiveUser alice "alice (Alice)" - alice #$> ("/ttl", id, "old messages are set to be deleted after: 2 second(s)") + alice #$> ("/ttl", id, "old messages are set to be deleted after: 10 second(s)") alice #> "@bob alice 3" bob <# "alice> alice 3" @@ -2365,7 +2374,7 @@ testUsersDifferentCIExpirationTTL ps = do -- second user messages alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/ttl", id, "old messages are set to be deleted after: 4 second(s)") + alice #$> ("/ttl", id, "old messages are set to be deleted after: 25 second(s)") alice #> "@bob alisa 3" bob <# "alisa> alisa 3" @@ -2374,22 +2383,22 @@ testUsersDifferentCIExpirationTTL ps = do alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 3000000 + threadDelay 12000000 -- messages both before and after setting chat item ttl are deleted -- first user messages alice ##> "/user alice" showActiveUser alice "alice (Alice)" - alice #$> ("/_get chat @2 count=100", chat, [(1,"chat banner")]) + (alice ##> "/_get chat @2 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(1,"chat banner")] -- second user messages alice ##> "/user alisa" showActiveUser alice "alisa" alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 2100000 + threadDelay 15000000 - alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) + (alice ##> "/_get chat @5 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(1,"chat banner")] where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} @@ -2398,13 +2407,13 @@ testUsersRestartCIExpiration ps = do withNewTestChat ps "bob" bobProfile $ \bob -> do withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- set ttl for first user - alice #$> ("/_ttl 1 2", id, "ok") + alice #$> ("/_ttl 1 10", id, "ok") connectUsers alice bob -- create second user and set ttl alice ##> "/create user alisa" showActiveUser alice "alisa" - alice #$> ("/_ttl 2 5", id, "ok") + alice #$> ("/_ttl 2 25", id, "ok") connectUsers alice bob -- first user messages @@ -2436,7 +2445,7 @@ testUsersRestartCIExpiration ps = do -- first user messages alice ##> "/user alice" showActiveUser alice "alice (Alice)" - alice #$> ("/ttl", id, "old messages are set to be deleted after: 2 second(s)") + alice #$> ("/ttl", id, "old messages are set to be deleted after: 10 second(s)") alice #> "@bob alice 3" bob <# "alice> alice 3" @@ -2448,7 +2457,7 @@ testUsersRestartCIExpiration ps = do -- second user messages alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/ttl", id, "old messages are set to be deleted after: 5 second(s)") + alice #$> ("/ttl", id, "old messages are set to be deleted after: 25 second(s)") alice #> "@bob alisa 3" bob <# "alisa> alisa 3" @@ -2457,22 +2466,22 @@ testUsersRestartCIExpiration ps = do alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 3000000 + threadDelay 12000000 -- messages both before and after restart are deleted -- first user messages alice ##> "/user alice" showActiveUser alice "alice (Alice)" - alice #$> ("/_get chat @2 count=100", chat, [(1,"chat banner")]) + (alice ##> "/_get chat @2 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(1,"chat banner")] -- second user messages alice ##> "/user alisa" showActiveUser alice "alisa" alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 4000000 + threadDelay 15000000 - alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) + (alice ##> "/_get chat @5 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(1,"chat banner")] where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} @@ -2558,7 +2567,7 @@ testDisableCIExpirationOnlyForOneUser ps = do -- create second user and set ttl alice ##> "/create user alisa" showActiveUser alice "alisa" - alice #$> ("/_ttl 2 1", id, "ok") + alice #$> ("/_ttl 2 5", id, "ok") connectUsers alice bob -- first user disables expiration @@ -2570,7 +2579,7 @@ testDisableCIExpirationOnlyForOneUser ps = do -- second user still has ttl configured alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + alice #$> ("/ttl", id, "old messages are set to be deleted after: 5 second(s)") alice #> "@bob alisa 1" bob <# "alisa> alisa 1" @@ -2579,17 +2588,17 @@ testDisableCIExpirationOnlyForOneUser ps = do alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2")]) - threadDelay 2000000 + threadDelay 6000000 -- second user messages are deleted - alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) + (alice ##> "/_get chat @5 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(1,"chat banner")] withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "subscribed 1 connections on server localhost" alice <## "subscribed 1 connections on server localhost" -- second user still has ttl configured after restart - alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + alice #$> ("/ttl", id, "old messages are set to be deleted after: 5 second(s)") alice #> "@bob alisa 3" bob <# "alisa> alisa 3" @@ -2598,10 +2607,10 @@ testDisableCIExpirationOnlyForOneUser ps = do alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 3000000 + threadDelay 6000000 -- second user messages are deleted - alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) + (alice ##> "/_get chat @5 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(1,"chat banner")] where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} @@ -2610,13 +2619,13 @@ testUsersTimedMessages ps' = do withNewTestChat ps "bob" bobProfile $ \bob -> do withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers alice bob - configureTimedMessages alice bob "2" "2" + configureTimedMessages alice bob "2" "10" -- create second user and configure timed messages for contact alice ##> "/create user alisa" showActiveUser alice "alisa" connectUsers alice bob - configureTimedMessages alice bob "5" "3" + configureTimedMessages alice bob "5" "16" -- first user messages alice ##> "/user alice" @@ -2637,7 +2646,7 @@ testUsersTimedMessages ps' = do alice <# "bob> alisa 2" -- messages are deleted after ttl - threadDelay 1500000 + threadDelay 6000000 alice ##> "/user alice" showActiveUser alice "alice (Alice)" @@ -2647,7 +2656,7 @@ testUsersTimedMessages ps' = do showActiveUser alice "alisa" alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 1"), (0, "alisa 2")]) - threadDelay 1000000 + threadDelay 4000000 alice <### ["[user: alice] timed message deleted: alice 1", "[user: alice] timed message deleted: alice 2"] bob <### ["timed message deleted: alice 1", "timed message deleted: alice 2"] @@ -2660,7 +2669,7 @@ testUsersTimedMessages ps' = do showActiveUser alice "alisa" alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 1"), (0, "alisa 2")]) - threadDelay 1000000 + threadDelay 7000000 alice <### ["timed message deleted: alisa 1", "timed message deleted: alisa 2"] bob <### ["timed message deleted: alisa 1", "timed message deleted: alisa 2"] @@ -2700,7 +2709,7 @@ testUsersTimedMessages ps' = do alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) -- messages are deleted after restart - threadDelay 1000000 + threadDelay 8000000 alice <### ["[user: alice] timed message deleted: alice 3", "[user: alice] timed message deleted: alice 4"] bob <### ["timed message deleted: alice 3", "timed message deleted: alice 4"] @@ -2713,7 +2722,7 @@ testUsersTimedMessages ps' = do showActiveUser alice "alisa" alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 1000000 + threadDelay 8000000 alice <### ["timed message deleted: alisa 3", "timed message deleted: alisa 4"] bob <### ["timed message deleted: alisa 3", "timed message deleted: alisa 4"] @@ -2879,15 +2888,15 @@ testUserPrivacy = testSetChatItemTTL :: HasCallStack => TestParams -> IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ - \alice bob -> withXFTPServer $ do + \alice bob -> withXFTPServer alice $ do connectUsers alice bob alice #> "@bob 1" bob <# "alice> 1" bob #> "@alice 2" alice <# "bob> 2" -- chat item with file - alice #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") - copyFile "./tests/fixtures/test.jpg" "./tests/tmp/app_files/test.jpg" + alice #$> ("/_files_folder " <> tmpFile alice "app_files", id, "ok") + copyFile "./tests/fixtures/test.jpg" $ tmpFile alice "app_files/test.jpg" alice ##> "/_send @2 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" @@ -2895,17 +2904,17 @@ testSetChatItemTTL = bob <## "use /fr 1 [/ | ] to receive it" alice <## "completed uploading file 1 (test.jpg) for bob" -- above items should be deleted after we set ttl - threadDelay 3000000 + threadDelay 6000000 alice #> "@bob 3" bob <# "alice> 3" bob #> "@alice 4" alice <# "bob> 4" alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "1"), Nothing), ((0, "2"), Nothing), ((1, ""), Just "test.jpg"), ((1, "3"), Nothing), ((0, "4"), Nothing)]) - checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ - alice #$> ("/_ttl 1 2", id, "ok") + checkActionDeletesFile (tmpFile alice "app_files/test.jpg") $ + alice #$> ("/_ttl 1 5", id, "ok") alice #$> ("/_get chat @2 count=100", chat, [(1, "chat banner"), (1, "3"), (0, "4")]) -- when expiration is turned on, first cycle is synchronous bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, ""), (0, "3"), (1, "4")]) - alice #$> ("/_ttl 1", id, "old messages are set to be deleted after: 2 second(s)") + alice #$> ("/_ttl 1", id, "old messages are set to be deleted after: 5 second(s)") alice #$> ("/ttl week", id, "ok") alice #$> ("/ttl", id, "old messages are set to be deleted after: one week") alice #$> ("/ttl none", id, "ok") @@ -2929,13 +2938,13 @@ testSetDirectChatTTL = alice #$> ("/ttl @cath none", id, "ok") alice #$> ("/ttl @cath", id, "old messages are not being deleted") - threadDelay 3000000 + threadDelay 6000000 alice #> "@bob 3" bob <# "alice> 3" bob #> "@alice 4" alice <# "bob> 4" alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "1"), Nothing), ((0, "2"), Nothing), ((1, "3"), Nothing), ((0, "4"), Nothing)]) - alice #$> ("/_ttl 1 2", id, "ok") + alice #$> ("/_ttl 1 5", id, "ok") -- when expiration is turned on, first cycle is synchronous alice #$> ("/_get chat @2 count=100", chat, [(1, "chat banner"), (1, "3"), (0, "4")]) @@ -3016,8 +3025,8 @@ testSwitchContact = bob <## "alice changed address for you" alice <## "bob: you changed address" threadDelay 100000 - alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "started changing address..."), (1, "you changed address")]) - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "changed address for you")]) + (alice ##> "/_get chat @2 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` (chatFeatures <> [(1, "started changing address..."), (1, "you changed address")]) + (bob ##> "/_get chat @2 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (chatFeatures <> [(0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob testAbortSwitchContact :: HasCallStack => TestParams -> IO () @@ -3036,8 +3045,7 @@ testAbortSwitchContact ps = do alice ##> "/abort switch bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" withTestChat ps "bob" $ \bob -> do - bob <## "subscribed 1 connections on server localhost" - bob <## "alice started changing address for you" + bob <### ["subscribed 1 connections on server localhost", "alice started changing address for you"] -- alice changes address again alice #$> ("/switch bob", id, "switch started") alice <## "bob: you started changing address" @@ -3045,8 +3053,8 @@ testAbortSwitchContact ps = do bob <## "alice changed address for you" alice <## "bob: you changed address" threadDelay 100000 - alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "started changing address..."), (1, "started changing address..."), (1, "you changed address")]) - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) + (alice ##> "/_get chat @2 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` (chatFeatures <> [(1, "started changing address..."), (1, "started changing address..."), (1, "you changed address")]) + (bob ##> "/_get chat @2 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (chatFeatures <> [(0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob testSwitchGroupMember :: HasCallStack => TestParams -> IO () @@ -3060,8 +3068,8 @@ testSwitchGroupMember = bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "started changing address for bob..."), (1, "you changed address for bob")]) - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "changed address for you")]) + (alice ##> "/_get chat #1 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` (sndGroupFeatures <> [(0, "connected"), (1, "started changing address for bob..."), (1, "you changed address for bob")]) + (bob ##> "/_get chat #1 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" bob <# "#team alice> hey" bob #> "#team hi" @@ -3083,8 +3091,7 @@ testAbortSwitchGroupMember ps = do alice ##> "/abort switch #team bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" withTestChat ps "bob" $ \bob -> do - bob <## "subscribed 2 connections on server localhost" - bob <## "#team: alice started changing address for you" + bob <### ["subscribed 2 connections on server localhost", "#team: alice started changing address for you"] -- alice changes address again alice #$> ("/switch #team bob", id, "switch started") alice <## "#team: you started changing address for bob" @@ -3092,8 +3099,8 @@ testAbortSwitchGroupMember ps = do bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "started changing address for bob..."), (1, "started changing address for bob..."), (1, "you changed address for bob")]) - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) + (alice ##> "/_get chat #1 count=100" >> chat <$> getTermLine alice) `shouldEventuallyReturn` (sndGroupFeatures <> [(0, "connected"), (1, "started changing address for bob..."), (1, "started changing address for bob..."), (1, "you changed address for bob")]) + (bob ##> "/_get chat #1 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" bob <# "#team alice> hey" bob #> "#team hi" @@ -3215,8 +3222,7 @@ setupDesynchronizedRatchet ps alice = do alice #> "@bob 2" alice #> "@bob 3" (bob "/tail @alice 1" - bob <# "alice> decryption error, possibly due to the device change (header, 3 messages)" + (bob ##> "/tail @alice 1" >> dropTime <$> getTermLine bob) `shouldEventuallyReturn` "alice> decryption error, possibly due to the device change (header, 3 messages)" bob ##> "@alice 1" bob <## "error: command is prohibited, sendMessagesB: send prohibited" (alice ("/_get chat @2 count=3", chat, [(1, "connection synchronization started"), (0, "connection synchronization agreed"), (0, "connection synchronized")]) - alice #$> ("/_get chat @2 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) + (bob ##> "/_get chat @2 count=3" >> chat <$> getTermLine bob) `shouldEventuallyReturn` [(1, "connection synchronization started"), (0, "connection synchronization agreed"), (0, "connection synchronized")] + (alice ##> "/_get chat @2 count=2" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(0, "connection synchronization agreed"), (0, "connection synchronized")] alice #> "@bob hello again" bob <# "alice> hello again" @@ -3286,8 +3292,8 @@ testSyncRatchetCodeReset ps = bob <## "alice: connection synchronized" threadDelay 100000 - bob #$> ("/_get chat @2 count=4", chat, [(1, "connection synchronization started"), (0, "connection synchronization agreed"), (0, "security code changed"), (0, "connection synchronized")]) - alice #$> ("/_get chat @2 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) + (bob ##> "/_get chat @2 count=4" >> chat <$> getTermLine bob) `shouldEventuallyReturn` [(1, "connection synchronization started"), (0, "connection synchronization agreed"), (0, "security code changed"), (0, "connection synchronized")] + (alice ##> "/_get chat @2 count=2" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(0, "connection synchronization agreed"), (0, "connection synchronized")] -- connection not verified bob ##> "/i alice" diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 14851444a7..6c5ae61704 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -29,6 +29,7 @@ import Simplex.Messaging.Crypto.BBS (BBSPublicKey, bbsKeyGen) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) +import System.FilePath (()) import Test.Hspec hiding (it) chatFileTests :: SpecWith TestParams @@ -79,8 +80,9 @@ chatFileTests = do it "file proof is rejected under another binding, size or expired badge" testFileBadgeProofStatus runTestMessageWithFile :: HasCallStack => TestParams -> IO () -runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do +runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer alice $ do connectUsers alice bob + let testJpg = tmpFile bob "test.jpg" alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"hi, sending a file\"}}]" alice <# "@bob hi, sending a file" @@ -91,9 +93,9 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX bob <## "use /fr 1 [/ | ] to receive it" alice <## "completed uploading file 1 (test.jpg) for bob" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + <### [ ConsoleString $ "saving file 1 from alice to " <> testJpg, "started receiving file 1 (test.jpg) from alice" ] bob <## "completed receiving file 1 (test.jpg) from alice" @@ -101,7 +103,7 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX alice <# "bob> received" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" + dest <- B.readFile testJpg dest `shouldBe` src alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg"), ((0, "received"), Nothing)]) @@ -109,10 +111,10 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX alice <## "Chat content types: file, text" alice #$> ("/_get chat @2 content=file count=100", chatF, [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg")]) - bob #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg"), ((1, "received"), Nothing)]) + bob #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((0, "hi, sending a file"), Just testJpg), ((1, "received"), Nothing)]) bob ##> "/_get content types @2" bob <## "Chat content types: file, text" - bob #$> ("/_get chat @2 content=file count=100", chatF, [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg")]) + bob #$> ("/_get chat @2 content=file count=100", chatF, [((0, "hi, sending a file"), Just testJpg)]) -- Test file with link in text - should appear in both file and link filters alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"check https://example.com for docs\"}}]" @@ -131,14 +133,15 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX bob ##> "/_get content types @2" bob <## "Chat content types: file, text" - bob #$> ("/_get chat @2 content=file count=100", chatF, [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg"), ((0, "check https://example.com for docs"), Nothing)]) + bob #$> ("/_get chat @2 content=file count=100", chatF, [((0, "hi, sending a file"), Just testJpg), ((0, "check https://example.com for docs"), Nothing)]) bob #$> ("/_get chat @2 content=link count=100", chatF, [((0, "check https://example.com for docs"), Nothing)]) testSendImage :: HasCallStack => TestParams -> IO () testSendImage = testChat2 aliceProfile bobProfile $ - \alice bob -> withXFTPServer $ do + \alice bob -> withXFTPServer alice $ do connectUsers alice bob + let testJpg = tmpFile bob "test.jpg" alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]" alice <# "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -146,29 +149,29 @@ testSendImage = bob <## "use /fr 1 [/ | ] to receive it" alice <## "completed uploading file 1 (test.jpg) for bob" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + <### [ ConsoleString $ "saving file 1 from alice to " <> testJpg, "started receiving file 1 (test.jpg) from alice" ] bob <## "completed receiving file 1 (test.jpg) from alice" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" + dest <- B.readFile testJpg dest `shouldBe` src alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, ""), Just "./tests/fixtures/test.jpg")]) - bob #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((0, ""), Just "./tests/tmp/test.jpg")]) + bob #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((0, ""), Just testJpg)]) -- deleting contact without files folder set should not remove file bob ##> "/d alice" bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" - fileExists <- doesFileExist "./tests/tmp/test.jpg" + fileExists <- doesFileExist testJpg fileExists `shouldBe` True testSenderMarkItemDeleted :: HasCallStack => TestParams -> IO () testSenderMarkItemDeleted = testChat2 aliceProfile bobProfile $ - \alice bob -> withXFTPServer $ do + \alice bob -> withXFTPServer alice $ do connectUsers alice bob alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test_1MB.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}]" alice <# "@bob hi, sending a file" @@ -182,7 +185,7 @@ testSenderMarkItemDeleted = alice #$> ("/_delete item @2 " <> itemId 1 <> " broadcast", id, "message marked deleted") bob <# "alice> [marked deleted] hi, sending a file" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob <## "file cancelled: test_1MB.pdf" bob ##> "/fs 1" @@ -191,10 +194,11 @@ testSenderMarkItemDeleted = testFilesFoldersSendImage :: HasCallStack => TestParams -> IO () testFilesFoldersSendImage = testChat2 aliceProfile bobProfile $ - \alice bob -> withXFTPServer $ do + \alice bob -> withXFTPServer alice $ do connectUsers alice bob + let bobFiles = tmpFile bob "app_files" alice #$> ("/_files_folder ./tests/fixtures", id, "ok") - bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") + bob #$> ("/_files_folder " <> bobFiles, id, "ok") alice ##> "/_send @2 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" @@ -210,12 +214,12 @@ testFilesFoldersSendImage = bob <## "completed receiving file 1 (test.jpg) from alice" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/app_files/test.jpg" + dest <- B.readFile (bobFiles "test.jpg") dest `shouldBe` src alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, ""), Just "test.jpg")]) bob #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((0, ""), Just "test.jpg")]) -- deleting contact with files folder set should remove file - checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do + checkActionDeletesFile (bobFiles "test.jpg") $ do bob ##> "/d alice" bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" @@ -223,11 +227,13 @@ testFilesFoldersSendImage = testFilesFoldersImageSndDelete :: HasCallStack => TestParams -> IO () testFilesFoldersImageSndDelete = testChat2 aliceProfile bobProfile $ - \alice bob -> withXFTPServer $ do + \alice bob -> withXFTPServer alice $ do connectUsers alice bob - alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") - copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf" - bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") + let aliceFiles = tmpFile alice "alice_app_files" + bobFiles = tmpFile bob "bob_app_files" + alice #$> ("/_files_folder " <> aliceFiles, id, "ok") + copyFile "./tests/fixtures/test_1MB.pdf" (aliceFiles "test_1MB.pdf") + bob #$> ("/_files_folder " <> bobFiles, id, "ok") alice ##> "/_send @2 json [{\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]" alice <# "/f @bob test_1MB.pdf" alice <## "use /fc 1 to cancel sending" @@ -243,23 +249,24 @@ testFilesFoldersImageSndDelete = bob <## "completed receiving file 1 (test_1MB.pdf) from alice" -- deleting contact should remove file - checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do + checkActionDeletesFile (aliceFiles "test_1MB.pdf") $ do alice ##> "/d bob" alice <## "bob: contact is deleted" bob <## "alice (Alice) deleted contact with you" bob ##> "/fs 1" bob <##. "receiving file 1 (test_1MB.pdf) complete" - checkActionDeletesFile "./tests/tmp/bob_app_files/test_1MB.pdf" $ do + checkActionDeletesFile (bobFiles "test_1MB.pdf") $ do bob ##> "/d alice" bob <## "alice: contact is deleted" testFilesFoldersImageRcvDelete :: HasCallStack => TestParams -> IO () testFilesFoldersImageRcvDelete = testChat2 aliceProfile bobProfile $ - \alice bob -> withXFTPServer $ do + \alice bob -> withXFTPServer alice $ do connectUsers alice bob + let bobFiles = tmpFile bob "app_files" alice #$> ("/_files_folder ./tests/fixtures", id, "ok") - bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") + bob #$> ("/_files_folder " <> bobFiles, id, "ok") alice ##> "/_send @2 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" @@ -275,7 +282,7 @@ testFilesFoldersImageRcvDelete = bob <## "completed receiving file 1 (test.jpg) from alice" -- deleting contact should remove file - checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do + checkActionDeletesFile (bobFiles "test.jpg") $ do bob ##> "/d alice" bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" @@ -283,7 +290,7 @@ testFilesFoldersImageRcvDelete = testSendImageWithTextAndQuote :: HasCallStack => TestParams -> IO () testSendImageWithTextAndQuote = testChat2 aliceProfile bobProfile $ - \alice bob -> withXFTPServer $ do + \alice bob -> withXFTPServer alice $ do connectUsers alice bob bob #> "@alice hi alice" alice <# "bob> hi alice" @@ -298,18 +305,18 @@ testSendImageWithTextAndQuote = bob <## "use /fr 1 [/ | ] to receive it" alice <## "completed uploading file 1 (test.jpg) for bob" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile bob "test.jpg", "started receiving file 1 (test.jpg) from alice" ] bob <## "completed receiving file 1 (test.jpg) from alice" src <- B.readFile "./tests/fixtures/test.jpg" - B.readFile "./tests/tmp/test.jpg" `shouldReturn` src + B.readFile (tmpFile bob "test.jpg") `shouldReturn` src alice #$> ("/_get chat @2 count=100", chat'', chatFeatures'' <> [((0, "hi alice"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi alice"), Just "./tests/fixtures/test.jpg")]) alice @@@ [("@bob", "hey bob")] - bob #$> ("/_get chat @2 count=100", chat'', chatFeatures'' <> [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) + bob #$> ("/_get chat @2 count=100", chat'', chatFeatures'' <> [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just $ tmpFile bob "test.jpg")]) bob @@@ [("@alice", "hey bob")] -- quoting (file + text) with file uses quoted text @@ -324,15 +331,15 @@ testSendImageWithTextAndQuote = alice <## "use /fr 2 [/ | ] to receive it" bob <## "completed uploading file 2 (test.pdf) for alice" - alice ##> "/fr 2 ./tests/tmp" + alice ##> ("/fr 2 " <> tmpDir alice) alice - <### [ "saving file 2 from bob to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 2 from bob to " <> tmpFile alice "test.pdf", "started receiving file 2 (test.pdf) from bob" ] alice <## "completed receiving file 2 (test.pdf) from bob" txtSrc <- B.readFile "./tests/fixtures/test.pdf" - B.readFile "./tests/tmp/test.pdf" `shouldReturn` txtSrc + B.readFile (tmpFile alice "test.pdf") `shouldReturn` txtSrc -- quoting (file without text) with file uses file name alice ##> ("/_send @2 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> itemId 3 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]") @@ -346,20 +353,22 @@ testSendImageWithTextAndQuote = bob <## "use /fr 3 [/ | ] to receive it" alice <## "completed uploading file 3 (test.jpg) for bob" - bob ##> "/fr 3 ./tests/tmp" + bob ##> ("/fr 3 " <> tmpDir bob) bob - <### [ "saving file 3 from alice to ./tests/tmp/test_1.jpg", + <### [ ConsoleString $ "saving file 3 from alice to " <> tmpFile bob "test_1.jpg", "started receiving file 3 (test.jpg) from alice" ] bob <## "completed receiving file 3 (test.jpg) from alice" - B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src + B.readFile (tmpFile bob "test_1.jpg") `shouldReturn` src testGroupSendImage :: HasCallStack => TestParams -> IO () testGroupSendImage = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do + \alice bob cath -> withXFTPServer alice $ do createGroup3 "team" alice bob cath + let bobJpg = tmpFile bob "test.jpg" + cathJpg = tmpFile cath "test_1.jpg" threadDelay 1000000 alice ##> "/_send #1 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" @@ -374,16 +383,16 @@ testGroupSendImage = ] alice <## "completed uploading file 1 (test.jpg) for #team" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + <### [ ConsoleString $ "saving file 1 from alice to " <> bobJpg, "started receiving file 1 (test.jpg) from alice" ] bob <## "completed receiving file 1 (test.jpg) from alice" - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) cath - <### [ "saving file 1 from alice to ./tests/tmp/test_1.jpg", + <### [ ConsoleString $ "saving file 1 from alice to " <> cathJpg, "started receiving file 1 (test.jpg) from alice" ] cath <## "completed receiving file 1 (test.jpg) from alice" @@ -395,9 +404,9 @@ testGroupSendImage = [alice, bob] *<# "#team cath> received too" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" + dest <- B.readFile bobJpg dest `shouldBe` src - dest2 <- B.readFile "./tests/tmp/test_1.jpg" + dest2 <- B.readFile cathJpg dest2 `shouldBe` src alice #$> ("/_get chat #1 count=3", chatF, [((1, ""), Just "./tests/fixtures/test.jpg"), ((0, "received"), Nothing), ((0, "received too"), Nothing)]) @@ -405,21 +414,23 @@ testGroupSendImage = alice <## "Chat content types: image, text" alice #$> ("/_get chat #1 content=image count=100", chatF, [((1, ""), Just "./tests/fixtures/test.jpg")]) - bob #$> ("/_get chat #1 count=3", chatF, [((0, ""), Just "./tests/tmp/test.jpg"), ((1, "received"), Nothing), ((0, "received too"), Nothing)]) + bob #$> ("/_get chat #1 count=3", chatF, [((0, ""), Just bobJpg), ((1, "received"), Nothing), ((0, "received too"), Nothing)]) bob ##> "/_get content types #1" bob <## "Chat content types: image, text" - bob #$> ("/_get chat #1 content=image count=100", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) + bob #$> ("/_get chat #1 content=image count=100", chatF, [((0, ""), Just bobJpg)]) - cath #$> ("/_get chat #1 count=3", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg"), ((0, "received"), Nothing), ((1, "received too"), Nothing)]) + cath #$> ("/_get chat #1 count=3", chatF, [((0, ""), Just cathJpg), ((0, "received"), Nothing), ((1, "received too"), Nothing)]) cath ##> "/_get content types #1" cath <## "Chat content types: image, text" - cath #$> ("/_get chat #1 content=image count=100", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg")]) + cath #$> ("/_get chat #1 content=image count=100", chatF, [((0, ""), Just cathJpg)]) testGroupSendImageWithTextAndQuote :: HasCallStack => TestParams -> IO () testGroupSendImageWithTextAndQuote = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do + \alice bob cath -> withXFTPServer alice $ do createGroup3 "team" alice bob cath + let bobJpg = tmpFile bob "test.jpg" + cathJpg = tmpFile cath "test_1.jpg" threadDelay 1000000 bob #> "#team hi team" concurrently_ @@ -446,42 +457,44 @@ testGroupSendImageWithTextAndQuote = ] alice <## "completed uploading file 1 (test.jpg) for #team" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + <### [ ConsoleString $ "saving file 1 from alice to " <> bobJpg, "started receiving file 1 (test.jpg) from alice" ] bob <## "completed receiving file 1 (test.jpg) from alice" - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) cath - <### [ "saving file 1 from alice to ./tests/tmp/test_1.jpg", + <### [ ConsoleString $ "saving file 1 from alice to " <> cathJpg, "started receiving file 1 (test.jpg) from alice" ] cath <## "completed receiving file 1 (test.jpg) from alice" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" + dest <- B.readFile bobJpg dest `shouldBe` src - dest2 <- B.readFile "./tests/tmp/test_1.jpg" + dest2 <- B.readFile cathJpg dest2 `shouldBe` src alice #$> ("/_get chat #1 count=2", chat'', [((0, "hi team"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi team"), Just "./tests/fixtures/test.jpg")]) alice @@@ [("#team", "hey bob"), ("@bob", "sent invitation to join group team as admin"), ("@cath", "sent invitation to join group team as admin")] - bob #$> ("/_get chat #1 count=2", chat'', [((1, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi team"), Just "./tests/tmp/test.jpg")]) + bob #$> ("/_get chat #1 count=2", chat'', [((1, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi team"), Just bobJpg)]) bob @@@ [("#team", "hey bob"), ("@alice", "received invitation to join group team as admin")] - cath #$> ("/_get chat #1 count=2", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) + cath #$> ("/_get chat #1 count=2", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just cathJpg)]) cath @@@ [("#team", "hey bob"), ("@alice", "received invitation to join group team as admin")] testSendMultiFilesDirect :: HasCallStack => TestParams -> IO () testSendMultiFilesDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do + withXFTPServer alice $ do connectUsers alice bob - alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") - copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" - copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf" - bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") + let aliceFiles = tmpFile alice "alice_app_files" + bobFiles = tmpFile bob "bob_app_files" + alice #$> ("/_files_folder " <> aliceFiles, id, "ok") + copyFile "./tests/fixtures/test.jpg" (aliceFiles "test.jpg") + copyFile "./tests/fixtures/test.pdf" (aliceFiles "test.pdf") + bob #$> ("/_files_folder " <> bobFiles, id, "ok") let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}" cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}" @@ -525,12 +538,12 @@ testSendMultiFilesDirect = ] bob <## "completed receiving file 2 (test.pdf) from alice" - src1 <- B.readFile "./tests/tmp/alice_app_files/test.jpg" - dest1 <- B.readFile "./tests/tmp/bob_app_files/test.jpg" + src1 <- B.readFile (aliceFiles "test.jpg") + dest1 <- B.readFile (bobFiles "test.jpg") dest1 `shouldBe` src1 - src2 <- B.readFile "./tests/tmp/alice_app_files/test.pdf" - dest2 <- B.readFile "./tests/tmp/bob_app_files/test.pdf" + src2 <- B.readFile (aliceFiles "test.pdf") + dest2 <- B.readFile (bobFiles "test.pdf") dest2 `shouldBe` src2 alice #$> ("/_get chat @2 count=3", chatF, [((1, "message without file"), Nothing), ((1, "sending file 1"), Just "test.jpg"), ((1, "sending file 2"), Just "test.pdf")]) @@ -539,16 +552,19 @@ testSendMultiFilesDirect = testSendMultiFilesGroup :: HasCallStack => TestParams -> IO () testSendMultiFilesGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - withXFTPServer $ do + withXFTPServer alice $ do createGroup3 "team" alice bob cath threadDelay 1000000 - alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") - copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" - copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf" - bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") - cath #$> ("/_files_folder ./tests/tmp/cath_app_files", id, "ok") + let aliceFiles = tmpFile alice "alice_app_files" + bobFiles = tmpFile bob "bob_app_files" + cathFiles = tmpFile cath "cath_app_files" + alice #$> ("/_files_folder " <> aliceFiles, id, "ok") + copyFile "./tests/fixtures/test.jpg" (aliceFiles "test.jpg") + copyFile "./tests/fixtures/test.pdf" (aliceFiles "test.pdf") + bob #$> ("/_files_folder " <> bobFiles, id, "ok") + cath #$> ("/_files_folder " <> cathFiles, id, "ok") let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}" cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}" @@ -616,15 +632,15 @@ testSendMultiFilesGroup = ] cath <## "completed receiving file 2 (test.pdf) from alice" - src1 <- B.readFile "./tests/tmp/alice_app_files/test.jpg" - dest1_1 <- B.readFile "./tests/tmp/bob_app_files/test.jpg" - dest1_2 <- B.readFile "./tests/tmp/cath_app_files/test.jpg" + src1 <- B.readFile (aliceFiles "test.jpg") + dest1_1 <- B.readFile (bobFiles "test.jpg") + dest1_2 <- B.readFile (cathFiles "test.jpg") dest1_1 `shouldBe` src1 dest1_2 `shouldBe` src1 - src2 <- B.readFile "./tests/tmp/alice_app_files/test.pdf" - dest2_1 <- B.readFile "./tests/tmp/bob_app_files/test.pdf" - dest2_2 <- B.readFile "./tests/tmp/cath_app_files/test.pdf" + src2 <- B.readFile (aliceFiles "test.pdf") + dest2_1 <- B.readFile (bobFiles "test.pdf") + dest2_2 <- B.readFile (cathFiles "test.pdf") dest2_1 `shouldBe` src2 dest2_2 `shouldBe` src2 @@ -648,18 +664,19 @@ testXFTPRoundFDCount = do testXFTPFileTransfer :: HasCallStack => TestParams -> IO () testXFTPFileTransfer = testChat2 aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do + withXFTPServer alice $ do connectUsers alice bob + let testPdf = tmpFile bob "test.pdf" alice #> "/f @bob ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) concurrentlyN_ [ alice <## "completed uploading file 1 (test.pdf) for bob", bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> testPdf, "started receiving file 1 (test.pdf) from alice" ] ] @@ -668,10 +685,10 @@ testXFTPFileTransfer = alice ##> "/fs 1" alice <## "sending file 1 (test.pdf) complete" bob ##> "/fs 1" - bob <## "receiving file 1 (test.pdf) complete, path: ./tests/tmp/test.pdf" + bob <## ("receiving file 1 (test.pdf) complete, path: " <> testPdf) src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" + dest <- B.readFile testPdf dest `shouldBe` src testXFTPFileTransferEncrypted :: HasCallStack => TestParams -> IO () @@ -679,32 +696,35 @@ testXFTPFileTransferEncrypted = testChat2 aliceProfile bobProfile $ \alice bob -> do src <- B.readFile "./tests/fixtures/test.pdf" srcLen <- getFileSize "./tests/fixtures/test.pdf" - let srcPath = "./tests/tmp/alice/test.pdf" - createDirectoryIfMissing True "./tests/tmp/alice/" - createDirectoryIfMissing True "./tests/tmp/bob/" + let srcPath = tmpFile alice "alice/test.pdf" + bobDir = tmpFile bob "bob/" + createDirectoryIfMissing True $ tmpFile alice "alice/" + createDirectoryIfMissing True bobDir WFResult cfArgs <- chatWriteFile (chatController alice) srcPath src let fileJSON = LB.unpack $ J.encode $ CryptoFile srcPath $ Just cfArgs - withXFTPServer $ do + withXFTPServer alice $ do connectUsers alice bob alice ##> ("/_send @2 json [{\"msgContent\":{\"type\":\"file\", \"text\":\"\"}, \"fileSource\": " <> fileJSON <> "}]") - alice <# "/f @bob ./tests/tmp/alice/test.pdf" + alice <# ("/f @bob " <> srcPath) alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 encrypt=on ./tests/tmp/bob/" - bob <## "saving file 1 from alice to ./tests/tmp/bob/test.pdf" + bob ##> ("/fr 1 encrypt=on " <> bobDir) + bob + <### [ ConsoleString $ "saving file 1 from alice to " <> bobDir <> "test.pdf", + "started receiving file 1 (test.pdf) from alice" + ] alice <## "completed uploading file 1 (test.pdf) for bob" - bob <## "started receiving file 1 (test.pdf) from alice" bob <## "completed receiving file 1 (test.pdf) from alice" Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob - Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) + Right dest <- chatReadFile (bobDir <> "test.pdf") (strEncode key) (strEncode nonce) LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src testXFTPAcceptAfterUpload :: HasCallStack => TestParams -> IO () testXFTPAcceptAfterUpload = testChat2 aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do + withXFTPServer alice $ do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -715,21 +735,21 @@ testXFTPAcceptAfterUpload = threadDelay 100000 - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile bob "test.pdf", "started receiving file 1 (test.pdf) from alice" ] bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" + dest <- B.readFile (tmpFile bob "test.pdf") dest `shouldBe` src testXFTPGroupFileTransfer :: HasCallStack => TestParams -> IO () testXFTPGroupFileTransfer = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - withXFTPServer $ do + withXFTPServer alice $ do createGroup3 "team" alice bob cath alice #> "/f #team ./tests/fixtures/test.pdf" @@ -744,23 +764,23 @@ testXFTPGroupFileTransfer = ] alice <## "completed uploading file 1 (test.pdf) for #team" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile bob "test.pdf", "started receiving file 1 (test.pdf) from alice" ] bob <## "completed receiving file 1 (test.pdf) from alice" - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) cath - <### [ "saving file 1 from alice to ./tests/tmp/test_1.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile cath "test_1.pdf", "started receiving file 1 (test.pdf) from alice" ] cath <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest1 <- B.readFile "./tests/tmp/test.pdf" - dest2 <- B.readFile "./tests/tmp/test_1.pdf" + dest1 <- B.readFile (tmpFile bob "test.pdf") + dest2 <- B.readFile (tmpFile cath "test_1.pdf") dest1 `shouldBe` src dest2 `shouldBe` src @@ -775,7 +795,7 @@ testXFTPFileBadgeProof ps = do Right (pk, sk) <- bbsKeyGen testChatCfg2 (badgeFileCfg pk) aliceProfile bobProfile (test sk) ps where - test sk alice bob = withXFTPServer $ do + test sk alice bob = withXFTPServer ps $ do connectUsers alice bob addTestBadge alice =<< issueTestBadge sk futureDate @@ -783,18 +803,18 @@ testXFTPFileBadgeProof ps = do alice <## "use /fc 1 to cancel sending" bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir ps) concurrentlyN_ [ alice <## "completed uploading file 1 (test.pdf) for bob", bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile ps "test.pdf", "started receiving file 1 (test.pdf) from alice" ] ] bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" + dest <- B.readFile (tmpFile ps "test.pdf") dest `shouldBe` src testXFTPGroupFileBadgeProof :: HasCallStack => TestParams -> IO () @@ -802,7 +822,7 @@ testXFTPGroupFileBadgeProof ps = do Right (pk, sk) <- bbsKeyGen testChatCfg3 (badgeFileCfg pk) aliceProfile bobProfile cathProfile (test sk) ps where - test sk alice bob cath = withXFTPServer $ do + test sk alice bob cath = withXFTPServer ps $ do createGroup3 "team" alice bob cath addTestBadge alice =<< issueTestBadge sk futureDate @@ -818,28 +838,28 @@ testXFTPGroupFileBadgeProof ps = do ] alice <## "completed uploading file 1 (test.pdf) for #team" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir ps) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile ps "test.pdf", "started receiving file 1 (test.pdf) from alice" ] bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" + dest <- B.readFile (tmpFile ps "test.pdf") dest `shouldBe` src testXFTPFileNoBadgeProof :: HasCallStack => TestParams -> IO () testXFTPFileNoBadgeProof ps = withNewTestChatCfg ps sndCfg "alice" aliceProfile $ \alice -> - withNewTestChatCfg ps rcvCfg "bob" bobProfile $ \bob -> withXFTPServer $ do + withNewTestChatCfg ps rcvCfg "bob" bobProfile $ \bob -> withXFTPServer ps $ do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "file is above the limit of 100000 bytes: sender has no badge" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir ps) concurrentlyN_ [ bob <## "file size exceeds the limit: test.pdf", alice <## "completed uploading file 1 (test.pdf) for bob" @@ -852,7 +872,7 @@ testXFTPFileBadgeAboveLimit :: HasCallStack => TestParams -> IO () testXFTPFileBadgeAboveLimit ps = do Right (pk, sk) <- bbsKeyGen withNewTestChatCfg ps (badgeFileCfg pk) "alice" aliceProfile $ \alice -> - withNewTestChatCfg ps (rcvCfg pk) "bob" bobProfile $ \bob -> withXFTPServer $ do + withNewTestChatCfg ps (rcvCfg pk) "bob" bobProfile $ \bob -> withXFTPServer ps $ do connectUsers alice bob addTestBadge alice =<< issueTestBadge sk futureDate @@ -860,7 +880,7 @@ testXFTPFileBadgeAboveLimit ps = do alice <## "use /fc 1 to cancel sending" bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "file is above the limit of 150000 bytes: above the limit of the sender badge" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir ps) concurrentlyN_ [ bob <## "file size exceeds the limit: test.pdf", alice <## "completed uploading file 1 (test.pdf) for bob" @@ -874,7 +894,7 @@ testXFTPSndFileBadgeLimit ps = do testChatCfg2 (cfg pk) aliceProfile bobProfile (test sk) ps where cfg pk = badgeFileCfgLimits pk FileSizeLimits {noBadge = 100000, supporter = 150000, legend = 300000} - test sk alice bob = withXFTPServer $ do + test sk alice bob = withXFTPServer ps $ do connectUsers alice bob addTestBadge alice =<< issueTestBadgeType sk BTSupporter futureDate @@ -896,7 +916,7 @@ testXFTPSndFileBadgeGrace ps = do Right (pk, sk) <- bbsKeyGen testChatCfg2 (badgeFileCfg pk) aliceProfile bobProfile (test sk) ps where - test sk alice bob = withXFTPServer $ do + test sk alice bob = withXFTPServer ps $ do connectUsers alice bob now <- getCurrentTime @@ -941,7 +961,7 @@ testFileBadgeProofStatus ps = do testXFTPDeleteUploadedFile :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFile = testChat2 aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do + withXFTPServer alice $ do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -956,14 +976,15 @@ testXFTPDeleteUploadedFile = bob <## "alice cancelled sending file 1 (test.pdf)" ] - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob <## "file cancelled: test.pdf" testXFTPDeleteUploadedFileGroup :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFileGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - withXFTPServer $ do + withXFTPServer alice $ do createGroup3 "team" alice bob cath + let testPdf = tmpFile bob "test.pdf" alice #> "/f #team ./tests/fixtures/test.pdf" alice <## "use /fc 1 to cancel sending" @@ -977,9 +998,9 @@ testXFTPDeleteUploadedFileGroup = ] alice <## "completed uploading file 1 (test.pdf) for #team" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> testPdf, "started receiving file 1 (test.pdf) from alice" ] bob <## "completed receiving file 1 (test.pdf) from alice" @@ -987,12 +1008,12 @@ testXFTPDeleteUploadedFileGroup = alice ##> "/fs 1" alice <## "sending file 1 (test.pdf) complete" bob ##> "/fs 1" - bob <## "receiving file 1 (test.pdf) complete, path: ./tests/tmp/test.pdf" + bob <## ("receiving file 1 (test.pdf) complete, path: " <> testPdf) cath ##> "/fs 1" cath <## "receiving file 1 (test.pdf) not accepted yet, use /fr 1 to receive file" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" + dest <- B.readFile testPdf dest `shouldBe` src alice ##> "/fc 1" @@ -1006,21 +1027,21 @@ testXFTPDeleteUploadedFileGroup = alice ##> "/fs 1" alice <## "sending file 1 (test.pdf) cancelled" bob ##> "/fs 1" - bob <## "receiving file 1 (test.pdf) complete, path: ./tests/tmp/test.pdf" + bob <## ("receiving file 1 (test.pdf) complete, path: " <> testPdf) cath ##> "/fs 1" cath <## "receiving file 1 (test.pdf) cancelled" - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) cath <## "file cancelled: test.pdf" testXFTPWithRelativePaths :: HasCallStack => TestParams -> IO () testXFTPWithRelativePaths = testChat2 aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do + withXFTPServer alice $ do -- agent is passed xftp work directory only on chat start, -- so for test we work around by stopping and starting chat - setRelativePaths alice "./tests/fixtures" "./tests/tmp/alice_xftp" - setRelativePaths bob "./tests/tmp/bob_files" "./tests/tmp/bob_xftp" + setRelativePaths alice "./tests/fixtures" (tmpFile alice "alice_xftp") + setRelativePaths bob (tmpFile bob "bob_files") (tmpFile bob "bob_xftp") connectUsers alice bob alice #> "/f @bob test.pdf" @@ -1038,12 +1059,12 @@ testXFTPWithRelativePaths = bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/bob_files/test.pdf" + dest <- B.readFile (tmpFile bob "bob_files/test.pdf") dest `shouldBe` src testXFTPContinueRcv :: HasCallStack => TestParams -> IO () testXFTPContinueRcv ps = do - withXFTPServer $ do + withXFTPServer ps $ do withNewTestChat ps "alice" aliceProfile $ \alice -> do withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob @@ -1057,9 +1078,9 @@ testXFTPContinueRcv ps = do -- server is down - file is not received withTestChat ps "bob" $ \bob -> do bob <## "subscribed 1 connections on server localhost" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir ps) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile ps "test.pdf", "started receiving file 1 (test.pdf) from alice" ] @@ -1068,19 +1089,19 @@ testXFTPContinueRcv ps = do (bob do bob <## "subscribed 1 connections on server localhost" bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" + dest <- B.readFile (tmpFile ps "test.pdf") dest `shouldBe` src testXFTPMarkToReceive :: HasCallStack => TestParams -> IO () testXFTPMarkToReceive = do testChat2 aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do + withXFTPServer alice $ do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -1095,8 +1116,8 @@ testXFTPMarkToReceive = do bob ##> "/_stop" bob <## "chat stopped" - bob #$> ("/_files_folder ./tests/tmp/bob_files", id, "ok") - bob #$> ("/_temp_folder ./tests/tmp/bob_xftp", id, "ok") + bob #$> ("/_files_folder " <> tmpFile bob "bob_files", id, "ok") + bob #$> ("/_temp_folder " <> tmpFile bob "bob_xftp", id, "ok") threadDelay 100000 @@ -1111,12 +1132,12 @@ testXFTPMarkToReceive = do bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/bob_files/test.pdf" + dest <- B.readFile (tmpFile bob "bob_files/test.pdf") dest `shouldBe` src testXFTPRcvError :: HasCallStack => TestParams -> IO () testXFTPRcvError ps = do - withXFTPServer $ do + withXFTPServer ps $ do withNewTestChat ps "alice" aliceProfile $ \alice -> do withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob @@ -1128,12 +1149,12 @@ testXFTPRcvError ps = do alice <## "completed uploading file 1 (test.pdf) for bob" -- server is up w/t store log - file reception should fail - withXFTPServer' xftpServerConfig {serverStoreCfg = XSCMemory Nothing, storeLogFile = Nothing} $ do + withXFTPServer' (xftpServerConfig ps) {serverStoreCfg = XSCMemory Nothing, storeLogFile = Nothing} $ do withTestChat ps "bob" $ \bob -> do bob <## "subscribed 1 connections on server localhost" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir ps) bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile ps "test.pdf", "started receiving file 1 (test.pdf) from alice" ] bob <## "error receiving file 1 (test.pdf) from alice" @@ -1145,20 +1166,22 @@ testXFTPRcvError ps = do testXFTPCancelRcvRepeat :: HasCallStack => TestParams -> IO () testXFTPCancelRcvRepeat = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile"] + withXFTPServer alice $ do + let testfile = tmpFile alice "testfile" + testfile1 = tmpFile bob "testfile_1" + xftpCLI ["rand", testfile, "17mb"] `shouldReturn` ["File created: " <> testfile] connectUsers alice bob - alice #> "/f @bob ./tests/tmp/testfile" + alice #> ("/f @bob " <> testfile) alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file testfile (17.0 MiB / 17825792 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) concurrentlyN_ [ alice <## "completed uploading file 1 (testfile) for bob", bob - <### [ "saving file 1 from alice to ./tests/tmp/testfile_1", + <### [ ConsoleString $ "saving file 1 from alice to " <> testfile1, "started receiving file 1 (testfile) from alice" ] ] @@ -1169,33 +1192,35 @@ testXFTPCancelRcvRepeat = bob <##. "receiving file 1 (testfile) progress" bob ##> "/fc 1" - bob <## "cancelled receiving file 1 (testfile) from alice" + bob + <### [ "cancelled receiving file 1 (testfile) from alice", + StartsWith "chat db error: SERcvFileNotFoundXFTP" + ] bob ##> "/fs 1" bob <## "receiving file 1 (testfile) not accepted yet, use /fr 1 to receive file" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) bob - <### [ "saving file 1 from alice to ./tests/tmp/testfile_1", - "started receiving file 1 (testfile) from alice", - StartsWith "chat db error: SERcvFileNotFoundXFTP" + <### [ ConsoleString $ "saving file 1 from alice to " <> testfile1, + "started receiving file 1 (testfile) from alice" ] bob <## "completed receiving file 1 (testfile) from alice" bob ##> "/fs 1" - bob <## "receiving file 1 (testfile) complete, path: ./tests/tmp/testfile_1" + bob <## ("receiving file 1 (testfile) complete, path: " <> testfile1) - src <- B.readFile "./tests/tmp/testfile" - dest <- B.readFile "./tests/tmp/testfile_1" + src <- B.readFile testfile + dest <- B.readFile testfile1 dest `shouldBe` src where cfg = testCfg {xftpDescrPartSize = 200} testAutoAcceptFile :: HasCallStack => TestParams -> IO () testAutoAcceptFile = - testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> withXFTPServer alice $ do connectUsers alice bob - bob ##> "/_files_folder ./tests/tmp/bob_files" + bob ##> ("/_files_folder " <> tmpFile bob "bob_files") bob <## "ok" alice #> "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -1218,7 +1243,7 @@ testAutoAcceptFile = testProhibitFiles :: HasCallStack => TestParams -> IO () testProhibitFiles = - testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer alice $ do createGroup3 "team" alice bob cath alice ##> "/set files #team off" alice <## "updated group preferences:" @@ -1240,7 +1265,7 @@ testProhibitFiles = testXFTPStandaloneSmall :: HasCallStack => TestParams -> IO () testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do - withXFTPServer $ do + withXFTPServer src $ do logNote "sending" src ##> "/_upload 1 ./tests/fixtures/logo.jpg" src <## "started standalone uploading file 1 (logo.jpg)" @@ -1254,7 +1279,7 @@ testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst _uri4 <- getTermLine src logNote "receiving" - let dstFile = "./tests/tmp/logo.jpg" + let dstFile = tmpFile dst "logo.jpg" dst ##> ("/_download 1 " <> uri3 <> " " <> dstFile) dst <## "started standalone receiving file 1 (logo.jpg)" -- silent progress events @@ -1265,7 +1290,7 @@ testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst testXFTPStandaloneSmallInfo :: HasCallStack => TestParams -> IO () testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do - withXFTPServer $ do + withXFTPServer src $ do logNote "sending" src ##> "/_upload 1 ./tests/fixtures/logo.jpg" src <## "started standalone uploading file 1 (logo.jpg)" @@ -1284,7 +1309,7 @@ testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst <## "{\"secret\":\"*********\"}" logNote "receiving" - let dstFile = "./tests/tmp/logo.jpg" + let dstFile = tmpFile dst "logo.jpg" dst ##> ("/_download 1 " <> uri <> " " <> dstFile) -- download sucessfully discarded extra info dst <## "started standalone receiving file 1 (logo.jpg)" -- silent progress events @@ -1295,11 +1320,12 @@ testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src testXFTPStandaloneLarge :: HasCallStack => TestParams -> IO () testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do - withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + withXFTPServer src $ do + let srcFile = tmpFile src "testfile.in" + xftpCLI ["rand", srcFile, "17mb"] `shouldReturn` ["File created: " <> srcFile] logNote "sending" - src ##> "/_upload 1 ./tests/tmp/testfile.in" + src ##> ("/_upload 1 " <> srcFile) src <## "started standalone uploading file 1 (testfile.in)" -- silent progress events threadDelay 250000 @@ -1311,22 +1337,23 @@ testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst _uri4 <- getTermLine src logNote "receiving" - let dstFile = "./tests/tmp/testfile.out" + let dstFile = tmpFile dst "testfile.out" dst ##> ("/_download 1 " <> uri <> " " <> dstFile) dst <## "started standalone receiving file 1 (testfile.out)" -- silent progress events threadDelay 250000 dst <## "completed standalone receiving file 1 (testfile.out)" - srcBody <- B.readFile "./tests/tmp/testfile.in" + srcBody <- B.readFile srcFile B.readFile dstFile `shouldReturn` srcBody testXFTPStandaloneLargeInfo :: HasCallStack => TestParams -> IO () testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do - withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + withXFTPServer src $ do + let srcFile = tmpFile src "testfile.in" + xftpCLI ["rand", srcFile, "17mb"] `shouldReturn` ["File created: " <> srcFile] logNote "sending" - src ##> "/_upload 1 ./tests/tmp/testfile.in" + src ##> ("/_upload 1 " <> srcFile) src <## "started standalone uploading file 1 (testfile.in)" -- silent progress events @@ -1344,22 +1371,23 @@ testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst <## "{\"secret\":\"*********\"}" logNote "receiving" - let dstFile = "./tests/tmp/testfile.out" + let dstFile = tmpFile dst "testfile.out" dst ##> ("/_download 1 " <> uri <> " " <> dstFile) dst <## "started standalone receiving file 1 (testfile.out)" -- silent progress events threadDelay 250000 dst <## "completed standalone receiving file 1 (testfile.out)" - srcBody <- B.readFile "./tests/tmp/testfile.in" + srcBody <- B.readFile srcFile B.readFile dstFile `shouldReturn` srcBody testXFTPStandaloneCancelSnd :: HasCallStack => TestParams -> IO () testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do - withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + withXFTPServer src $ do + let srcFile = tmpFile src "testfile.in" + xftpCLI ["rand", srcFile, "17mb"] `shouldReturn` ["File created: " <> srcFile] logNote "sending" - src ##> "/_upload 1 ./tests/tmp/testfile.in" + src ##> ("/_upload 1 " <> srcFile) src <## "started standalone uploading file 1 (testfile.in)" -- silent progress events threadDelay 250000 @@ -1376,7 +1404,7 @@ testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src threadDelay 1000000 logNote "trying to receive cancelled" - dst ##> ("/_download 1 " <> uri <> " " <> "./tests/tmp/should.not.extist") + dst ##> ("/_download 1 " <> uri <> " " <> tmpFile dst "should.not.extist") dst <## "started standalone receiving file 1 (should.not.extist)" threadDelay 100000 logWarn "no error?" @@ -1385,12 +1413,14 @@ testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src testXFTPStandaloneRelativePaths :: HasCallStack => TestParams -> IO () testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do - withXFTPServer $ do + withXFTPServer src $ do logNote "sending" - src #$> ("/_files_folder ./tests/tmp/src_files", id, "ok") - src #$> ("/_temp_folder ./tests/tmp/src_xftp_temp", id, "ok") + let srcFiles = tmpFile src "src_files" + dstFiles = tmpFile dst "dst_files" + src #$> ("/_files_folder " <> srcFiles, id, "ok") + src #$> ("/_temp_folder " <> tmpFile src "src_xftp_temp", id, "ok") - xftpCLI ["rand", "./tests/tmp/src_files/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/src_files/testfile.in"] + xftpCLI ["rand", srcFiles "testfile.in", "17mb"] `shouldReturn` ["File created: " <> (srcFiles "testfile.in")] src ##> "/_upload 1 testfile.in" src <## "started standalone uploading file 1 (testfile.in)" @@ -1404,23 +1434,24 @@ testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \ _uri4 <- getTermLine src logNote "receiving" - dst #$> ("/_files_folder ./tests/tmp/dst_files", id, "ok") - dst #$> ("/_temp_folder ./tests/tmp/dst_xftp_temp", id, "ok") + dst #$> ("/_files_folder " <> dstFiles, id, "ok") + dst #$> ("/_temp_folder " <> tmpFile dst "dst_xftp_temp", id, "ok") dst ##> ("/_download 1 " <> uri <> " testfile.out") dst <## "started standalone receiving file 1 (testfile.out)" -- silent progress events threadDelay 250000 dst <## "completed standalone receiving file 1 (testfile.out)" - srcBody <- B.readFile "./tests/tmp/src_files/testfile.in" - B.readFile "./tests/tmp/dst_files/testfile.out" `shouldReturn` srcBody + srcBody <- B.readFile (srcFiles "testfile.in") + B.readFile (dstFiles "testfile.out") `shouldReturn` srcBody testXFTPStandaloneCancelRcv :: HasCallStack => TestParams -> IO () testXFTPStandaloneCancelRcv = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do - withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] + withXFTPServer src $ do + let srcFile = tmpFile src "testfile.in" + xftpCLI ["rand", srcFile, "17mb"] `shouldReturn` ["File created: " <> srcFile] logNote "sending" - src ##> "/_upload 1 ./tests/tmp/testfile.in" + src ##> ("/_upload 1 " <> srcFile) src <## "started standalone uploading file 1 (testfile.in)" -- silent progress events threadDelay 250000 @@ -1432,7 +1463,7 @@ testXFTPStandaloneCancelRcv = testChat2 aliceProfile aliceDesktopProfile $ \src _uri4 <- getTermLine src logNote "receiving" - let dstFile = "./tests/tmp/testfile.out" + let dstFile = tmpFile dst "testfile.out" dst ##> ("/_download 1 " <> uri <> " " <> dstFile) dst <## "started standalone receiving file 1 (testfile.out)" threadDelay 25000 -- give workers some time to avoid internal errors from starting tasks diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 1654ce5335..a5de4d2c37 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -15,6 +15,7 @@ import qualified Data.Text as T import Simplex.Chat.Library.Commands (fixedImagePreview) import Simplex.Chat.Types (ImageData (..)) import System.Directory (copyFile, doesFileExist, removeFile) +import System.FilePath (()) import Test.Hspec hiding (it) chatForwardTests :: SpecWith TestParams @@ -500,7 +501,7 @@ testForwardDeleteForOther = testForwardFileNoFilesFolder :: HasCallStack => TestParams -> IO () testForwardFileNoFilesFolder = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do + \alice bob cath -> withXFTPServer alice $ do connectUsers alice bob connectUsers bob cath @@ -513,51 +514,51 @@ testForwardFileNoFilesFolder = bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" + bob ##> ("/fr 1 " <> tmpDir bob) concurrentlyN_ [ alice <## "completed uploading file 1 (test.pdf) for bob", bob - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile bob "test.pdf", "started receiving file 1 (test.pdf) from alice" ] ] bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" + dest <- B.readFile (tmpFile bob "test.pdf") dest `shouldBe` src -- forward file bob `send` "@cath <- @alice hi" bob <# "@cath <- @alice" bob <## " hi" - bob <# "/f @cath ./tests/tmp/test.pdf" + bob <# ("/f @cath " <> tmpFile bob "test.pdf") bob <## "use /fc 2 to cancel sending" cath <# "bob> -> forwarded" cath <## " hi" cath <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" cath <## "use /fr 1 [/ | ] to receive it" - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) concurrentlyN_ [ bob <## "completed uploading file 2 (test.pdf) for cath", cath - <### [ "saving file 1 from bob to ./tests/tmp/test_1.pdf", + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile cath "test_1.pdf", "started receiving file 1 (test.pdf) from bob" ] ] cath <## "completed receiving file 1 (test.pdf) from bob" - dest2 <- B.readFile "./tests/tmp/test_1.pdf" + dest2 <- B.readFile (tmpFile cath "test_1.pdf") dest2 `shouldBe` src testForwardFileContactToContact :: HasCallStack => TestParams -> IO () testForwardFileContactToContact = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do - setRelativePaths alice "./tests/fixtures" "./tests/tmp/alice_xftp" - setRelativePaths bob "./tests/tmp/bob_files" "./tests/tmp/bob_xftp" - setRelativePaths cath "./tests/tmp/cath_files" "./tests/tmp/cath_xftp" + \alice bob cath -> withXFTPServer alice $ do + setRelativePaths alice "./tests/fixtures" (tmpFile alice "alice_xftp") + setRelativePaths bob (tmpFile bob "bob_files") (tmpFile bob "bob_xftp") + setRelativePaths cath (tmpFile cath "cath_files") (tmpFile cath "cath_xftp") connectUsers alice bob connectUsers bob cath @@ -581,7 +582,7 @@ testForwardFileContactToContact = bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/bob_files/test.pdf" + dest <- B.readFile (tmpFile bob "bob_files/test.pdf") dest `shouldBe` src -- forward file @@ -605,24 +606,24 @@ testForwardFileContactToContact = ] cath <## "completed receiving file 1 (test_1.pdf) from bob" - src2 <- B.readFile "./tests/tmp/bob_files/test_1.pdf" + src2 <- B.readFile (tmpFile bob "bob_files/test_1.pdf") src2 `shouldBe` dest - dest2 <- B.readFile "./tests/tmp/cath_files/test_1.pdf" + dest2 <- B.readFile (tmpFile cath "cath_files/test_1.pdf") dest2 `shouldBe` src2 -- deleting original file doesn't delete forwarded file - checkActionDeletesFile "./tests/tmp/bob_files/test.pdf" $ do + checkActionDeletesFile (tmpFile bob "bob_files/test.pdf") $ do bob ##> "/clear alice" bob <## "alice: all messages are removed locally ONLY" - fwdFileExists <- doesFileExist "./tests/tmp/bob_files/test_1.pdf" + fwdFileExists <- doesFileExist (tmpFile bob "bob_files/test_1.pdf") fwdFileExists `shouldBe` True testForwardFileGroupToNotes :: HasCallStack => TestParams -> IO () testForwardFileGroupToNotes = testChat2 aliceProfile cathProfile $ - \alice cath -> withXFTPServer $ do - setRelativePaths alice "./tests/fixtures" "./tests/tmp/alice_xftp" - setRelativePaths cath "./tests/tmp/cath_files" "./tests/tmp/cath_xftp" + \alice cath -> withXFTPServer alice $ do + setRelativePaths alice "./tests/fixtures" (tmpFile alice "alice_xftp") + setRelativePaths cath (tmpFile cath "cath_files") (tmpFile cath "cath_xftp") createGroup2 "team" alice cath createCCNoteFolder cath @@ -646,7 +647,7 @@ testForwardFileGroupToNotes = cath <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/cath_files/test.pdf" + dest <- B.readFile (tmpFile cath "cath_files/test.pdf") dest `shouldBe` src -- forward file @@ -655,23 +656,23 @@ testForwardFileGroupToNotes = cath <## " hi" cath <# "* file 2 (test_1.pdf)" - dest2 <- B.readFile "./tests/tmp/cath_files/test_1.pdf" + dest2 <- B.readFile (tmpFile cath "cath_files/test_1.pdf") dest2 `shouldBe` dest -- deleting original file doesn't delete forwarded file - checkActionDeletesFile "./tests/tmp/cath_files/test.pdf" $ do + checkActionDeletesFile (tmpFile cath "cath_files/test.pdf") $ do cath ##> "/clear #team" cath <## "#team: all messages are removed locally ONLY" - fwdFileExists <- doesFileExist "./tests/tmp/cath_files/test_1.pdf" + fwdFileExists <- doesFileExist (tmpFile cath "cath_files/test_1.pdf") fwdFileExists `shouldBe` True testForwardFileNotesToGroup :: HasCallStack => TestParams -> IO () testForwardFileNotesToGroup = testChat2 aliceProfile cathProfile $ - \alice cath -> withXFTPServer $ do - setRelativePaths alice "./tests/tmp/alice_files" "./tests/tmp/alice_xftp" - setRelativePaths cath "./tests/tmp/cath_files" "./tests/tmp/cath_xftp" - copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_files/test.pdf" + \alice cath -> withXFTPServer alice $ do + setRelativePaths alice (tmpFile alice "alice_files") (tmpFile alice "alice_xftp") + setRelativePaths cath (tmpFile cath "cath_files") (tmpFile cath "cath_xftp") + copyFile "./tests/fixtures/test.pdf" (tmpFile alice "alice_files/test.pdf") createCCNoteFolder alice createGroup2 "team" alice cath @@ -699,17 +700,17 @@ testForwardFileNotesToGroup = ] cath <## "completed receiving file 1 (test_1.pdf) from alice" - src <- B.readFile "./tests/tmp/alice_files/test.pdf" - src2 <- B.readFile "./tests/tmp/alice_files/test_1.pdf" + src <- B.readFile (tmpFile alice "alice_files/test.pdf") + src2 <- B.readFile (tmpFile alice "alice_files/test_1.pdf") src2 `shouldBe` src - dest2 <- B.readFile "./tests/tmp/cath_files/test_1.pdf" + dest2 <- B.readFile (tmpFile cath "cath_files/test_1.pdf") dest2 `shouldBe` src2 -- deleting original file doesn't delete forwarded file - checkActionDeletesFile "./tests/tmp/alice_files/test.pdf" $ do + checkActionDeletesFile (tmpFile alice "alice_files/test.pdf") $ do alice ##> "/clear *" alice <## "notes: all messages are removed" - fwdFileExists <- doesFileExist "./tests/tmp/alice_files/test_1.pdf" + fwdFileExists <- doesFileExist (tmpFile alice "alice_files/test_1.pdf") fwdFileExists `shouldBe` True testForwardContactToContactMulti :: HasCallStack => TestParams -> IO () @@ -789,14 +790,17 @@ testForwardGroupToGroupMulti = testMultiForwardFiles :: HasCallStack => TestParams -> IO () testMultiForwardFiles = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do - setRelativePaths alice "./tests/tmp/alice_app_files" "./tests/tmp/alice_xftp" - copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" - copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf" - copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf" - copyFile "./tests/fixtures/logo.jpg" "./tests/tmp/alice_app_files/logo.jpg" - setRelativePaths bob "./tests/tmp/bob_app_files" "./tests/tmp/bob_xftp" - setRelativePaths cath "./tests/tmp/cath_app_files" "./tests/tmp/cath_xftp" + \alice bob cath -> withXFTPServer alice $ do + let aliceFiles = tmpFile alice "alice_app_files" + bobFiles = tmpFile bob "bob_app_files" + cathFiles = tmpFile cath "cath_app_files" + setRelativePaths alice aliceFiles (tmpFile alice "alice_xftp") + copyFile "./tests/fixtures/test.jpg" (aliceFiles "test.jpg") + copyFile "./tests/fixtures/test.pdf" (aliceFiles "test.pdf") + copyFile "./tests/fixtures/test_1MB.pdf" (aliceFiles "test_1MB.pdf") + copyFile "./tests/fixtures/logo.jpg" (aliceFiles "logo.jpg") + setRelativePaths bob bobFiles (tmpFile bob "bob_xftp") + setRelativePaths cath cathFiles (tmpFile cath "cath_xftp") connectUsers alice bob connectUsers bob cath @@ -876,12 +880,12 @@ testMultiForwardFiles = ] bob <## "completed receiving file 2 (test.pdf) from alice" - src1 <- B.readFile "./tests/tmp/alice_app_files/test.jpg" - dest1 <- B.readFile "./tests/tmp/bob_app_files/test.jpg" + src1 <- B.readFile (aliceFiles "test.jpg") + dest1 <- B.readFile (bobFiles "test.jpg") dest1 `shouldBe` src1 - src2 <- B.readFile "./tests/tmp/alice_app_files/test.pdf" - dest2 <- B.readFile "./tests/tmp/bob_app_files/test.pdf" + src2 <- B.readFile (aliceFiles "test.pdf") + dest2 <- B.readFile (bobFiles "test.pdf") dest2 `shouldBe` src2 -- forward file @@ -955,14 +959,14 @@ testMultiForwardFiles = ] cath <## "completed receiving file 2 (test_1.pdf) from bob" - src1B <- B.readFile ("./tests/tmp/bob_app_files/" <> jpgFileName) + src1B <- B.readFile (bobFiles jpgFileName) src1B `shouldBe` dest1 - dest1C <- B.readFile ("./tests/tmp/cath_app_files/" <> jpgFileName) + dest1C <- B.readFile (cathFiles jpgFileName) dest1C `shouldBe` src1B - src2B <- B.readFile "./tests/tmp/bob_app_files/test_1.pdf" + src2B <- B.readFile (bobFiles "test_1.pdf") src2B `shouldBe` dest2 - dest2C <- B.readFile "./tests/tmp/cath_app_files/test_1.pdf" + dest2C <- B.readFile (cathFiles "test_1.pdf") dest2C `shouldBe` src2B bob ##> "/fr 3" @@ -986,19 +990,19 @@ testMultiForwardFiles = bob ##> ("/_forward plan @2 " <> msgIds) bob <## "all messages can be forwarded" - removeFile "./tests/tmp/bob_app_files/test_1MB.pdf" + removeFile (bobFiles "test_1MB.pdf") bob ##> ("/_forward plan @2 " <> msgIds) bob <## "1 file(s) are missing" bob <## "all messages can be forwarded" - removeFile "./tests/tmp/bob_app_files/test.pdf" + removeFile (bobFiles "test.pdf") bob ##> ("/_forward plan @2 " <> msgIds) bob <## "2 file(s) are missing" bob <## "5 message(s) out of 6 can be forwarded" -- deleting original file doesn't delete forwarded file - checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do + checkActionDeletesFile (bobFiles "test.jpg") $ do bob ##> "/clear alice" bob <## "alice: all messages are removed locally ONLY" - fwdFileExists <- doesFileExist ("./tests/tmp/bob_app_files/" <> jpgFileName) + fwdFileExists <- doesFileExist (bobFiles jpgFileName) fwdFileExists `shouldBe` True diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 0cc697b012..39e50dc16b 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2003,7 +2003,7 @@ testGroupDelayedModerationFullDelete ps = do testDeleteMemberWithMessages :: HasCallStack => TestParams -> IO () testDeleteMemberWithMessages = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do + \alice bob cath -> withXFTPServer alice $ do createGroup3' "team" alice (bob, GRMember) (cath, GRMember) threadDelay 750000 alice ##> "/set delete #team on" @@ -2022,10 +2022,13 @@ testDeleteMemberWithMessages = ] threadDelay 750000 - alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") - bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") - cath #$> ("/_files_folder ./tests/tmp/cath_app_files", id, "ok") - copyFile "./tests/fixtures/test.jpg" "./tests/tmp/bob_app_files/test.jpg" + let aliceFiles = tmpFile alice "alice_app_files" + bobFiles = tmpFile bob "bob_app_files" + cathFiles = tmpFile cath "cath_app_files" + alice #$> ("/_files_folder " <> aliceFiles, id, "ok") + bob #$> ("/_files_folder " <> bobFiles, id, "ok") + cath #$> ("/_files_folder " <> cathFiles, id, "ok") + copyFile "./tests/fixtures/test.jpg" (bobFiles "test.jpg") bob ##> "/_send #1 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"file from bob\"}}]" bob <# "#team file from bob" @@ -2057,9 +2060,9 @@ testDeleteMemberWithMessages = cath <## "completed receiving file 1 (test.jpg) from bob" src <- B.readFile "./tests/fixtures/test.jpg" - B.readFile "./tests/tmp/alice_app_files/test.jpg" `shouldReturn` src - B.readFile "./tests/tmp/bob_app_files/test.jpg" `shouldReturn` src - B.readFile "./tests/tmp/cath_app_files/test.jpg" `shouldReturn` src + B.readFile (aliceFiles "test.jpg") `shouldReturn` src + B.readFile (bobFiles "test.jpg") `shouldReturn` src + B.readFile (cathFiles "test.jpg") `shouldReturn` src threadDelay 1000000 alice ##> "/rm #team bob messages=on" @@ -2068,9 +2071,9 @@ testDeleteMemberWithMessages = bob <## "use /d #team to delete the group" cath <## "#team: alice removed bob from the group with all messages (signed)" - doesFileExist "./tests/tmp/alice_app_files/test.jpg" `shouldReturn` False - doesFileExist "./tests/tmp/bob_app_files/test.jpg" `shouldReturn` False - doesFileExist "./tests/tmp/cath_app_files/test.jpg" `shouldReturn` False + doesFileExist (aliceFiles "test.jpg") `shouldReturn` False + doesFileExist (bobFiles "test.jpg") `shouldReturn` False + doesFileExist (cathFiles "test.jpg") `shouldReturn` False -- Under fullDelete, bob's items are physically deleted on all sides; only the system event remains. alice #$> ("/_get chat #1 count=1", chat, [(1, "removed bob (signed)")]) @@ -2253,11 +2256,15 @@ testSharedMessageBody ps' = withTestChatOpts ps opts' "cath" $ \cath -> do concurrentlyN_ [ alice <## "subscribed 4 connections on server localhost", - bob <## "subscribed 3 connections on server localhost", - cath <## "subscribed 3 connections on server localhost" + bob + <### [ "subscribed 3 connections on server localhost", + WithTime "#team alice> hello" + ], + cath + <### [ "subscribed 3 connections on server localhost", + WithTime "#team alice> hello" + ] ] - bob <# "#team alice> hello" - cath <# "#team alice> hello" threadDelay 500000 checkMsgBodyCount alice 0 @@ -2266,8 +2273,8 @@ testSharedMessageBody ps' = ps = ps' {printOutput = True} :: TestParams tmp = tmpPath ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } opts' = @@ -2320,8 +2327,8 @@ testSharedBatchBody ps = where tmp = tmpPath ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } opts' = @@ -2525,8 +2532,8 @@ testSharedBatchBodyMixed ps = oldCfg = testCfg {chatVRange = mkVersionRange (VersionChat 9) (VersionChat 17)} tmp = tmpPath ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } opts' = @@ -2931,10 +2938,10 @@ testPlanGroupLinkOwn ps = alice <## "group link: own link for group #team" alice ##> ("/c " <> gLink) - alice <## "connection request sent!" - alice <## "alice_1 (Alice): accepting request to join group #team..." alice - <### [ "#team: alice_1 joined the group", + <### [ "connection request sent!", + "alice_1 (Alice): accepting request to join group #team...", + "#team: alice_1 joined the group", "#team_1: joining the group...", "#team_1: you joined the group" ] @@ -3319,7 +3326,7 @@ testGroupLinkMemberRole = bob <## "#team: you joined the group" ] - threadDelay 250000 + getProfileShortDescrByName bob "alice" `shouldEventuallyReturn` Just "Alice" alice ##> "/ms team" alice @@ -3489,10 +3496,7 @@ testGroupLinkHostProfileReceived = bob <## "#team: you joined the group" ] - threadDelay 250000 - - aliceImage <- getProfilePictureByName bob "alice" - aliceImage `shouldBe` Just profileImage + getProfilePictureByName bob "alice" `shouldEventuallyReturn` Just profileImage testGroupLinkExistingContactMerged :: HasCallStack => TestParams -> IO () testGroupLinkExistingContactMerged = @@ -3553,12 +3557,8 @@ testGLinkRejectBlockedName = bob <## "#team: joining the group..." bob <## "#team: join rejected, reason: GRRBlockedName" - threadDelay 100000 - + withCCTransaction alice (\db -> DB.query_ db "SELECT count(1) FROM group_members" :: IO [[Int]]) `shouldEventuallyReturn` [[1]] alice `hasContactProfiles` ["alice"] - memCount <- withCCTransaction alice $ \db -> - DB.query_ db "SELECT count(1) FROM group_members" :: IO [[Int]] - memCount `shouldBe` [[1]] -- rejected member can't send messages to group bob ##> "#team hello" @@ -4116,10 +4116,13 @@ testPlanGroupLinkConnecting ps = do <### [ "subscribed 1 connections on server localhost", "bob (Bob): accepting request to join group #team..." ] + withCCAgentTransaction alice (\db -> DB.query_ db "SELECT count(1) FROM commands" :: IO [[Int]]) `shouldEventuallyReturn` [[0]] withTestChat ps "bob" $ \bob -> do threadDelay 500000 - bob <## "subscribed 1 connections on server localhost" - bob <## "#team: joining the group..." + bob + <### [ "subscribed 1 connections on server localhost", + "#team: joining the group..." + ] bob <## "#team: you joined the group" bob ##> ("/_connect plan 1 " <> gLink) @@ -4214,8 +4217,8 @@ testGroupSyncRatchet ps = bob <## "#team alice: connection synchronized" threadDelay 100000 - bob #$> ("/_get chat #1 count=3", chat, [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "connection synchronized")]) - alice #$> ("/_get chat #1 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) + (bob ##> "/_get chat #1 count=3" >> chat <$> getTermLine bob) `shouldEventuallyReturn` [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "connection synchronized")] + (alice ##> "/_get chat #1 count=2" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(0, "connection synchronization agreed"), (0, "connection synchronized")] alice #> "#team hello again" bob <# "#team alice> hello again" @@ -4254,8 +4257,8 @@ testGroupSyncRatchetCodeReset ps = bob <## "#team alice: connection synchronized" threadDelay 100000 - bob #$> ("/_get chat #1 count=4", chat, [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "security code changed"), (0, "connection synchronized")]) - alice #$> ("/_get chat #1 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) + (bob ##> "/_get chat #1 count=4" >> chat <$> getTermLine bob) `shouldEventuallyReturn` [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "security code changed"), (0, "connection synchronized")] + (alice ##> "/_get chat #1 count=2" >> chat <$> getTermLine alice) `shouldEventuallyReturn` [(0, "connection synchronization agreed"), (0, "connection synchronized")] -- connection not verified bob ##> "/i #team alice" @@ -4748,13 +4751,13 @@ testMergeGroupLinkHostMultipleContacts = concurrentlyN_ [ bob <### [ EndsWith "joined the group", - "contact and member are merged: cath, #party cath_2", + Predicate (`elem` ["contact and member are merged: cath, #party cath_2", "contact and member are merged: cath_1, #party cath_2"]), StartsWith "use @cath" ], cath <### [ "#party: joining the group...", "#party: you joined the group", - "contact and member are merged: bob, #party bob_2", + Predicate (`elem` ["contact and member are merged: bob, #party bob_2", "contact and member are merged: bob_1, #party bob_2"]), StartsWith "use @bob" ] ] @@ -4809,8 +4812,10 @@ testMemberContactMessage = alice <##> bob alice `send` "@bob hi" - alice <## "bob: quantum resistant end-to-end encryption enabled" - alice <# "@bob hi" + alice + <### [ "bob: quantum resistant end-to-end encryption enabled", + WithTime "@bob hi" + ] bob <## "alice: quantum resistant end-to-end encryption enabled" bob <# "alice> hi" @@ -5539,8 +5544,16 @@ setupGroupForwardingVectors :: TestCC -> TestCC -> TestCC -> IO () setupGroupForwardingVectors host invitee1 invitee2 = do invitee1Name <- userName invitee1 invitee2Name <- userName invitee2 + groupForwardingRelations host invitee1Name invitee2Name `shouldEventuallyReturn` (MRConnected, MRConnected) updateGroupForwardingVectors host invitee1Name invitee2Name MRIntroduced +groupForwardingRelations :: TestCC -> String -> String -> IO (MemberRelation, MemberRelation) +groupForwardingRelations host invitee1Name invitee2Name = + withCCTransaction host $ \db -> do + [(invitee1Index, invitee1Vec)] <- DB.query db "SELECT index_in_group, member_relations_vector FROM group_members WHERE local_display_name = ?" (Only invitee1Name) + [(invitee2Index, invitee2Vec)] <- DB.query db "SELECT index_in_group, member_relations_vector FROM group_members WHERE local_display_name = ?" (Only invitee2Name) + pure (getRelation invitee2Index (fromMaybe B.empty invitee1Vec), getRelation invitee1Index (fromMaybe B.empty invitee2Vec)) + updateGroupForwardingVectors :: TestCC -> String -> String -> MemberRelation -> IO () updateGroupForwardingVectors host invitee1Name invitee2Name relation = do void $ withCCTransaction host $ \db -> do @@ -5675,7 +5688,7 @@ testGroupMsgForwardDeletion = testGroupMsgForwardFile :: HasCallStack => TestParams -> IO () testGroupMsgForwardFile = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do + \alice bob cath -> withXFTPServer alice $ do createGroup3 "team" alice bob cath setupGroupForwarding alice bob cath @@ -5690,12 +5703,14 @@ testGroupMsgForwardFile = cath <# "#team bob> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" cath <## "use /fr 1 [/ | ] to receive it [>>]" ] - cath ##> "/fr 1 ./tests/tmp" - cath <## "saving file 1 from bob to ./tests/tmp/test.jpg" - cath <## "started receiving file 1 (test.jpg) from bob" + cath ##> ("/fr 1 " <> tmpDir cath) + cath + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile cath "test.jpg", + "started receiving file 1 (test.jpg) from bob" + ] cath <## "completed receiving file 1 (test.jpg) from bob" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" + dest <- B.readFile (tmpFile cath "test.jpg") dest `shouldBe` src testGroupMsgForwardChangeRole :: HasCallStack => TestParams -> IO () @@ -6047,7 +6062,7 @@ testGroupHistoryPreferenceOff = testGroupHistoryHostFile :: HasCallStack => TestParams -> IO () testGroupHistoryHostFile = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do + \alice bob cath -> withXFTPServer alice $ do createGroup2 "team" alice bob alice #> "/f #team ./tests/fixtures/test.jpg" @@ -6073,20 +6088,20 @@ testGroupHistoryHostFile = bob <## "#team: new member cath is connected" ] - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) cath - <### [ "saving file 1 from alice to ./tests/tmp/test.jpg", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile cath "test.jpg", "started receiving file 1 (test.jpg) from alice" ] cath <## "completed receiving file 1 (test.jpg) from alice" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" + dest <- B.readFile (tmpFile cath "test.jpg") dest `shouldBe` src testGroupHistoryMemberFile :: HasCallStack => TestParams -> IO () testGroupHistoryMemberFile = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do + \alice bob cath -> withXFTPServer alice $ do createGroup2 "team" alice bob bob #> "/f #team ./tests/fixtures/test.jpg" @@ -6112,27 +6127,28 @@ testGroupHistoryMemberFile = bob <## "#team: new member cath is connected" ] - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) cath - <### [ "saving file 1 from bob to ./tests/tmp/test.jpg", + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile cath "test.jpg", "started receiving file 1 (test.jpg) from bob" ] cath <## "completed receiving file 1 (test.jpg) from bob" src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" + dest <- B.readFile (tmpFile cath "test.jpg") dest `shouldBe` src testGroupHistoryLargeFile :: HasCallStack => TestParams -> IO () testGroupHistoryLargeFile = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile"] + \alice bob cath -> withXFTPServer alice $ do + let testfile = tmpFile bob "testfile" + xftpCLI ["rand", testfile, "17mb"] `shouldReturn` ["File created: " <> testfile] createGroup2 "team" alice bob - bob ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile\", \"msgContent\": {\"text\":\"hello\",\"type\":\"file\"}}]" + bob ##> ("/_send #1 json [{\"filePath\": \"" <> testfile <> "\", \"msgContent\": {\"text\":\"hello\",\"type\":\"file\"}}]") bob <# "#team hello" - bob <# "/f #team ./tests/tmp/testfile" + bob <# ("/f #team " <> testfile) bob <## "use /fc 1 to cancel sending" bob <## "completed uploading file 1 (testfile) for #team" @@ -6141,14 +6157,14 @@ testGroupHistoryLargeFile = alice <## "use /fr 1 [/ | ] to receive it" -- admin receiving file does not prevent the new member from receiving it later - alice ##> "/fr 1 ./tests/tmp" + alice ##> ("/fr 1 " <> tmpDir alice) alice - <### [ "saving file 1 from bob to ./tests/tmp/testfile_1", + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile alice "testfile_1", "started receiving file 1 (testfile) from bob" ] alice <## "completed receiving file 1 (testfile) from bob" - src <- B.readFile "./tests/tmp/testfile" - destAlice <- B.readFile "./tests/tmp/testfile_1" + src <- B.readFile testfile + destAlice <- B.readFile (tmpFile alice "testfile_1") destAlice `shouldBe` src connectUsers alice cath @@ -6168,14 +6184,14 @@ testGroupHistoryLargeFile = bob <## "#team: new member cath is connected" ] - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) cath - <### [ "saving file 1 from bob to ./tests/tmp/testfile_2", + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile cath "testfile_2", "started receiving file 1 (testfile) from bob" ] cath <## "completed receiving file 1 (testfile) from bob" - destCath <- B.readFile "./tests/tmp/testfile_2" + destCath <- B.readFile (tmpFile cath "testfile_2") destCath `shouldBe` src where cfg = testCfg {xftpDescrPartSize = 200} @@ -6183,17 +6199,19 @@ testGroupHistoryLargeFile = testGroupHistoryMultipleFiles :: HasCallStack => TestParams -> IO () testGroupHistoryMultipleFiles = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] - xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] + \alice bob cath -> withXFTPServer alice $ do + let testfileBob = tmpFile bob "testfile_bob" + testfileAlice = tmpFile alice "testfile_alice" + xftpCLI ["rand", testfileBob, "2mb"] `shouldReturn` ["File created: " <> testfileBob] + xftpCLI ["rand", testfileAlice, "1mb"] `shouldReturn` ["File created: " <> testfileAlice] createGroup2 "team" alice bob threadDelay 1000000 - bob ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile_bob\", \"msgContent\": {\"text\":\"hi alice\",\"type\":\"file\"}}]" + bob ##> ("/_send #1 json [{\"filePath\": \"" <> testfileBob <> "\", \"msgContent\": {\"text\":\"hi alice\",\"type\":\"file\"}}]") bob <# "#team hi alice" - bob <# "/f #team ./tests/tmp/testfile_bob" + bob <# ("/f #team " <> testfileBob) bob <## "use /fc 1 to cancel sending" bob <## "completed uploading file 1 (testfile_bob) for #team" @@ -6203,9 +6221,9 @@ testGroupHistoryMultipleFiles = threadDelay 1000000 - alice ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile_alice\", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}]" + alice ##> ("/_send #1 json [{\"filePath\": \"" <> testfileAlice <> "\", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}]") alice <# "#team hey bob" - alice <# "/f #team ./tests/tmp/testfile_alice" + alice <# ("/f #team " <> testfileAlice) alice <## "use /fc 2 to cancel sending" alice <## "completed uploading file 2 (testfile_alice) for #team" @@ -6233,45 +6251,47 @@ testGroupHistoryMultipleFiles = bob <## "#team: new member cath is connected" ] - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir cath) cath - <### [ "saving file 1 from bob to ./tests/tmp/testfile_bob_1", + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile cath "testfile_bob_1", "started receiving file 1 (testfile_bob) from bob" ] cath <## "completed receiving file 1 (testfile_bob) from bob" - srcBob <- B.readFile "./tests/tmp/testfile_bob" - destBob <- B.readFile "./tests/tmp/testfile_bob_1" + srcBob <- B.readFile testfileBob + destBob <- B.readFile (tmpFile cath "testfile_bob_1") destBob `shouldBe` srcBob - cath ##> "/fr 2 ./tests/tmp" + cath ##> ("/fr 2 " <> tmpDir cath) cath - <### [ "saving file 2 from alice to ./tests/tmp/testfile_alice_1", + <### [ ConsoleString $ "saving file 2 from alice to " <> tmpFile cath "testfile_alice_1", "started receiving file 2 (testfile_alice) from alice" ] cath <## "completed receiving file 2 (testfile_alice) from alice" - srcAlice <- B.readFile "./tests/tmp/testfile_alice" - destAlice <- B.readFile "./tests/tmp/testfile_alice_1" + srcAlice <- B.readFile testfileAlice + destAlice <- B.readFile (tmpFile cath "testfile_alice_1") destAlice `shouldBe` srcAlice cath ##> "/_get chat #1 count=100" r <- chatF <$> getTermLine cath r - `shouldContain` [ ((0, "hi alice"), Just "./tests/tmp/testfile_bob_1"), - ((0, "hey bob"), Just "./tests/tmp/testfile_alice_1") + `shouldContain` [ ((0, "hi alice"), Just $ tmpFile cath "testfile_bob_1"), + ((0, "hey bob"), Just $ tmpFile cath "testfile_alice_1") ] testGroupHistoryFileCancel :: HasCallStack => TestParams -> IO () testGroupHistoryFileCancel = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] - xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] + \alice bob cath -> withXFTPServer alice $ do + let testfileBob = tmpFile bob "testfile_bob" + testfileAlice = tmpFile alice "testfile_alice" + xftpCLI ["rand", testfileBob, "2mb"] `shouldReturn` ["File created: " <> testfileBob] + xftpCLI ["rand", testfileAlice, "1mb"] `shouldReturn` ["File created: " <> testfileAlice] createGroup2 "team" alice bob - bob ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile_bob\", \"msgContent\": {\"text\":\"hi alice\",\"type\":\"file\"}}]" + bob ##> ("/_send #1 json [{\"filePath\": \"" <> testfileBob <> "\", \"msgContent\": {\"text\":\"hi alice\",\"type\":\"file\"}}]") bob <# "#team hi alice" - bob <# "/f #team ./tests/tmp/testfile_bob" + bob <# ("/f #team " <> testfileBob) bob <## "use /fc 1 to cancel sending" bob <## "completed uploading file 1 (testfile_bob) for #team" @@ -6285,9 +6305,9 @@ testGroupHistoryFileCancel = threadDelay 1000000 - alice ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile_alice\", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}]" + alice ##> ("/_send #1 json [{\"filePath\": \"" <> testfileAlice <> "\", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}]") alice <# "#team hey bob" - alice <# "/f #team ./tests/tmp/testfile_alice" + alice <# ("/f #team " <> testfileAlice) alice <## "use /fc 2 to cancel sending" alice <## "completed uploading file 2 (testfile_alice) for #team" @@ -6318,9 +6338,11 @@ testGroupHistoryFileCancel = testGroupHistoryFileCancelNoText :: HasCallStack => TestParams -> IO () testGroupHistoryFileCancelNoText = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile_bob", "2mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_bob"] - xftpCLI ["rand", "./tests/tmp/testfile_alice", "1mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile_alice"] + \alice bob cath -> withXFTPServer alice $ do + let testfileBob = tmpFile bob "testfile_bob" + testfileAlice = tmpFile alice "testfile_alice" + xftpCLI ["rand", testfileBob, "2mb"] `shouldReturn` ["File created: " <> testfileBob] + xftpCLI ["rand", testfileAlice, "1mb"] `shouldReturn` ["File created: " <> testfileAlice] createGroup2 "team" alice bob @@ -6329,7 +6351,7 @@ testGroupHistoryFileCancelNoText = -- bob file - bob #> "/f #team ./tests/tmp/testfile_bob" + bob #> ("/f #team " <> testfileBob) bob <## "use /fc 1 to cancel sending" bob <## "completed uploading file 1 (testfile_bob) for #team" @@ -6342,7 +6364,7 @@ testGroupHistoryFileCancelNoText = -- alice file - alice #> "/f #team ./tests/tmp/testfile_alice" + alice #> ("/f #team " <> testfileAlice) alice <## "use /fc 2 to cancel sending" alice <## "completed uploading file 2 (testfile_alice) for #team" @@ -6533,12 +6555,12 @@ testGroupHistoryDisappearingMessage = threadDelay 1000000 -- 3 seconds so that messages 2 and 3 are not deleted for alice before sending history to cath - alice ##> "/set disappear #team on 4" + alice ##> "/set disappear #team on 15" alice <## "updated group preferences:" - alice <## "Disappearing messages: on (4 sec)" + alice <## "Disappearing messages: on (15 sec)" bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" - bob <## "Disappearing messages: on (4 sec)" + bob <## "Disappearing messages: on (15 sec)" bob #> "#team 2" alice <# "#team bob> 2" @@ -6582,6 +6604,7 @@ testGroupHistoryDisappearingMessage = r1 <- chat <$> getTermLine cath r1 `shouldContain` [(0, "1"), (0, "2"), (0, "3"), (0, "4")] + threadDelay 11000000 concurrentlyN_ [ alice <### [ "timed message deleted: 2", @@ -7665,6 +7688,7 @@ testGroupMemberInactive ps = do bob <# "#team alice> hi" bob #> "#team hey" alice <# "#team bob> hey" + threadDelay 1000000 -- bob is offline alice #> "#team 1" @@ -7682,8 +7706,10 @@ testGroupMemberInactive ps = do threadDelay 1500000 withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do - bob <## "subscribed 2 connections on server localhost" - bob <# "#team alice> 1" + bob + <### [ "subscribed 2 connections on server localhost", + WithTime "#team alice> 1" + ] bob <# "#team alice> 2" bob <#. "#team alice> skipped message ID" alice <## "[#team bob] inactive connection is marked as active" @@ -7702,8 +7728,8 @@ testGroupMemberInactive ps = do alice <# "#team bob> hey" where serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], msgQueueQuota = 2 } fastRetryInterval = defaultReconnectInterval {initialInterval = 50_000} -- same as in agent tests @@ -7786,9 +7812,10 @@ testGroupMemberReports = dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content")]) alice ##> "\\\\ #jokes cath inappropriate joke" concurrentlyN_ - [ do - alice <## "#jokes: 1 messages deleted by user" - alice <## "message marked deleted by you", + [ alice + <### [ "#jokes: 1 messages deleted by user", + "message marked deleted by you" + ], do bob <# "#jokes cath> [marked deleted by alice] inappropriate joke" bob <## "#jokes: 1 messages deleted by member alice", @@ -8356,7 +8383,7 @@ testScopedSupportDontForwardBetweenScopes = testScopedSupportForwardFile :: HasCallStack => TestParams -> IO () testScopedSupportForwardFile = - testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> withXFTPServer $ do + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> withXFTPServer alice $ do createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator) setupGroupForwarding alice bob dan @@ -8379,9 +8406,9 @@ testScopedSupportForwardFile = bob <## "completed uploading file 1 (test.jpg) for #team" - dan ##> "/fr 1 ./tests/tmp" + dan ##> ("/fr 1 " <> tmpDir dan) dan - <### [ "saving file 1 from bob to ./tests/tmp/test.jpg", + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile dan "test.jpg", "started receiving file 1 (test.jpg) from bob" ] dan <## "completed receiving file 1 (test.jpg) from bob" @@ -9202,7 +9229,7 @@ testChannels1RelayDeliver ps = alice <## "group ID: 1" alice <## "subscribers: 4" -- subscriber refreshes count via short link - threadDelay 100000 -- wait for async short link data update + waitQueuedLinkUpdates alice cath ##> "/_get group link data #1" cath <## "group ID: 1" cath <## "subscribers: 4" @@ -9424,20 +9451,26 @@ memberJoinChannelIncognito gName relays owners shortLink fullLink member = do -- | Assert that sender's member_relations_vector has 'MRIntroduced' at -- the recipient's index, looked up by display name on the same DB. memberIntroducedTo :: HasCallStack => TestCC -> T.Text -> T.Text -> IO () -memberIntroducedTo cc senderName recipientName = do - rows <- withCCTransaction cc $ \db -> - DB.query - db - [sql| - SELECT s.member_relations_vector, r.index_in_group - FROM group_members s, group_members r - WHERE s.local_display_name = ? AND r.local_display_name = ? - |] - (senderName, recipientName) :: - IO [(Maybe ByteString, Int64)] - case rows of - [(mv, idx)] -> getRelation idx (fromMaybe B.empty mv) `shouldBe` MRIntroduced - _ -> expectationFailure $ "memberIntroducedTo: expected exactly one row for " <> show (senderName, recipientName) <> ", got " <> show (length rows) +memberIntroducedTo cc senderName recipientName = go (50 :: Int) + where + go n = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + [sql| + SELECT s.member_relations_vector, r.index_in_group + FROM group_members s, group_members r + WHERE s.local_display_name = ? AND r.local_display_name = ? + |] + (senderName, recipientName) :: + IO [(Maybe ByteString, Int64)] + case rows of + [(mv, idx)] + | relation == MRIntroduced || n == 0 -> relation `shouldBe` MRIntroduced + | otherwise -> threadDelay 100000 >> go (n - 1) + where + relation = getRelation idx (fromMaybe B.empty mv) + _ -> expectationFailure $ "memberIntroducedTo: expected exactly one row for " <> show (senderName, recipientName) <> ", got " <> show (length rows) testChannels1RelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () testChannels1RelayDeliverLoop deliveryBucketSize ps = @@ -9488,9 +9521,9 @@ testChannelsSenderDeduplicateOwn ps = do dan #> "#team 6" withTestChatCfgOpts ps cfg relayTestOpts "bob" $ \bob -> do - bob <## "subscribed 6 connections on server localhost" bob - <### [ WithTime "#team> 1", + <### [ "subscribed 6 connections on server localhost", + WithTime "#team> 1", WithTime "#team> 2", WithTime "#team> 3", WithTime "#team cath> 4", @@ -9923,6 +9956,7 @@ testChannelLinkAfterProfileUpdate ps = withNewTestChat ps "dan" danProfile $ \dan -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + waitQueuedLinkUpdates alice -- owner updates channel profile alice ##> "/gp team my_team My team description" @@ -9957,6 +9991,7 @@ testChannelLinkAfterWelcomeUpdate ps = withNewTestChat ps "dan" danProfile $ \dan -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + waitQueuedLinkUpdates alice -- owner updates channel welcome message alice ##> "/set welcome #team welcome to team" @@ -9995,6 +10030,7 @@ testChannelOwnerKeyAfterLinkUpdate ps = withNewTestChat ps "dan" danProfile $ \dan -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + waitQueuedLinkUpdates alice threadDelay 100000 @@ -10201,10 +10237,31 @@ testChannelBlockMemberSigned ps = r2 `shouldEndWith` "(signed)" checkMemberRow :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () -checkMemberRow cc name expectedRole = do - roles <- withCCTransaction cc $ \db -> - DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text] - map (\(Only r) -> r) roles `shouldBe` maybeToList expectedRole +checkMemberRow cc name expectedRole = memberRoles cc name `shouldReturn` maybeToList expectedRole + +waitMemberRow :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () +waitMemberRow cc name expectedRole = go (50 :: Int) + where + expected = maybeToList expectedRole + go n = do + roles <- memberRoles cc name + if roles == expected || n == 0 + then roles `shouldBe` expected + else threadDelay 100000 >> go (n - 1) + +memberRoles :: TestCC -> T.Text -> IO [T.Text] +memberRoles cc name = + map (\(Only r) -> r) <$> withCCTransaction cc (\db -> DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name)) + +waitQueuedLinkUpdates :: HasCallStack => TestCC -> IO () +waitQueuedLinkUpdates cc = go (100 :: Int) + where + go n = do + [[queued]] <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT COUNT(1) FROM commands WHERE command_function = 'set_short_link'" :: IO [[Int]] + if queued == 0 || n == 0 + then queued `shouldBe` 0 + else threadDelay 100000 >> go (n - 1) -- The wire member id for a named member (look it up on a client that knows the name, e.g. the owner), used to -- find a member by id on a subscriber that only knows it by member-id hash (e.g. after roster recovery). @@ -10285,7 +10342,7 @@ testChannelModeratorActionViaRoster ps = memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank -- the late joiner learns the roster from the served snapshot (verified below); under the -- no-broadcast model the apply finds no role change to surface, so no item here - threadDelay 1000000 -- the served roster arrives async + waitMemberRow frank "cath" (Just "moderator") checkMemberRole frank "cath" "moderator" where checkMemberRole :: HasCallStack => TestCC -> T.Text -> T.Text -> IO () @@ -10322,7 +10379,7 @@ testChannelSubscriberRosterCatchUp ps = -- the next privileged change (frank -> v2) reaches cath at a jumped version, triggering catch-up: -- cath requests the roster from the forwarding relay, which re-serves the current snapshot promoteChannelMember "team" alice bob frank [cath, dan, eve] - threadDelay 2000000 -- wait for the gap request + relay re-serve to recover dan + withCCTransaction cath (\db -> DB.query db "SELECT member_role, member_pub_key FROM group_members WHERE member_id = ?" (Only danId) :: IO [(T.Text, Maybe ByteString)]) `shouldEventuallyReturn` [("member", danKey)] -- cath recovered dan from the re-served roster: same member id, role, and owner-pinned key (recRole, recKey) <- getMemberRoleKey cath danId recRole `shouldBe` "member" @@ -10382,7 +10439,7 @@ testChannel2RelaysSubscriberRosterCatchUp ps = dan <### [EndsWith "from member to moderator (signed)"], frank <### [EndsWith "from member to moderator (signed)"] ] - threadDelay 2000000 -- wait for the gap request + relay re-serve to recover dan + withCCTransaction frank (\db -> DB.query db "SELECT member_role, member_pub_key FROM group_members WHERE member_id = ?" (Only danId) :: IO [(T.Text, Maybe ByteString)]) `shouldEventuallyReturn` [("member", danKey)] (recRole, recKey) <- getMemberRoleKey frank danId recRole `shouldBe` "member" recKey `shouldBe` danKey @@ -10447,8 +10504,7 @@ testChannelRoleTransitionsUpdateRoster ps = -- no separate role-change item under the no-broadcast model) threadDelay 100000 memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan - threadDelay 1000000 -- the served roster arrives async; wait before reading the applied state - checkMemberRow dan "cath" (Just "moderator") + waitMemberRow dan "cath" (Just "moderator") -- moderator -> admin: dan now knows cath, role event lands cleanly threadDelay 100000 alice ##> "/mr #team cath admin" @@ -10461,8 +10517,7 @@ testChannelRoleTransitionsUpdateRoster ps = -- eve joins; cached roster has cath as admin (learned from the served snapshot) threadDelay 100000 memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink eve - threadDelay 1000000 -- the served roster arrives async; wait before reading the applied state - checkMemberRow eve "cath" (Just "admin") + waitMemberRow eve "cath" (Just "admin") -- admin -> observer (crossing out of roster, since member is now in-roster): roster drops cath threadDelay 100000 alice ##> "/mr #team cath observer" @@ -10538,7 +10593,7 @@ testChannelRelayCannotForgePrivilegedMember ps = withNewTestChat ps "cath" cathProfile $ \cath -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath - threadDelay 1000000 + waitMemberRow cath "alice" (Just "owner") -- the forged attribution only resolves to a privileged author if the victim already holds the -- owner at GROwner (established via the group link on join) - this documents and guards that premise checkMemberRow cath "alice" (Just "owner") @@ -10613,7 +10668,7 @@ testChannelRemoveMemberSigned ps = alice ##> "/_info #1" alice <## "group ID: 1" alice <## "subscribers: 4" - threadDelay 100000 -- wait for async short link data update + waitQueuedLinkUpdates alice cath ##> "/_get group link data #1" cath <## "group ID: 1" cath <## "subscribers: 4" @@ -10645,7 +10700,7 @@ testChannelRemoveMemberSigned ps = alice ##> "/_info #1" alice <## "group ID: 1" alice <## "subscribers: 3" - threadDelay 100000 -- wait for async short link data update + waitQueuedLinkUpdates alice cath ##> "/_get group link data #1" cath <## "group ID: 1" cath <## "subscribers: 3" @@ -10674,7 +10729,7 @@ testChannelRemoveMemberSigned ps = alice ##> "/_info #1" alice <## "group ID: 1" alice <## "subscribers: 2" - threadDelay 100000 -- wait for async short link data update + waitQueuedLinkUpdates alice cath ##> "/_get group link data #1" cath <## "group ID: 1" cath <## "subscribers: 2" @@ -10802,7 +10857,7 @@ testChannelSubscriberLeave ps = alice ##> "/_info #1" alice <## "group ID: 1" alice <## "subscribers: 4" - threadDelay 100000 -- wait for async short link data update + waitQueuedLinkUpdates alice eve ##> "/_get group link data #1" eve <## "group ID: 1" eve <## "subscribers: 4" @@ -10826,7 +10881,7 @@ testChannelSubscriberLeave ps = alice ##> "/_info #1" alice <## "group ID: 1" alice <## "subscribers: 3" - threadDelay 100000 -- wait for async short link data update + waitQueuedLinkUpdates alice eve ##> "/_get group link data #1" eve <## "group ID: 1" eve <## "subscribers: 3" @@ -10856,7 +10911,7 @@ testChannelSubscriberLeave ps = alice ##> "/_info #1" alice <## "group ID: 1" alice <## "subscribers: 2" - threadDelay 100000 -- wait for async short link data update + waitQueuedLinkUpdates alice eve ##> "/_get group link data #1" eve <## "group ID: 1" eve <## "subscribers: 2" @@ -10872,10 +10927,9 @@ testChannelSubscriberLeave ps = checkMemberStatus cath "dan" Nothing where checkMemberStatus :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () - checkMemberStatus cc name expected = do - statuses <- withCCTransaction cc $ \db -> - DB.query db "SELECT member_status FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text] - map (\(Only s) -> s) statuses `shouldBe` maybeToList expected + checkMemberStatus cc name expected = + (map (\(Only s) -> s) <$> withCCTransaction cc (\db -> DB.query db "SELECT member_status FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text])) + `shouldEventuallyReturn` maybeToList expected testChannelRelayLeave :: HasCallStack => TestParams -> IO () testChannelRelayLeave ps = @@ -10941,7 +10995,7 @@ testChannelRelayLeave ps = (eve ("/_connect plan 1 " <> shortLink) frank <## "group link: channel has no active relays, please try to join later" where @@ -11265,8 +11319,7 @@ testChannelRosterMultipartReassembly ps = threadDelay 100000 memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan -- dan reassembles the multi-chunk roster from the served snapshot (arrives async) - threadDelay 1000000 - checkMemberRow dan "cath" (Just "moderator") + waitMemberRow dan "cath" (Just "moderator") where cfg = testCfg {fileChunkSize = 30} @@ -11295,7 +11348,7 @@ testChannelRosterDigestMismatchRejected ps = -- frank joins; bob re-serves the valid header with the corrupted blob, frank rejects it threadDelay 100000 memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank - threadDelay 1000000 + waitMemberRow frank "cath" (Just "observer") -- the rejected roster never elevates cath: the intro caps her to the channel default, so she -- stays observer (not moderator), and the version must not advance to the corrupted roster's version 1 checkMemberRow frank "cath" (Just "observer") @@ -11590,7 +11643,7 @@ testChannelRemoveLeftRelay ps = alice <## "#team: you removed cath from the group (signed)" -- dan syncs with link - should clean up cath's stale record - threadDelay 100000 + waitQueuedLinkUpdates alice dan ##> "/_get group link data #1" dan <## "group ID: 1" void $ getTermLine dan -- subscribers: N @@ -11690,7 +11743,7 @@ testRelayRejectAfterLeave ps = -- bob's transient row was created with relay_own_status='rejected'; -- after INFO arrives the cleanup arm deletes it. Original row 1 remains rejected. - threadDelay 1000000 + listRelayOwnStatuses bob `shouldEventuallyReturn` [(1, "rejected")] checkRelayGroupCount bob 1 finalStatuses <- listRelayOwnStatuses bob finalStatuses `shouldBe` [(1, "rejected")] @@ -11841,7 +11894,7 @@ testRelayRejectRaceConcurrentInvitations ps = alice <## "#team: group relays:" alice .<##. (" - relay id", ": invited") alice <## "#team: relay rejected, reason: RRRRejoinRejected" - threadDelay 1000000 + listRelayOwnStatuses bob `shouldEventuallyReturn` [(1, "rejected")] checkRelayGroupCount bob 1 -- subscriber doesn't receive between rejections (no active relay) @@ -11861,7 +11914,7 @@ testRelayRejectRaceConcurrentInvitations ps = alice #> "#team after second rejection" (cath withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> - withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve -- the roster arrives as a file before this one; Postgres assigns it a new id and does not -- reuse it on delete (SQLite does), so the received message file is id 2 here, 1 on SQLite. @@ -12047,7 +12100,7 @@ testChannelMessageFile ps = ] where receiveFile cc name fileId src = do - let path = "./tests/tmp/test_" <> name <> ".jpg" + let path = tmpFile cc $ "test_" <> name <> ".jpg" cc ##> ("/fr " <> show fileId <> " " <> path) cc <### [ ConsoleString ("saving file " <> show fileId <> " from #team to " <> path), @@ -12062,7 +12115,7 @@ testChannelMessageFileCancel ps = withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> - withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve #if defined(dbPostgres) let rcvFileId = 2 :: Int @@ -12242,7 +12295,7 @@ testChannelOwnerFileTransferAsMember ps = withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> - withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve #if defined(dbPostgres) let rcvFileId = 2 :: Int @@ -12278,7 +12331,7 @@ testChannelOwnerFileTransferAsMember ps = ] where receiveFile cc name fileId src = do - let path = "./tests/tmp/test_" <> name <> ".jpg" + let path = tmpFile cc $ "test_" <> name <> ".jpg" cc ##> ("/fr " <> show fileId <> " " <> path) cc <### [ ConsoleString ("saving file " <> show fileId <> " from alice to " <> path), @@ -12293,7 +12346,7 @@ testGroupHistoryFileBadgeProof ps = do let cfg = testCfg {badgePublicKeys = testBadgeKeys pk, fileSizeLimits = FileSizeLimits {noBadge = 100000, supporter = 300000, legend = 400000}} testChatCfg3 cfg aliceProfile bobProfile cathProfile (test sk) ps where - test sk alice bob cath = withXFTPServer $ do + test sk alice bob cath = withXFTPServer ps $ do createGroup2 "team" alice bob addTestBadge alice =<< issueTestBadge sk futureDate @@ -12322,14 +12375,14 @@ testGroupHistoryFileBadgeProof ps = do bob <## "#team: new member cath is connected" ] - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir ps) cath - <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from alice to " <> tmpFile ps "test.pdf", "started receiving file 1 (test.pdf) from alice" ] cath <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test.pdf" + dest <- B.readFile (tmpFile ps "test.pdf") dest `shouldBe` src testGroupHistoryRcvFileBadgeProof :: HasCallStack => TestParams -> IO () @@ -12338,7 +12391,7 @@ testGroupHistoryRcvFileBadgeProof ps = do let cfg = testCfg {badgePublicKeys = testBadgeKeys pk, fileSizeLimits = FileSizeLimits {noBadge = 100000, supporter = 300000, legend = 400000}} testChatCfg3 cfg aliceProfile bobProfile cathProfile (test sk) ps where - test sk alice bob cath = withXFTPServer $ do + test sk alice bob cath = withXFTPServer ps $ do createGroup2 "team" alice bob addTestBadge bob =<< issueTestBadge sk futureDate @@ -12346,11 +12399,11 @@ testGroupHistoryRcvFileBadgeProof ps = do bob <## "use /fc 1 to cancel sending" alice <# "#team bob> sends file test.pdf (266.0 KiB / 272376 bytes)" alice <## "use /fr 1 [/ | ] to receive it" - alice ##> "/fr 1 ./tests/tmp" + alice ##> ("/fr 1 " <> tmpDir ps) concurrentlyN_ [ bob <## "completed uploading file 1 (test.pdf) for #team", alice - <### [ "saving file 1 from bob to ./tests/tmp/test.pdf", + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile ps "test.pdf", "started receiving file 1 (test.pdf) from bob" ] ] @@ -12375,14 +12428,14 @@ testGroupHistoryRcvFileBadgeProof ps = do bob <## "#team: new member cath is connected" ] - cath ##> "/fr 1 ./tests/tmp" + cath ##> ("/fr 1 " <> tmpDir ps) cath - <### [ "saving file 1 from bob to ./tests/tmp/test_1.pdf", + <### [ ConsoleString $ "saving file 1 from bob to " <> tmpFile ps "test_1.pdf", "started receiving file 1 (test.pdf) from bob" ] cath <## "completed receiving file 1 (test.pdf) from bob" src <- B.readFile "./tests/fixtures/test.pdf" - dest <- B.readFile "./tests/tmp/test_1.pdf" + dest <- B.readFile (tmpFile ps "test_1.pdf") dest `shouldBe` src testChannelFileBadgeProof :: HasCallStack => TestParams -> IO () @@ -12393,7 +12446,7 @@ testChannelFileBadgeProof ps = do withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> withNewTestChatCfg ps cfg "dan" danProfile $ \dan -> - withNewTestChatCfg ps cfg "eve" eveProfile $ \eve -> withXFTPServer $ do + withNewTestChatCfg ps cfg "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve addTestBadge alice =<< issueTestBadge sk futureDate #if defined(dbPostgres) @@ -12420,7 +12473,7 @@ testChannelFileBadgeProof ps = do ] src <- B.readFile "./tests/fixtures/test.jpg" - let path = "./tests/tmp/test_cath.jpg" + let path = tmpFile ps "test_cath.jpg" cath ##> ("/fr " <> show rcvFileId <> " " <> path) cath <### [ ConsoleString ("saving file " <> show rcvFileId <> " from alice to " <> path), @@ -12446,7 +12499,7 @@ testChannelFileBadgeProof ps = do eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" eve <## ("use /fr " <> show (rcvFileId + 1) <> " [/ | ] to receive it [>>]") ] - let path2 = "./tests/tmp/test_cath_2.jpg" + let path2 = tmpFile ps "test_cath_2.jpg" cath ##> ("/fr " <> show (rcvFileId + 1) <> " " <> path2) cath <### [ ConsoleString ("saving file " <> show (rcvFileId + 1) <> " from #team to " <> path2), @@ -12461,7 +12514,7 @@ testChannelOwnerFileCancelAsMember ps = withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> - withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve #if defined(dbPostgres) let rcvFileId = 2 :: Int @@ -12805,8 +12858,9 @@ testChannelSignedFile ps = withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> - withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do - xftpCLI ["rand", "./tests/tmp/testfile", "1mb"] `shouldReturn` ["File created: ./tests/tmp/testfile"] + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do + let testfile = tmpFile ps "testfile" + xftpCLI ["rand", testfile, "1mb"] `shouldReturn` ["File created: " <> testfile] createChannel1Relay "team" alice bob cath dan eve promoteChannelMember "team" alice bob cath [dan, eve] -- roster serves arrive as files that Postgres deletes without reusing the id (SQLite reuses @@ -12836,9 +12890,9 @@ testChannelSignedFile ps = ] -- cath sends a signed file - cath ##> "/_send #1 sign=on json [{\"filePath\": \"./tests/tmp/testfile\", \"msgContent\": {\"text\":\"signed file\",\"type\":\"file\"}}]" + cath ##> ("/_send #1 sign=on json [{\"filePath\": \"" <> testfile <> "\", \"msgContent\": {\"text\":\"signed file\",\"type\":\"file\"}}]") cath <# "#team signed file (signed)" - cath <# "/f #team ./tests/tmp/testfile" + cath <# ("/f #team " <> testfile) cath <## ("use /fc " <> show fileId <> " to cancel sending") cath <## ("completed uploading file " <> show fileId <> " (testfile) for #team") @@ -12859,14 +12913,14 @@ testChannelSignedFile ps = ] -- dan downloads: the signed digest is verified and the file completes - dan ##> ("/fr " <> show fileId <> " ./tests/tmp") + dan ##> ("/fr " <> show fileId <> " " <> tmpDir ps) dan - <### [ ConsoleString ("saving file " <> show fileId <> " from cath to ./tests/tmp/testfile_1"), + <### [ ConsoleString ("saving file " <> show fileId <> " from cath to " <> tmpFile ps "testfile_1"), ConsoleString ("started receiving file " <> show fileId <> " (testfile) from cath") ] dan <## ("completed receiving file " <> show fileId <> " (testfile) from cath") - src <- B.readFile "./tests/tmp/testfile" - destDan <- B.readFile "./tests/tmp/testfile_1" + src <- B.readFile testfile + destDan <- B.readFile (tmpFile ps "testfile_1") destDan `shouldBe` src -- the signed digest was carried to dan and stored, so verification ran (not skipped) and passed digestCount <- withCCTransaction dan $ \db -> @@ -12911,11 +12965,10 @@ testChannelMemberUpdateEnforcement ps = either (fail . show) (const $ pure ()) sent -- dan rejects the unsigned mutation of the held-signed item (RGEMsgBadSignature, stored not shown live), -- and the original signed content is not overwritten - threadDelay 2000000 + -- the rejection is recorded as a bad-signature item + (dan ##> "/_get chat #1 count=100 search=bad signature" >> chat <$> getTermLine dan) `shouldEventuallyReturn` [(0, "message rejected: bad signature")] -- (critical) the forged content did NOT overwrite the original signed item dan #$> ("/_get chat #1 count=100 search=secret", chat, [(0, "secret (signed)")]) - -- the rejection is recorded as a bad-signature item - dan #$> ("/_get chat #1 count=100 search=bad signature", chat, [(0, "message rejected: bad signature")]) -- a legitimate signed edit by cath is accepted cathMsgId <- lastItemId cath @@ -13292,9 +13345,8 @@ testChannelMemberDeleteEnforcement ps = sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] either (fail . show) (const $ pure ()) sent -- dan rejects the unsigned delete of the held-signed item; item not deleted, rejection recorded - threadDelay 2000000 + (dan ##> "/_get chat #1 count=100 search=bad signature" >> chat <$> getTermLine dan) `shouldEventuallyReturn` [(0, "message rejected: bad signature")] dan #$> ("/_get chat #1 count=100 search=secret", chat, [(0, "secret (signed)")]) - dan #$> ("/_get chat #1 count=100 search=bad signature", chat, [(0, "message rejected: bad signature")]) -- a legitimate signed self-delete by cath is accepted cathMsgId <- lastItemId cath diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index 6760901c1d..ca1ccfd50d 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -121,7 +121,7 @@ testFiles :: TestParams -> IO () testFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do -- setup createCCNoteFolder alice - let files = "./tests/tmp/app_files" + let files = tmpFile ps "app_files" alice ##> ("/_files_folder " <> files) alice <## "ok" @@ -170,10 +170,10 @@ testFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testOtherFiles :: TestParams -> IO () testOtherFiles = - testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> withXFTPServer alice $ do connectUsers alice bob createCCNoteFolder bob - bob ##> "/_files_folder ./tests/tmp/" + bob ##> ("/_files_folder " <> tmpDir bob) bob <## "ok" alice #> "/f @bob ./tests/fixtures/test.jpg" @@ -197,7 +197,7 @@ testOtherFiles = bob ##> "/tail *" bob ##> "/fs 1" bob <## "receiving file 1 (test.jpg) complete, path: test.jpg" - doesFileExist "./tests/tmp/test.jpg" `shouldReturn` True + doesFileExist (tmpFile bob "test.jpg") `shouldReturn` True where cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} @@ -212,9 +212,10 @@ testCreateMulti ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testCreateMultiFiles :: TestParams -> IO () testCreateMultiFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice - alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") - copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" - copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf" + let files = tmpFile ps "alice_app_files" + alice #$> ("/_files_folder " <> files, id, "ok") + copyFile "./tests/fixtures/test.jpg" (files "test.jpg") + copyFile "./tests/fixtures/test.pdf" (files "test.pdf") let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}" cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}" @@ -227,8 +228,8 @@ testCreateMultiFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <# "* sending file 2" alice <# "* file 2 (test.pdf)" - doesFileExist "./tests/tmp/alice_app_files/test.jpg" `shouldReturn` True - doesFileExist "./tests/tmp/alice_app_files/test.pdf" `shouldReturn` True + doesFileExist (files "test.jpg") `shouldReturn` True + doesFileExist (files "test.pdf") `shouldReturn` True alice ##> "/_get chat *1 count=3" r <- chatF <$> getTermLine alice diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index 8e37c5e2d2..6421459124 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -28,7 +28,7 @@ chatNamesTests = do it "connect by name resolving to business (primary) and channel" testConnectByNameBusinessAndChannel testConnectByName :: HasCallStack => TestParams -> IO () -testConnectByName ps = withSmpServerAndNames $ \reg -> +testConnectByName ps = withSmpServerAndNames ps $ \reg -> testChat2 aliceProfile bobProfile (test reg) ps where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) @@ -63,7 +63,7 @@ testConnectByName ps = withSmpServerAndNames $ \reg -> pure () testConnectByNameNotClaimed :: HasCallStack => TestParams -> IO () -testConnectByNameNotClaimed ps = withSmpServerAndNames $ \reg -> +testConnectByNameNotClaimed ps = withSmpServerAndNames ps $ \reg -> testChat2 aliceProfile bobProfile (test reg) ps where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) @@ -76,7 +76,7 @@ testConnectByNameNotClaimed ps = withSmpServerAndNames $ \reg -> bob <## "SimpleX name alice.simplex is not included in the connection link's profile" testConnectByNameKnownContactNotClaimed :: HasCallStack => TestParams -> IO () -testConnectByNameKnownContactNotClaimed ps = withSmpServerAndNames $ \reg -> +testConnectByNameKnownContactNotClaimed ps = withSmpServerAndNames ps $ \reg -> testChat2 aliceProfile bobProfile (test reg) ps where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) @@ -99,7 +99,7 @@ testConnectByNameKnownContactNotClaimed ps = withSmpServerAndNames $ \reg -> bob <## "SimpleX name alice.simplex is not included in the connection link's profile" testConnectByNameNotFound :: HasCallStack => TestParams -> IO () -testConnectByNameNotFound ps = withSmpServerAndNames $ \_reg -> +testConnectByNameNotFound ps = withSmpServerAndNames ps $ \_reg -> testChat2 aliceProfile bobProfile test ps where test _alice bob = do @@ -108,7 +108,7 @@ testConnectByNameNotFound ps = withSmpServerAndNames $ \_reg -> bob .<## "smpErr = NAME {nameErr = NOT_FOUND}}" testSetNameNotOwnAddress :: HasCallStack => TestParams -> IO () -testSetNameNotOwnAddress ps = withSmpServerAndNames $ \reg -> +testSetNameNotOwnAddress ps = withSmpServerAndNames ps $ \reg -> testChat2 aliceProfile bobProfile (test reg) ps where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) @@ -124,7 +124,7 @@ testSetNameNotOwnAddress ps = withSmpServerAndNames $ \reg -> -- a self-claimed name is never auto-verified from link data: the claim is not proof of ownership testChannelDomainLinkJoinUnverified :: HasCallStack => TestParams -> IO () -testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames $ \reg -> +testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames ps $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -144,7 +144,7 @@ testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames $ \reg -> teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) testChannelDomainVerify :: HasCallStack => TestParams -> IO () -testChannelDomainVerify ps = withSmpServerAndNames $ \reg -> +testChannelDomainVerify ps = withSmpServerAndNames ps $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -174,7 +174,7 @@ testChannelDomainVerify ps = withSmpServerAndNames $ \reg -> teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) testConnectByChannelName :: HasCallStack => TestParams -> IO () -testConnectByChannelName ps = withSmpServerAndNames $ \reg -> +testConnectByChannelName ps = withSmpServerAndNames ps $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -208,7 +208,7 @@ testConnectByChannelName ps = withSmpServerAndNames $ \reg -> -- first and succeeds (bob has joined #team), so it is the primary (planSimplexName); otherSimplexName -- is the direct contact @team.simplex, shown as "You can also connect to @team.simplex in direct chat". testConnectByNameChannelAndContact :: HasCallStack => TestParams -> IO () -testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> +testConnectByNameChannelAndContact ps = withSmpServerAndNames ps $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -247,7 +247,7 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> -- channel #acme, shown as "You can also join channel #acme". The channel link is a real, fetchable -- #acme channel, so the failure is the faithful "channel does not claim this domain" case, not a broken link. testConnectByNameContactAndChannel :: HasCallStack => TestParams -> IO () -testConnectByNameContactAndChannel ps = withSmpServerAndNames $ \reg -> +testConnectByNameContactAndChannel ps = withSmpServerAndNames ps $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -266,7 +266,7 @@ testConnectByNameContactAndChannel ps = withSmpServerAndNames $ \reg -> acmeName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "acme" []) testConnectByNameBusinessAndChannel :: HasCallStack => TestParams -> IO () -testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg -> +testConnectByNameBusinessAndChannel ps = withSmpServerAndNames ps $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index f96eac2870..31ca1bae73 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -740,26 +740,26 @@ testCreateAddressOnServer :: HasCallStack => TestParams -> IO () testCreateAddressOnServer ps = testChat aliceProfile test ps where tmp = tmpPath ps - -- second SMP server, distinct from alice's configured server (localhost:7001) - altServer = "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003" + -- second SMP server, distinct from alice's configured server + altServer = smpServer2Str ps altServerCfg = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } test alice = do withSmpServer' altServerCfg $ do - -- without a server the address is created on the configured server (7001) + -- without a server the address is created on the configured server alice ##> "/_address 1" (_, defaultLink) <- getContactLinks alice True - defaultLink `shouldContain` "localhost%3A7001" -- server is URL-encoded in the link + defaultLink `shouldContain` ("localhost%3A" <> smpTestPort ps) -- server is URL-encoded in the link alice ##> "/_delete_address 1" alice <## "Your chat address is deleted - accepted contacts will remain connected." alice <## "To create a new chat address use /ad" - -- with a server the address is pinned to the requested server (7003) + -- with a server the address is pinned to the requested server alice ##> ("/_address 1 " <> altServer) (_, pinnedLink) <- getContactLinks alice True - pinnedLink `shouldContain` "localhost%3A7003" + pinnedLink `shouldContain` ("localhost%3A" <> smpTestPort2 ps) alice <## "disconnected 1 connections on server localhost" testRetryConnectingViaContactLink :: HasCallStack => TestParams -> IO () @@ -803,8 +803,8 @@ testRetryConnectingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile alice <## "disconnected 2 connections on server localhost" bob <## "disconnected 1 connections on server localhost" serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], msgQueueQuota = 2, serverStoreCfg = persistentServerStoreCfg tmp } @@ -814,7 +814,8 @@ testRetryConnectingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile { agentConfig = testAgentCfg { quotaExceededTimeout = 1, - messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} + messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval}, + persistErrorInterval = 0 } } opts' = @@ -859,7 +860,7 @@ testRetryConnectingContactViaAddress ps = alice ##> "/_rotate_address_keys 1" _ <- getContactLink_ alice False alice <## "auto_accept off" - serverCfg' = smpServerCfg {transports = [("7003", transport @TLS, False)]} + serverCfg' = (smpServerCfg ps) {transports = [(smpTestPort2 ps, transport @TLS, False)]} cfg' = testCfg {agentConfig = testAgentCfg {persistErrorInterval = 0}} opts' = testOpts @@ -1262,9 +1263,10 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $ alice <## "bob (Bob): you can send messages to contact" alice <# "@bob hello!" concurrentlyN_ - [ do - bob <# "alice> hello!" - bob <## "alice (Alice): contact is connected", + [ bob + <### [ WithTime "alice> hello!", + "alice (Alice): contact is connected" + ], alice <## "bob (Bob): contact is connected" ] @@ -1284,11 +1286,8 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice bob <## "contact address: connecting, allowed to reconnect" biz <## "#bob (Bob): accepting business address request..." bob <## "#biz: joining the group..." - -- the next command can be prone to race conditions - bob ##> ("/_connect plan 1 " <> cLink) - bob <## "business address: connecting to business #biz" + checkBusinessPlanWhileJoining bob cLink biz <## "#bob: bob_1 joined the group" - bob <## "#biz: you joined the group" biz #> "#bob hi" bob <# "#biz biz_1> hi" bob #> "#biz hello" @@ -1322,6 +1321,19 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice (alice <# "#bob bob_1> hey there") (biz <# "#bob bob_1> hey there") +checkBusinessPlanWhileJoining :: HasCallStack => TestCC -> String -> IO () +checkBusinessPlanWhileJoining cc link = do + let cmd = "/_connect plan 1 " <> link + joined = "#biz: you joined the group" + known = "business address: known business #biz" + cc `send` cmd + ls <- replicateM 3 $ getTermLine cc + if known `elem` ls + then do + l <- getTermLine cc + (l : ls) `shouldMatchList` [cmd, joined, known, "use #biz to send messages"] + else ls `shouldMatchList` [cmd, joined, "business address: connecting to business #biz"] + testBusinessUpdateProfiles :: HasCallStack => TestParams -> IO () testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile cathProfile $ \biz alice bob cath -> do @@ -1404,8 +1416,10 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c WithTime "#alisa alisa_1> hello again [>>]", WithTime "#alisa robert> hi there [>>]" ] - cath <## "#alisa: member alisa_1 is connected" - cath <## "#alisa: member robert is connected", + cath + <### [ "#alisa: member alisa_1 is connected", + "#alisa: member robert is connected" + ], biz <## "#alisa: cath joined the group", do alice <## "#biz: biz_1 added cath (Catherine) to the group (connecting...)" @@ -1417,6 +1431,8 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c -- both customers receive business profile change biz ##> "/p business" biz <## "user profile is changed to business (your 1 contacts are notified)" + cath <## "contact biz changed to business" + cath <## "use @business to send messages" biz #> "#alisa hey" concurrentlyN_ [ do @@ -1427,10 +1443,7 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c bob <## "biz_1 updated group #biz: (signed)" bob <## "changed to #business" bob <# "#business business_1> hey", - do - cath <## "contact biz changed to business" - cath <## "use @business to send messages" - cath <# "#alisa business> hey" + cath <# "#alisa business> hey" ] biz ##> "/set delete #alisa on" biz <## "updated group preferences:" @@ -1502,10 +1515,12 @@ testPlanAddressOwn ps = alice <## "contact address: own address" alice ##> ("/c " <> cLink) - alice <## "connection request sent!" - alice <## "alice_1 (Alice) wants to connect to you!" - alice <## "to accept: /ac alice_1" - alice <## "to reject: /rc alice_1 (the sender will NOT be notified)" + alice + <### [ "connection request sent!", + "alice_1 (Alice) wants to connect to you!", + "to accept: /ac alice_1", + "to reject: /rc alice_1 (the sender will NOT be notified)" + ] alice @@@ [("@alice_1", "Audio/video calls: enabled"), (":2", "")] alice ##> "/ac alice_1" alice <## "alice_1 (Alice): accepting contact request, you can send messages to contact" @@ -1554,10 +1569,12 @@ testPlanAddressConnecting ps = do threadDelay 100000 withTestChat ps "alice" $ \alice -> do - alice <## "subscribed 1 connections on server localhost" - alice <## "bob (Bob) wants to connect to you!" - alice <## "to accept: /ac bob" - alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice + <### [ "subscribed 1 connections on server localhost", + "bob (Bob) wants to connect to you!", + "to accept: /ac bob", + "to reject: /rc bob (the sender will NOT be notified)" + ] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" withTestChat ps "bob" $ \bob -> do @@ -1934,8 +1951,10 @@ testSetConnectionIncognitoProhibitedDuringNegotiation ps = do alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" withTestChat ps "bob" $ \bob -> do - bob <## "subscribed 1 connections on server localhost" - bob <## "alice (Alice): contact is connected" + bob + <### [ "subscribed 1 connections on server localhost", + "alice (Alice): contact is connected" + ] alice <##> bob alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] @@ -2514,12 +2533,12 @@ testChangePCCUserDiffSrv ps = do alice ##> "/smp" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" - alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003", id, "ok") + alice <## (" " <> smpServerStr ps) + alice #$> ("/smp " <> altServer, id, "ok") alice ##> "/smp" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003" + alice <## (" " <> altServer) alice ##> "/user alice" showActiveUser alice "alice (Alice)" -- Change connection to newly created user and use the newly created connection @@ -2541,9 +2560,10 @@ testChangePCCUserDiffSrv ps = do (bob <## "alisa: contact is connected") alice <##> bob where + altServer = "smp://" <> testServerKeyHash <> ":server_password@127.0.0.1:" <> smpTestPort2 ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False), ("7002", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False), (xftpTestPort ps, transport @TLS, False)], msgQueueQuota = 2 } @@ -2581,13 +2601,13 @@ testSetGroupAlias = testChat2 aliceProfile bobProfile $ testSetContactPrefs :: HasCallStack => TestParams -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ - \alice bob -> withXFTPServer $ do - alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") - bob #$> ("/_files_folder ./tests/tmp/bob", id, "ok") - createDirectoryIfMissing True "./tests/tmp/alice" - createDirectoryIfMissing True "./tests/tmp/bob" - copyFile "./tests/fixtures/test.txt" "./tests/tmp/alice/test.txt" - copyFile "./tests/fixtures/test.txt" "./tests/tmp/bob/test.txt" + \alice bob -> withXFTPServer alice $ do + alice #$> ("/_files_folder " <> tmpFile alice "alice", id, "ok") + bob #$> ("/_files_folder " <> tmpFile bob "bob", id, "ok") + createDirectoryIfMissing True $ tmpFile alice "alice" + createDirectoryIfMissing True $ tmpFile bob "bob" + copyFile "./tests/fixtures/test.txt" $ tmpFile alice "alice/test.txt" + copyFile "./tests/fixtures/test.txt" $ tmpFile bob "bob/test.txt" bob ##> "/_profile 1 {\"displayName\": \"bob\", \"fullName\": \"\", \"shortDescr\": \"Bob\", \"preferences\": {\"voice\": {\"allow\": \"no\"}, \"receipts\": {\"allow\": \"yes\", \"activated\": true}}}" bob <## "profile image removed" bob <## "updated preferences:" @@ -2706,8 +2726,7 @@ testUpdateGroupPrefs = bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Full deletion: on" - threadDelay 500000 - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on")]) + (bob ##> "/_get chat #1 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (groupFeatures <> [(0, "connected"), (0, "Full deletion: on")]) alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"off\"}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" alice <## "updated group preferences:" alice <## "Full deletion: off" @@ -2717,8 +2736,7 @@ testUpdateGroupPrefs = bob <## "updated group preferences:" bob <## "Full deletion: off" bob <## "Voice messages: off" - threadDelay 500000 - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off")]) + (bob ##> "/_get chat #1 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off")]) alice ##> "/set voice #team on" alice <## "updated group preferences:" alice <## "Voice messages: on" @@ -2726,8 +2744,7 @@ testUpdateGroupPrefs = bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" bob <## "Voice messages: on" - threadDelay 500000 - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on")]) + (bob ##> "/_get chat #1 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on")]) threadDelay 500000 alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" -- no update @@ -2780,7 +2797,7 @@ testAllowFullDeletionGroup = bob <## "updated group preferences:" bob <## "Full deletion: on" alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "hi"), (0, "hey"), (1, "Full deletion: on")]) - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "hi"), (1, "hey"), (0, "Full deletion: on")]) + (bob ##> "/_get chat #1 count=100" >> chat <$> getTermLine bob) `shouldEventuallyReturn` (groupFeatures <> [(0, "connected"), (0, "hi"), (1, "hey"), (0, "Full deletion: on")]) bob #$> ("/_delete item #1 " <> msgItemId <> " broadcast", id, "message deleted") alice <# "#team bob> [deleted] hey" alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "hi"), (1, "Full deletion: on")]) @@ -2849,41 +2866,41 @@ testEnableTimedMessagesContact = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - alice ##> "/_set prefs @2 {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": 1}}" + alice ##> "/_set prefs @2 {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": 3}}" alice <## "you updated preferences for bob:" - alice <## "Disappearing messages: enabled (you allow: yes (1 sec), contact allows: yes)" + alice <## "Disappearing messages: enabled (you allow: yes (3 sec), contact allows: yes)" bob <## "alice updated preferences for you:" - bob <## "Disappearing messages: enabled (you allow: yes (1 sec), contact allows: yes (1 sec))" + bob <## "Disappearing messages: enabled (you allow: yes (3 sec), contact allows: yes (3 sec))" bob ##> "/set disappear @alice yes" bob <## "your preferences for alice did not change" alice <##> bob threadDelay 500000 - alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (1 sec)"), (1, "hi"), (0, "hey")]) - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (1 sec)"), (0, "hi"), (1, "hey")]) - threadDelay 1000000 + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (3 sec)"), (1, "hi"), (0, "hey")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (3 sec)"), (0, "hi"), (1, "hey")]) + threadDelay 3000000 alice <### ["timed message deleted: hi", "timed message deleted: hey"] bob <### ["timed message deleted: hi", "timed message deleted: hey"] - alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (1 sec)")]) - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (1 sec)")]) + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (3 sec)")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (3 sec)")]) -- turn off, messages are not disappearing bob ##> "/set disappear @alice no" bob <## "you updated preferences for alice:" - bob <## "Disappearing messages: off (you allow: no, contact allows: yes (1 sec))" + bob <## "Disappearing messages: off (you allow: no, contact allows: yes (3 sec))" alice <## "bob updated preferences for you:" - alice <## "Disappearing messages: off (you allow: yes (1 sec), contact allows: no)" + alice <## "Disappearing messages: off (you allow: yes (3 sec), contact allows: no)" alice <##> bob threadDelay 1500000 - alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (1 sec)"), (0, "Disappearing messages: off"), (1, "hi"), (0, "hey")]) - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (1 sec)"), (1, "Disappearing messages: off"), (0, "hi"), (1, "hey")]) + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (3 sec)"), (0, "Disappearing messages: off"), (1, "hi"), (0, "hey")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (3 sec)"), (1, "Disappearing messages: off"), (0, "hi"), (1, "hey")]) -- test api bob ##> "/set disappear @alice yes 30s" bob <## "you updated preferences for alice:" - bob <## "Disappearing messages: enabled (you allow: yes (30 sec), contact allows: yes (1 sec))" + bob <## "Disappearing messages: enabled (you allow: yes (30 sec), contact allows: yes (3 sec))" alice <## "bob updated preferences for you:" alice <## "Disappearing messages: enabled (you allow: yes (30 sec), contact allows: yes (30 sec))" bob ##> "/set disappear @alice week" -- "yes" is optional bob <## "you updated preferences for alice:" - bob <## "Disappearing messages: enabled (you allow: yes (1 week), contact allows: yes (1 sec))" + bob <## "Disappearing messages: enabled (you allow: yes (1 week), contact allows: yes (3 sec))" alice <## "bob updated preferences for you:" alice <## "Disappearing messages: enabled (you allow: yes (1 week), contact allows: yes (1 week))" @@ -2893,23 +2910,23 @@ testEnableTimedMessagesGroup = \alice bob -> do createGroup2 "team" alice bob threadDelay 1000000 - alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"timedMessages\": {\"enable\": \"on\", \"ttl\": 1}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" + alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"timedMessages\": {\"enable\": \"on\", \"ttl\": 3}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}" alice <## "updated group preferences:" - alice <## "Disappearing messages: on (1 sec)" + alice <## "Disappearing messages: on (3 sec)" bob <## "alice updated group #team: (signed)" bob <## "updated group preferences:" - bob <## "Disappearing messages: on (1 sec)" + bob <## "Disappearing messages: on (3 sec)" threadDelay 1000000 alice #> "#team hi" bob <# "#team alice> hi" threadDelay 500000 - alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "hi")]) - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)"), (0, "hi")]) - threadDelay 1000000 + alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Disappearing messages: on (3 sec)"), (1, "hi")]) + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (3 sec)"), (0, "hi")]) + threadDelay 3000000 alice <## "timed message deleted: hi" bob <## "timed message deleted: hi" - alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Disappearing messages: on (1 sec)")]) - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)")]) + alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Disappearing messages: on (3 sec)")]) + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (3 sec)")]) -- turn off, messages are not disappearing alice ##> "/set disappear #team off" alice <## "updated group preferences:" @@ -2921,8 +2938,8 @@ testEnableTimedMessagesGroup = alice #> "#team hey" bob <# "#team alice> hey" threadDelay 1500000 - alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Disappearing messages: on (1 sec)"), (1, "Disappearing messages: off"), (1, "hey")]) - bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (1 sec)"), (0, "Disappearing messages: off"), (0, "hey")]) + alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Disappearing messages: on (3 sec)"), (1, "Disappearing messages: off"), (1, "hey")]) + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Disappearing messages: on (3 sec)"), (0, "Disappearing messages: off"), (0, "hey")]) -- test api alice ##> "/set disappear #team on 30s" alice <## "updated group preferences:" @@ -2944,20 +2961,20 @@ testTimedMessagesEnabledGlobally = alice ##> "/set disappear yes" alice <## "user profile did not change" connectUsers alice bob - bob ##> "/_set prefs @2 {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": 1}}" + bob ##> "/_set prefs @2 {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": 3}}" bob <## "you updated preferences for alice:" - bob <## "Disappearing messages: enabled (you allow: yes (1 sec), contact allows: yes)" + bob <## "Disappearing messages: enabled (you allow: yes (3 sec), contact allows: yes)" alice <## "bob updated preferences for you:" - alice <## "Disappearing messages: enabled (you allow: yes (1 sec), contact allows: yes (1 sec))" + alice <## "Disappearing messages: enabled (you allow: yes (3 sec), contact allows: yes (3 sec))" alice <##> bob threadDelay 500000 - alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (1 sec)"), (1, "hi"), (0, "hey")]) - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (1 sec)"), (0, "hi"), (1, "hey")]) - threadDelay 1000000 + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (3 sec)"), (1, "hi"), (0, "hey")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (3 sec)"), (0, "hi"), (1, "hey")]) + threadDelay 3000000 alice <### ["timed message deleted: hi", "timed message deleted: hey"] bob <### ["timed message deleted: hi", "timed message deleted: hey"] - alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (1 sec)")]) - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (1 sec)")]) + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (3 sec)")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (3 sec)")]) testUpdateMultipleUserPrefs :: HasCallStack => TestParams -> IO () testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ @@ -3057,13 +3074,13 @@ testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danP testGroupPrefsFilesForRole :: HasCallStack => TestParams -> IO () testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do - alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") - bob #$> ("/_files_folder ./tests/tmp/bob", id, "ok") - createDirectoryIfMissing True "./tests/tmp/alice" - createDirectoryIfMissing True "./tests/tmp/bob" - copyFile "./tests/fixtures/test.txt" "./tests/tmp/alice/test1.txt" - copyFile "./tests/fixtures/test.txt" "./tests/tmp/bob/test2.txt" + \alice bob cath -> withXFTPServer alice $ do + alice #$> ("/_files_folder " <> tmpFile alice "alice", id, "ok") + bob #$> ("/_files_folder " <> tmpFile bob "bob", id, "ok") + createDirectoryIfMissing True $ tmpFile alice "alice" + createDirectoryIfMissing True $ tmpFile bob "bob" + copyFile "./tests/fixtures/test.txt" $ tmpFile alice "alice/test1.txt" + copyFile "./tests/fixtures/test.txt" $ tmpFile bob "bob/test2.txt" createGroup3 "team" alice bob cath threadDelay 1000000 alice ##> "/set files #team on owner" @@ -3092,7 +3109,7 @@ testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ testGroupPrefsSimplexLinksForRole :: HasCallStack => TestParams -> IO () testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> withXFTPServer $ do + \alice bob cath -> withXFTPServer alice $ do createGroup3 "team" alice bob cath threadDelay 1000000 alice ##> "/set links #team on owner" @@ -3355,9 +3372,10 @@ testShortLinkJoinGroup = do cath <## "#team: alice added dan (Daniel) to the group (connecting...)" cath <## "#team: new member dan is connected", - do - dan <## "#team: member bob (Bob) is connected" - dan <## "#team: member cath (Catherine) is connected" + dan + <### [ "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected" + ] ] dan ##> ("/_connect plan 1 " <> fullLink) dan <## "group link: known group #team" @@ -3397,10 +3415,13 @@ testShortLinkInvitationPrepareContact = testChat2 aliceProfile bobProfile test <### [ "alice: connection started", WithTime "@alice hello" ] - alice <# "bob> hello" concurrently_ (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") + ( alice + <### [ WithTime "bob> hello", + "bob (Bob): contact is connected" + ] + ) alice <##> bob bob ##> ("/_connect plan 1 " <> shortLink) bob <## "invitation link: known contact alice" @@ -3427,15 +3448,19 @@ testShortLinkInvitationImage = testChat2 aliceProfile bobProfile test <### [ "bob: connection started", WithTime "@bob hello" ] - bob <# "alice> hello" concurrently_ (alice <## "bob (Bob): contact is connected") - (bob <## "alice (Alice): contact is connected") + ( bob + <### [ WithTime "alice> hello", + "alice (Alice): contact is connected" + ] + ) bob <##> alice testShortLinkInvitationConnectRetry :: HasCallStack => TestParams -> IO () -testShortLinkInvitationConnectRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps +testShortLinkInvitationConnectRetry ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test ps where + cfg' = testCfg {agentConfig = testAgentCfg {persistErrorInterval = 0}} test alice bob = do shortLink <- withSmpServer' serverCfg' $ do alice ##> "/_connect 1" @@ -3459,17 +3484,20 @@ testShortLinkInvitationConnectRetry ps = testChatOpts2 opts' aliceProfile bobPro <### [ "alice: connection started", WithTime "@alice hello" ] - alice <# "bob> hello" concurrently_ (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") + ( alice + <### [ WithTime "bob> hello", + "bob (Bob): contact is connected" + ] + ) alice <##> bob alice <## "disconnected 1 connections on server localhost" bob <## "disconnected 1 connections on server localhost" tmp = tmpPath ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } opts' = @@ -3641,8 +3669,8 @@ testShortLinkAddressConnectRetry ps = where tmp = tmpPath ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } opts' = @@ -3675,11 +3703,11 @@ testShortLinkAddressConnectRetryIncognito ps = bob ##> ("/_connect plan 1 " <> shortLink) bob <## "contact address: known prepared contact alice" bob ##> "/_connect contact @2 incognito=on text hello" - bobIncognito <- getTermLine bob - bob - <### [ "alice: connection started incognito", - WithTime "i @alice hello" - ] + line <- getTermLine bob + let helloFirst = dropTime_ line == Just "i @alice hello" + bobIncognito <- if helloFirst then getTermLine bob else pure line + bob <## "alice: connection started incognito" + unless helloFirst $ bob <# "i @alice hello" alice <### [ ConsoleString (bobIncognito <> " wants to connect to you!"), WithTime (bobIncognito <> "> hello") @@ -3704,8 +3732,8 @@ testShortLinkAddressConnectRetryIncognito ps = where tmp = tmpPath ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } opts' = @@ -3735,11 +3763,8 @@ testShortLinkAddressPrepareBusiness = testChat3 businessProfile aliceProfile {fu bob <## "#biz: connection started" biz <## "#bob (Bob): accepting business address request..." bob <## "#biz: joining the group..." - -- the next command can be prone to race conditions - bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "business address: connecting to business #biz" + checkBusinessPlanWhileJoining bob shortLink biz <## "#bob: bob_1 joined the group" - bob <## "#biz: you joined the group" biz #> "#bob hi" bob <# "#biz biz_1> hi" bob #> "#biz hello" @@ -3960,10 +3985,10 @@ testShortLinkGroupRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps bob ##> "/_connect group #1" bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: known prepared group #team" alice <## "subscribed 2 connections on server localhost" bob <## "subscribed 1 connections on server localhost" + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: known prepared group #team" threadDelay 250000 bob ##> "/_connect group #1" bob <## "#team: connection started" @@ -3986,8 +4011,8 @@ testShortLinkGroupRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps bob <## "disconnected 2 connections on server localhost" tmp = tmpPath ps serverCfg' = - smpServerCfg - { transports = [("7003", transport @TLS, False)], + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } opts' = @@ -4084,10 +4109,13 @@ testShortLinkChangePreparedContactUser = testChat2 aliceProfile bobProfile test <### [ "alice: connection started", WithTime "@alice hello" ] - alice <# "robert> hello" concurrently_ (bob <## "alice (Alice): contact is connected") - (alice <## "robert: contact is connected") + ( alice + <### [ WithTime "robert> hello", + "robert: contact is connected" + ] + ) alice <##> bob @@ -4136,10 +4164,13 @@ testShortLinkChangePreparedContactUserDuplicate = testChat2 aliceProfile bobProf <### [ "alice_1: connection started", WithTime "@alice_1 hello" ] - alice <# "robert_1> hello" concurrently_ (bob <## "alice_1 (Alice): contact is connected") - (alice <## "robert_1: contact is connected") + ( alice + <### [ WithTime "robert_1> hello", + "robert_1: contact is connected" + ] + ) alice #> "@robert_1 hi" bob <# "alice_1> hi" @@ -4340,13 +4371,13 @@ testShortLinkChangePreparedGroupUserDuplicate = testChat3 aliceProfile bobProfil ] cath <# "#team alice> 4" - bob #> "#team_1 5" + bob `send` "#team_1 5" + bob <### [WithTime "#team_1 5", WithTime "#team robert_2> 5"] [alice, cath] *<# "#team robert> 5" - bob <# "#team robert_2> 5" - bob #> "#team 6" + bob `send` "#team 6" + bob <### [WithTime "#team 6", WithTime "#team_1 robert_1> 6"] [alice, cath] *<# "#team robert_1> 6" - bob <# "#team_1 robert_1> 6" threadDelay 1000000 cath #> "#team 7" @@ -4387,13 +4418,14 @@ testShortLinkInvitationSetIncognito = testChat2 aliceProfile bobProfile test <### [ ConsoleString (aliceIncognito <> ": connection started"), WithTime ("@" <> aliceIncognito <> " hello") ] - alice ?<# "bob> hello" - _ <- getTermLine alice concurrentlyN_ [ bob <## (aliceIncognito <> ": contact is connected"), - do - alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) - alice <## "use /i bob to print out this incognito profile again" + alice + <### [ WithTime "i bob> hello", + ConsoleString aliceIncognito, + ConsoleString ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito), + "use /i bob to print out this incognito profile again" + ] ] alice ?#> ("@bob hi") bob <# (aliceIncognito <> "> hi") @@ -4432,10 +4464,13 @@ testShortLinkInvitationChangeUser = testChat2 aliceProfile bobProfile test <### [ "alisa: connection started", WithTime "@alisa hello" ] - alice <# "bob> hello" concurrently_ (bob <## "alisa: contact is connected") - (alice <## "bob (Bob): contact is connected") + ( alice + <### [ WithTime "bob> hello", + "bob (Bob): contact is connected" + ] + ) alice <##> bob testShortLinkAddressChangeProfile :: HasCallStack => TestParams -> IO () @@ -4573,11 +4608,13 @@ testShortLinkGroupChangeProfileReceived = testChat3 aliceProfile bobProfile cath cath <## "changed to #club" alice <## "cath updated group #team: (signed)" alice <## "changed to #club" - threadDelay 250000 - bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect directly" - groupSLinkData <- getTermLine bob + let planGroupLinkData = do + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect directly" + getTermLine bob + (T.isInfixOf "\"displayName\":\"club\"" . T.pack <$> planGroupLinkData) `shouldEventuallyReturn` True + groupSLinkData <- planGroupLinkData bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#club: group is prepared" bob ##> "/_connect group #1" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9586957281..cd975a32c3 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -13,7 +13,7 @@ import ChatTests.DBUtils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_, mapConcurrently_) import Control.Concurrent.STM -import Control.Monad (unless, when) +import Control.Monad (join, unless, when) import Control.Monad.Except (runExceptT) import Data.ByteString (ByteString) import qualified Data.ByteString.Base64 as B64 @@ -34,7 +34,7 @@ import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.FileTransfer.Client.Main (xftpClientCLI) +import Simplex.FileTransfer.Description (FileSize (..)) import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -43,8 +43,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern P import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) -import System.Environment (lookupEnv, withArgs) -import System.IO.Silently (capture_) +import System.Environment (lookupEnv) import System.Info (os) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec @@ -95,7 +94,7 @@ it :: HasCallStack => String -> (ps -> Expectation) -> SpecWith (Arg (ps -> Expe it name test = Hspec.it name $ \tmp -> timeout t (test tmp) >>= maybe (error "test timed out") pure where - t = 90 * 1000000 + t = 180 * 1000000 xit' :: HasCallStack => String -> (ps -> Expectation) -> SpecWith (Arg (ps -> Expectation)) xit' = if os == "linux" then xit else it @@ -127,9 +126,9 @@ versionTestMatrix2 runTest = do it "prev" $ runTestCfg2 testCfgVPrev testCfgVPrev (runTest True True) it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev (runTest True True) it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg (runTest True True) - it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile (runTest True False) + it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile (runTest True True) it "old to curr" $ runTestCfg2 testCfg testCfgV1 (runTest True True) - it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest True False) + it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest True True) versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix3 runTest = do @@ -680,11 +679,24 @@ createCCNoteFolder cc = withCCUser cc $ \user -> runExceptT (createNoteFolder db user) >>= either (fail . show) pure +shouldEventuallyReturn :: (HasCallStack, Eq a, Show a) => IO a -> a -> Expectation +shouldEventuallyReturn action expected = go (200 :: Int) + where + go n = do + r <- action + if r == expected || n == 0 + then r `shouldBe` expected + else threadDelay 100000 >> go (n - 1) + getProfilePictureByName :: TestCC -> String -> IO (Maybe String) getProfilePictureByName cc displayName = withTransaction (chatStore $ chatController cc) $ \db -> - maybeFirstRow fromOnly $ - DB.query db "SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName) + join <$> maybeFirstRow fromOnly (DB.query db "SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName)) + +getProfileShortDescrByName :: TestCC -> String -> IO (Maybe String) +getProfileShortDescrByName cc displayName = + withTransaction (chatStore $ chatController cc) $ \db -> + join <$> maybeFirstRow fromOnly (DB.query db "SELECT short_descr FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName)) pqSndForContact :: TestCC -> ContactId -> IO PQEncryption pqSndForContact = pqForContact_ pqSndEnabled PQEncOff @@ -814,8 +826,10 @@ createGroup4 gName cc1 (cc2, role2) (cc3, role3) (cc4, role4) = do [ cc1 <## "#team: dan joined the group", do cc4 <## ("#" <> gName <> ": you joined the group") - cc4 <## ("#" <> gName <> ": member " <> sName2 <> " is connected") - cc4 <## ("#" <> gName <> ": member " <> sName3 <> " is connected"), + cc4 + <### [ ConsoleString ("#" <> gName <> ": member " <> sName2 <> " is connected"), + ConsoleString ("#" <> gName <> ": member " <> sName3 <> " is connected") + ], do cc2 <## ("#" <> gName <> ": " <> name1 <> " added " <> sName4 <> " to the group (connecting...)") cc2 <## ("#" <> gName <> ": new member " <> name4 <> " is connected"), @@ -890,7 +904,12 @@ linkAnotherSchema link | otherwise = error "link starts with neither https://simplex.chat/ nor simplex:/" xftpCLI :: [String] -> IO [String] -xftpCLI params = lines <$> capture_ (withArgs params xftpClientCLI) +xftpCLI = \case + ["rand", path, size] -> do + let FileSize n = fromString size :: FileSize Int + B.writeFile path =<< atomically . C.randomBytes n =<< C.newRandom + pure ["File created: " <> path] + params -> error $ "unsupported xftp CLI command: " <> unwords params setRelativePaths :: HasCallStack => TestCC -> String -> String -> IO () setRelativePaths cc filesFolder tempFolder = do diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 2ab4cae0d6..c191402e9f 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -232,8 +232,8 @@ storedBindingsTest = testRemote $ \compress mobile desktop -> do desktop ##> "/stop remote host new" desktop <## "ok" - desktop ##> ("/start remote host new addr=" <> localAddress <> " iface=\"lo\" port=52230") - desktop <## ("new remote host started on " <> localAddress <> ":52230") + desktop ##> ("/start remote host new addr=" <> localAddress <> " iface=\"lo\" port=" <> remoteTestPort desktop) + desktop <## ("new remote host started on " <> localAddress <> ":" <> remoteTestPort desktop) desktop <##. "other addresses: " desktop <## "Remote session invitation:" inv <- getTermLine desktop @@ -286,17 +286,17 @@ remoteMessageTest = testRemote3 $ \compress mobile desktop bob -> do remoteStoreFileTest :: HasCallStack => ((Bool, Bool), TestParams) -> IO () remoteStoreFileTest = testRemote3 $ \compress mobile desktop bob -> - withXFTPServer $ do - let mobileFiles = "./tests/tmp/mobile_files" + withXFTPServer mobile $ do + let mobileFiles = tmpFile mobile "mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) mobile <## "ok" - let desktopFiles = "./tests/tmp/desktop_files" + let desktopFiles = tmpFile desktop "desktop_files" desktop ##> ("/_files_folder " <> desktopFiles) desktop <## "ok" - let desktopHostFiles = "./tests/tmp/remote_hosts_data" + let desktopHostFiles = tmpFile desktop "remote_hosts_data" desktop ##> ("/remote_hosts_folder " <> desktopHostFiles) desktop <## "ok" - let bobFiles = "./tests/tmp/bob_files" + let bobFiles = tmpFile bob "bob_files" bob ##> ("/_files_folder " <> bobFiles) bob <## "ok" @@ -328,7 +328,7 @@ remoteStoreFileTest = runExceptT (remoteStoreFile rhClient "tests/fixtures/test.pdf" "../x") >>= \case Left (RPEInvalidBody _) -> pure () r -> fail $ "expected RPEInvalidBody, got " <> show r - doesFileExist "./tests/tmp/x" `shouldReturn` False + doesFileExist (tmpFile mobile "x") `shouldReturn` False -- the undrained attachment did not break the session desktop ##> "/store remote file 1 tests/fixtures/test.pdf" desktop <## "file test_3.pdf stored on remote host 1" @@ -353,8 +353,10 @@ remoteStoreFileTest = [ do desktop <## "completed uploading file 1 (test_1.pdf) for bob", do - bob <## "saving file 1 from alice to test_1.pdf" - bob <## "started receiving file 1 (test_1.pdf) from alice" + bob + <### [ "saving file 1 from alice to test_1.pdf", + "started receiving file 1 (test_1.pdf) from alice" + ] bob <## "completed receiving file 1 (test_1.pdf) from alice" ] B.readFile (bobFiles "test_1.pdf") `shouldReturn` src @@ -381,8 +383,10 @@ remoteStoreFileTest = [ do desktop <## "completed uploading file 2 (test_2.pdf) for bob", do - bob <## "saving file 2 from alice to test_2.pdf" - bob <## "started receiving file 2 (test_2.pdf) from alice" + bob + <### [ "saving file 2 from alice to test_2.pdf", + "started receiving file 2 (test_2.pdf) from alice" + ] bob <## "completed receiving file 2 (test_2.pdf) from alice" ] B.readFile (bobFiles "test_2.pdf") `shouldReturn` src @@ -398,8 +402,10 @@ remoteStoreFileTest = [ do bob <## "completed uploading file 3 (test.jpg) for alice", do - desktop <## "saving file 3 from bob to test.jpg" - desktop <## "started receiving file 3 (test.jpg) from bob" + desktop + <### [ "saving file 3 from bob to test.jpg", + "started receiving file 3 (test.jpg) from bob" + ] desktop <## "completed receiving file 3 (test.jpg) from bob" ] Just cfArgs'@(CFArgs key' nonce') <- J.decode . LB.pack <$> getTermLine desktop @@ -426,13 +432,13 @@ remoteStoreFileTest = r `shouldContain` err remoteCLIFileTest :: HasCallStack => ((Bool, Bool), TestParams) -> IO () -remoteCLIFileTest = testRemote3 $ \compress mobile desktop bob -> withXFTPServer $ do - let mobileFiles = "./tests/tmp/mobile_files" +remoteCLIFileTest = testRemote3 $ \compress mobile desktop bob -> withXFTPServer mobile $ do + let mobileFiles = tmpFile mobile "mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) mobile <## "ok" - let bobFiles = "./tests/tmp/bob_files/" + let bobFiles = tmpFile bob "bob_files/" createDirectoryIfMissing True bobFiles - let desktopHostFiles = "./tests/tmp/remote_hosts_data" + let desktopHostFiles = tmpFile desktop "remote_hosts_data" desktop ##> ("/remote_hosts_folder " <> desktopHostFiles) desktop <## "ok" @@ -456,8 +462,10 @@ remoteCLIFileTest = testRemote3 $ \compress mobile desktop bob -> withXFTPServer [ do bob <## "completed uploading file 1 (test.pdf) for alice", do - desktop <## "saving file 1 from bob to test.pdf" - desktop <## "started receiving file 1 (test.pdf) from bob" + desktop + <### [ "saving file 1 from bob to test.pdf", + "started receiving file 1 (test.pdf) from bob" + ] desktop <## "completed receiving file 1 (test.pdf) from bob" ] @@ -482,8 +490,10 @@ remoteCLIFileTest = testRemote3 $ \compress mobile desktop bob -> withXFTPServer [ do desktop <## "completed uploading file 2 (test.jpg) for bob", do - bob <## "saving file 2 from alice to ./tests/tmp/bob_files/test.jpg" - bob <## "started receiving file 2 (test.jpg) from alice" + bob + <### [ ConsoleString ("saving file 2 from alice to " <> bobFiles <> "test.jpg"), + "started receiving file 2 (test.jpg) from alice" + ] bob <## "completed receiving file 2 (test.jpg) from alice" ] diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 783f6587bf..0f2cddcc1c 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -5,7 +5,6 @@ module SchemaDump where -import ChatClient (withTmpFiles) import ChatTests.DBUtils import Control.Concurrent.STM import Control.DeepSeq @@ -62,7 +61,7 @@ schemaDumpTest = do it "verify strict tables" testVerifyStrict testVerifySchemaDump :: IO () -testVerifySchemaDump = withTmpFiles $ do +testVerifySchemaDump = do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing) @@ -70,7 +69,7 @@ testVerifySchemaDump = withTmpFiles $ do removeFile testDB testVerifyLintFKeyIndexes :: IO () -testVerifyLintFKeyIndexes = withTmpFiles $ do +testVerifyLintFKeyIndexes = do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing) @@ -78,7 +77,7 @@ testVerifyLintFKeyIndexes = withTmpFiles $ do removeFile testDB testSchemaMigrations :: IO () -testSchemaMigrations = withTmpFiles $ do +testSchemaMigrations = do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations diff --git a/tests/Test.hs b/tests/Test.hs index 0a5b56b230..4517a8edb2 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -31,15 +31,17 @@ import OperatorTests import RandomServers import RemoteTests import Test.Hspec hiding (it) +#if !MIN_VERSION_hspec(2,10,10) +import Test.Hspec.Core.Spec (sequential) +#endif import UnliftIO.Temporary (withTempDirectory) import ValidNames import ViewTests #if defined(dbPostgres) -import Control.Exception (bracket_) +import Control.Exception (bracket_, finally) import PostgresSchemaDump import Simplex.Chat.Store.Postgres.Migrations (migrations) import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropDatabaseAndUser) -import System.Directory (createDirectoryIfMissing, removePathForcibly) #else import APIDocs import qualified Simplex.Messaging.TMap as TM @@ -55,19 +57,20 @@ main = do chatQueryStats <- TM.emptyIO agentQueryStats <- TM.emptyIO #endif - withGlobalLogging logCfg . hspec + portBases <- newPortBases + withTestDB . withTmpFiles . withGlobalLogging logCfg . hspec . parallel $ do #if defined(dbPostgres) - createdDropDb . around_ (bracket_ (createDirectoryIfMissing False "tests/tmp") (removePathForcibly "tests/tmp")) $ + sequential $ describe "Postgres schema dump" $ postgresSchemaDumpTest migrations schemaDumpDBOpts "src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql" #else - describe "Schema dump" schemaDumpTest + sequential $ describe "Schema dump" schemaDumpTest #if MIN_VERSION_base(4,18,0) - describe "Bot API docs" apiDocsTest + sequential $ describe "Bot API docs" apiDocsTest #endif around tmpBracket $ describe "WebRTC encryption" webRTCTests #endif @@ -90,13 +93,13 @@ main = do describe "Operators" operatorTests describe "Random servers" randomServersTests #if !defined(dbPostgres) - around (tmpTestBracket chatQueryStats agentQueryStats) $ describe "names tests" chatNamesTests - around (tmpTestBracket chatQueryStats agentQueryStats) $ xdescribe'' "SimpleX Directory names" directoryNameTests + around (tmpTestBracket chatQueryStats agentQueryStats portBases) $ describe "names tests" chatNamesTests + around (tmpTestBracket chatQueryStats agentQueryStats portBases) $ describe "SimpleX Directory names" directoryNameTests #endif #if defined(dbPostgres) - createdDropDb . around testBracket + around (testBracket portBases) #else - around (testBracket chatQueryStats agentQueryStats) + around (testBracket chatQueryStats agentQueryStats portBases) #endif $ do #if !defined(dbPostgres) @@ -104,30 +107,35 @@ main = do #endif describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests - xdescribe'' "SimpleX Directory service bot" directoryServiceTests - xdescribe'' "SimpleX badge service e2e" $ do + describe "SimpleX Directory service bot" directoryServiceTests + describe "SimpleX badge service e2e" $ do badgeServiceTests describe "managed group" badgeGroupIntegrationTests describe "Remote session" remoteTests #if !defined(dbPostgres) - xdescribe'' "Save query plans" saveQueryPlans + sequential $ xdescribe'' "Save query plans" saveQueryPlans #endif where #if defined(dbPostgres) - createdDropDb = - before_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) - . after_ (dropDatabaseAndUser testDBConnectInfo) - testBracket test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, printOutput = False} + withTestDB = + bracket_ + (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) + (dropDatabaseAndUser testDBConnectInfo) + testBracket portBases test = + withPortBase portBases $ \portBase -> tmpBracket $ \tmpPath -> do + let ps = TestParams {tmpPath, portBase, printOutput = False} + withSmpServer ps (test ps) `finally` dropTestSchemas ps #else - testBracket chatQueryStats agentQueryStats test = - withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats, printOutput = False} - tmpTestBracket chatQueryStats agentQueryStats test = - tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats, printOutput = False} + withTestDB = id + testBracket chatQueryStats agentQueryStats portBases test = + tmpTestBracket chatQueryStats agentQueryStats portBases $ \ps -> withSmpServer ps $ test ps + tmpTestBracket chatQueryStats agentQueryStats portBases test = + withPortBase portBases $ \portBase -> tmpBracket $ \tmpPath -> test TestParams {tmpPath, portBase, chatQueryStats, agentQueryStats, printOutput = False} #endif tmpBracket test = do t <- getSystemTime let ts = show (systemSeconds t) <> show (systemNanoseconds t) - withTmpFiles $ withTempDirectory "tests/tmp" ts test + withTempDirectory "tests/tmp" ts test logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} From 84f73ce8b81d86ee87f7c0f27a6d77c908efdde2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 7 Oct 2026 09:53:45 +0100 Subject: [PATCH 15/94] website: update crowdfunding page --- .../SQLite/Migrations/chat_query_plans.txt | 30 ++++++++++-------- website/src/crowdfunding.html | 10 +++--- .../crowdfunding/simplex-chat-pitch-deck.pdf | Bin 2413661 -> 2436357 bytes .../src/img/crowdfunding/simplex-growth-2.png | Bin 220105 -> 242883 bytes 4 files changed, 22 insertions(+), 18 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 8b4c0bf4bf..c169b95d9a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1123,6 +1123,15 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) +Query: + SELECT s.member_relations_vector, r.index_in_group + FROM group_members s, group_members r + WHERE s.local_display_name = ? AND r.local_display_name = ? + +Plan: +SCAN s +SEARCH r USING AUTOMATIC PARTIAL COVERING INDEX (local_display_name=?) + Query: SELECT t.roster_transfer_id, t.roster_version, t.roster_digest, t.sending_owner_gm_id, t.broker_ts, t.roster_msg_chat_binding, t.roster_msg_signatures, t.roster_msg_body @@ -4201,15 +4210,6 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -Query: - SELECT s.member_relations_vector, r.index_in_group - FROM group_members s, group_members r - WHERE s.local_display_name = ? AND r.local_display_name = ? - -Plan: -SCAN s -SEARCH r USING AUTOMATIC PARTIAL COVERING INDEX (local_display_name=?) - Query: SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled, role_storage, role_proxy, role_names @@ -7470,9 +7470,9 @@ Query: SELECT connection_id FROM connections WHERE user_id = ? AND group_member_ Plan: SEARCH connections USING INDEX idx_connections_group_member_id (group_member_id=?) -Query: SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND (chat_item_ttl > 0 OR chat_item_ttl IS NULL) Plan: -SCAN contacts +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) Query: SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0 Plan: @@ -7568,9 +7568,9 @@ Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMI Plan: SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) -Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Query: SELECT group_id FROM groups WHERE user_id = ? AND (chat_item_ttl > 0 OR chat_item_ttl IS NULL) Plan: -SCAN groups +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ? Plan: @@ -7608,6 +7608,10 @@ Query: SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1 Plan: SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) +Query: SELECT index_in_group, member_relations_vector FROM group_members WHERE local_display_name = ? +Plan: +SCAN group_members + Query: SELECT issue_failed_since, issue_error FROM badge_purchases Plan: SCAN badge_purchases diff --git a/website/src/crowdfunding.html b/website/src/crowdfunding.html index a4faa67dc8..4beddd2151 100644 --- a/website/src/crowdfunding.html +++ b/website/src/crowdfunding.html @@ -1033,7 +1033,7 @@ templateEngineOverride: njk
-
520KMonthly users
+
600KMonthly users
@@ -1048,8 +1048,8 @@ templateEngineOverride: njk
-

520,000
monthly users

-

Grown with zero marketing spend: hundreds of user posts, podcasts and videos, and $650,000 in user donations, before any paid features.

+

Over 600,000
monthly users

+

Organic growth: hundreds of user posts, podcasts and videos, and $650,000 in user donations, before any paid features.

Only $1.7M investment over 4 years — more capital efficient than most startups.

@@ -1074,7 +1074,7 @@ templateEngineOverride: njk
Users
-
520,000 active monthly,
+
600,000 active monthly,
organic growth
@@ -1084,7 +1084,7 @@ templateEngineOverride: njk
Businesses
-
20+ offer customer
+
50+ offer customer
support
diff --git a/website/src/img/crowdfunding/simplex-chat-pitch-deck.pdf b/website/src/img/crowdfunding/simplex-chat-pitch-deck.pdf index c35540061cb8d2dba14f5cc195d18399c6f1ff8f..62ceb5ed482f09edef66745ebbc93acf6660a726 100644 GIT binary patch delta 176472 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

c-d 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

J|xbpS4Z~rEH9}$qw--Mbc<{SXViI7V)HI z|5vpd=}^1MuhTy3{u%{OWUOmluI(4TBnS0=GI#P)fMN1lHTWUV$cXltv6&t*FmG!L zCMVBjj5LN4#%jSs1DV}by;ALqmZ=?4U9YtWKNjeA@XCVA#e!?m_Q>4}CsHC=_5g_j zkQ(X_i3IdKsj&;b-_WjzS^@?=LWb5QNH_Qag|@2NA$6TZnGJ`Gb>ok6$22F8JZRfL zo_5*XJ#@y5oIOj=(ukp(LF2YPhnt*KFMDIa0agAY)j7?=l8s4&6B5P=l{bd)IUAtH zk?{zAwzL7$nq0^HJb`Zq0YLzGqT|FtLd@6Hay}KS_uq7yVP;5T7V-1QatLz4`{nQ_ z#B)?x{yqf-m`IB%vkhOjkVtxDOFd2081LyaIx(^hJ~%SQlxn0?$>!|vXh#Ys4*I3; zeEDhA&Cg_2Tgz~<1hGuri-|z^)13 z@U^zj)o=;s?gCVH$!j0#)qADT-G|(jJ8tU1yI*A#?`=J)(hl2aWI&f^WUbxH#tVVm zsS71{{k+_7ek}8N?L&Hwt2KzEt2I|_eocr@owvL6VvZb66n_LIIIQK6R47DhGR=tE zNDRRc5syLurg)PJl&_~08FPuOJms@{*{sA^)0VBpyjSYlz~KW;{)JzKvH4&ji*aO* z@`ce1c;hFDd*kJX`mfH+u+t%%)n^oH1`F=%_C1?AE32++vU%l}RNQ1}D};61sDsHZ zNE2UaVJCxa5rCb3ZrISefUfb(g_>(EfEn?qcmbrbc}mG=IY~bgwR>_XlZ_?i%$Isb zGt7PAn;{2b&An&O?MW%ha#+fVG?G!;=;NHRmS*DNgYFHf7&yRLdNJd`4@Y<9GfYdo z`wUfabZwF}Oh9yoYmB?J!9`mH`}67IWYehWo-U?RG$B~v;IzD{bxgPA;N;nC#x&{=`K`w-z058|!{Lz}S zvx;@KG3STMYq?x`&yJXGUBBog@${8D_SsGR3&cbyK`{|bM+YVto>Vx=C<4&|rc5=j zABGaF*<3mhg%iG%F!2X6N(&X@kp_AfQVhC(ae6*@d3|>74as`7k>*derT~qkjWx|z zC?!1A;Wg?EHHy3+JfUR7ruZh2=nY5G_3FBomI%Xsga?Ij?o%VZ>!#PQ>=!8lOEVUr zE!=DlB_iU`JU^i#bj>$Ze48Fde_}n}r}+GBz@0s%zr&2pe+WItbbi2Ng1pYP?=8}l zvd2gRA**jsvVR)YkXPZ&gI=|o?4vEn3x$#j&-1vuPxl|m=i>HXXmTO+&;>_3av{|Cmvr-Q zpj1RM(4zeAi!M)qiGdoTXVi9SS0LcX2f253aVPTA#8v7pe&GITp85!$f#)?6L=UDj zyYN!5>xpoRH#sDq9N(=sBf5@b$}I;i9sgOMH5yC!nqSpWhtJx!TG*UgQ|-YB#XgNn zi5(n+0G3cLz#I1nE72sbn9w9jv{WXmsvQR2e|uHm5*<49iS(qYu{i;ic~w63utbHq^vZ6WgGP&~ar!Ou zb(3$p^bb^!KZ@%txrbszy#?m5XaSVUNH7p1S@e)82qu*xWrp&pMny!#>XiFc;2l{t zh;u%1J+Q#+rV}K{dBXBx9WU6sW@UbukukIeSDi*3eTA&11d%ynh$MVZ01xQ5n1@`Q zy24VCdKS&6HvRZy4sW)nKNH%Xa+=h7+befY{H_S7XE;jeS$j`A2BKSPi%>$;vvXzI zU0Dn1JD4vVH8&`Q-jQ5Q?H|Xkh{jTjDE;8GH=|B5Qd^E8 z@w2LK{9oy|b7gYYq-rW~sXj{uxwGcbwOenmr>FVpm!n>Pj5H*p5Y`tTYmBSk5thC-h(g{eh^naJ&I)33z-oT;^_g+jv;-njFnBR}R8 zQc7Nv2l~&*oSj8b)u;0VXnM?{86hK0)T!a&?wM*K$G)knGsLLZD)S=0q3C47B zpx|6Zy!CLI;DxGg$2UIRR^L4wE9e`H*cP!#W*3xXr6EGMt7T*%3|(fSi7@ftKAb3W z-{^(#GHWxEgkO$PYk7w9uX?x_O`#OWBWKWcTNQXVAU6ZJC(8(oFFG@DU`OEXo>v=O z7%4|U*A`K4PPXUrC!Y(@cKw!5D$D8AntJ{8t>l!tLc>LZdI{}WQxA(N1Zs^s39eQn z%8XRh9ljBb1+~~7S(4-|(_6FfotD~{v7LH*^I#E6s;)FoEsK(krz9{MjgF)kPT9`oR^GIZsJoB(^?h_Z89o5S&=2^#_rU9t z@oVfvfFT+H{}OTONM0$vWZ5saSn2a-xDzU^ihjVxSkzM%)H37vJ^U}K7kdoc4<3pF z*ePpQzjbqXJ^Rip<4w{J zi^GUz${!3_2ta=fz8MNif{LM?1=^0F^-u~_SpJkBQ|{|_iJ9v5%E7Gg>_h|ifu=*NXXs+*nOryKV2}3pzx)k>Hu1f@cGiQvwi`=S3miTF zHDDoXpW$6+crW6%l*&F%@2-f4BtOa`sM+N`AP#!~z`XDYZk*)lX6eSMx@^MCfiYi< zA40u%%O9yNiyPb{fL<0*hCl4l;Q1sDZgvKZaleJI{l6geqQV~{A?6- z`#fxOfh+(Paa4c)yK8?gvF^ZUN7n{u!sx+VbpV1M01h?@&NYV_gf<4~ceKb>d{ZBp z)_S}7ETAauEI3@!Uk|BZ+f-Q&qqF>jPx}X#SU~_9crWm8f6hPqHGjSNzk2WUL$^B{ zZ(e?x7PSAQqC8W6esormeh%!G`~~$V@f(8C2T^}srr6s(T|5Hu>1__0J)yYw+wDA4dxda*;+E}4?Jaqf9%@$WW3{W2O^kIa_8amO$xAQ zND1ma>c}Jy7EDG>)t-^j{)ZO#U$#xfC!jpp%>F+Ji*A|Y_`0E*9OVg@zRAg6d$5@3 zEz*?MCztX>^04Z9r}7*56o4J23(E4`0Dq|yFxZDu21RE2E`kE^Z>g;RpaxeDQsm2l zGYT8Lf)ze2`G1 zKji|ja?_^M1Sj%9xj_EZSgOZc-v*!%4D{{obJd25-o1a96KN`9Q`qa3Iu>Pj*+K}y zHv0YBe|b~>mtWD&-`xrPi=F;=xBiPegWy7Ri&sd~wq5jL^66E{5UhWTM_2!+co9vL z=bm06vUX=A>pL5$kpc>sVc0PeY!&3X#x1tP(KtnFxvAoDA1ADR;dpL77c}I0B>io*uSV&4Hunj)yVsnewGKM-mk#?bIR#GoXmsU{3f$VMHoO<~ zRMKiF{Z50~IrV_XqZDVK=H#zn{qwL|C0Qd1@PSUSp4|`TL)O$mfCxW84W#*Sg}<&$ zVt=mv&53wj0OhdFdTsO(N`e55L5mL4d1j^6M~w&XJrf%F*xoG~IX!)_`)ksPFYLyb zkAp19L!&-(`Vr?q%2{ws7(BVY{@uyt4P3`3qD4U&kssERc*?!T`o;i6mm3sx-WFzq z2PU)%%OB=XIDeNubE*SB^3hj z8Vvaj_d(3;X|DU2;|LCUz=42C+9EqJd0r7tt1vw99UI(+n>}B-kI?i8&+B^5Ex}v%h{rUe+Q{&fpCdpbI79n{js-g1b$E zw6`bgG#LpxL+D@yZa-2w?9Q9KxZUJPZczN`&mZjfw?7XG{FSYoc^y9+50{1!@?f)7 zaMBe33V@t@?%=Km>nu!(O+zqe1Rv(Ho0^Ns)~FV(BpGJ178;HYQl)=fb0Ebk@W zxAq>Oy&++ibY7{V`(U<^r_^9-P6CKs)jWzYSVOP&OgwnQHSZA-{Ky*o>wTVsMh z*vKoP>4uVeN9JZmIV@+K?zf{tDJK_G@6t3;=g1$8dUXc)mu)-4&^rM$ebD$>hi(|j zB$*v(m!<7PI`3T=S8oh{<9mhwH$=CiJ%*g506Xu-ud@5RP7>NH(YNsfh@+41zIUL( zxEAbY=*=bP(!f5~{bd;AsNyoH4B|#6{V^7nzZlF2a}MtVOQ^$EV^>$^HYS9aZ99&J zT!emy&MQu)ho7w5TGXb^cb1=LSC^YO7|C+74c7{vS+p zlquPf&{s>6$}Lf}}AN4O&DT=C@TyfY6T;vj zgR??eD-?ZssyQ+ddRYldS6VBK7*CN09R$ zlU4Z!uxb_pV~BA)SerkHmZ$TPMw8Jnq!BEJsqw9h{c|BLBU=q$r#M1iHLZZyTE>n^5tB?5hFH~Lc=h;CrrM=k`YhrKdCcQ z@kpeT@%DSeciGtTaamp-*~6pjJ#^vfIj=P{a?EUWegUZX%!lAaT_Wk#hY}Kf9c9x_ zt2l@2B+h-i*PX(ukn{aYz7q6~jXX+3vkL-%uC5YKFE^us&Dn9~VPHGEJ%fQB-DIbM z;rPdSY;7_ltxW$n#H>jPKKl2ziw60*uhYRKmJ_kcI(H&3B7H@`y~ucD@mlw1*um75 zWsR}XHaH2yH{F_vX7b+~2iXX92wEVF#p|IAoF0*d*8wMbMnjE`f)$&-Sd z88c>j0a&HWVFuxwd$qpF%&E(-@k(fgtYdl2bF-0u6U_ZR`gcC>AK_-fXnmgNKY(QM zxCzWFMXt)fDpS$b84X91iQvQ*jBb+&)0$=0zOxJzZw2M2j3_?`h(lRlbJ_P1=|KqWhMMC`^q zI28nnrkppZ6*x%E8yc5m(t`>TlWBW{4(V8jdv}(%TsCW(-Qvk!BP1F2o78xgb8vy0 zcz_VUfSG+x!O(?6Tk(Ia{ss{3CZqJD2AEKFj#ob(>U(L_3*2li3oYNvfp+wv3Q}m) zy#dZ8cd-e(0==LlY~#ay8b4KDmai%ZxSz`rrCfpEsU$2=7ZZDKY9@!JKgE-j^!LE7 zxhuyDu!9cy^9TIP{`nq5{68~lK!wyn+3D%ppeoI*E|NrnaItv#T)AA?56V>%NgGtY zhbv>W(e((lIQr9LdN_Fcb9_u*L^)hJPBou*OT6By6z0j7)3T6qV1-> ziuw(~X2<~-X&a#5Frq!ck1xAdgIFW=<2#bT%$w$?FbO8ddLnqjn2||;GU-1@%wLb0 z|3XInpX^a!f$l*p(`aoG_=XB7*XvW0`5e9yy&X5jn*^}i!jrJ13s9crhM3k3DfAa3 z1vvFN{VZ9xe;7~Sj+LX~o9owK3vwWuFx;9H2%pb_V?;kXn>Gg1^<_gPlv>C2hV zfqf+F4RG;!TMFAnm+|qU&d1@ic`xQi8Tey3OzMs?_$q#p{`MCB`X>H9Li;Bh{YN~L z(~penfNnUtCh=hLe#aDLCf3DsR&ox2|^>wKll zPUJCsIpj}m0SDmvK&E@l+7mE^+fUVZWTY_iqv%FP50YRNi`nM`GWxqdkRx6u{%|lvW2tUWgm4I*X>5g$yBEg9_#X* z^iePUg{O>HRDz}x$sEP=!#to-yJBIBQFJL;vUmc3TYGJGz5elsN(T1Rm9gikPHfjT zrVY8|5iqLNC*rEkFIo3$0&oAIU@veu+so>JQyFyOC zico19eeV7+cp5ye76xv_T)fVQEtaY7;5E@chAN^Lngj^AgDsoG*#Ms*f zy>pxZgyDg?M=d?7byoktiPsq&O zN4Guy(0)zX>jITsfCiQmHd_kvozGB@sbM=bcz`yeCQTBvr|UAxV3;Y3I+NZNP4#gL zDZZ=F{5t*C$m-U4)n2yIeP)1u`P9n>LOleyde33A5X|v7jxJB+U^-;;=||sx1=}I+ zZCOvg7%F+FSibp=1UZgJqEppVyjtT_jQ3d-y-zh66TbZ`_B`V-x_^mwPNQ{(Pk+tj z=o7_G(;6W^JSoXkHO%>1quAzJ=Z1Wt+?C?yZ1s;W+?3HP&KXOne(Hokmd#pmoylO~qV0w*BJR}oi~#I9 z5WQF5X?aZ=3rvU~H{(li`ne{_+`6%o0jlI(D=VT4cTmO|RkfUdfROAQ)cB(ruQr5m zQ92y1aV#Gkjv0L}ZN`cVB+u<{I_Wj|_Pf?zU}*FJ(Wtftxm zO5Zn8!|x&2OsX1D*oz=6fn<$kW%i1Nx}6{^%pt3ZdhaB55C>d6=BJdtlUuK4URCRR zZ}7vgvRf2gOckcVP)ESiibK2u5xJ-1toJlcqTs5+e3qx3eWugF%+wEd?9Xx`tPmEr zKERo9=l#dGU2}!~cK(MYx9F<9w!y_Ex4IV2I0;llM`m%c@4J7 z4GmkT!F&&Biw0pNM8kktk%*A zwMSbHtEP0?n*@KXID}n^2EnE_hO(eU^TsK=AEj?WA->tF(BVp{U>QPgZ3wb@LsKE)PXzlybaZrhOBKVBM$8M z3k_4xCT54k3W#`D6d=6bnz3D)@yaz0i9=%f7CJxgA7x{#fM@Js`2Qq8RtB!YK}827 z55Xa7-O!J$;#l*gCj0F3K%9r;O^%8_!OYW7Z>6`gef?;z^wFwU-2CaXrEQVe{I;3z z6y&~5P+jR8(J;4RZttv#p<&BdpdL>b5vSBLihhui zSAXG__xO{EDb1_>u=Xo3+k*l6qHR^Y=C^NPLhm%gs zU49dn4>@)a!qUnbSm;!G{FHBUgSroU_M$?eH#DM!l(NeMZ3 zKTn>#YLigonp?hBe-}dvjWm=XZJc@3p35@izip{Cc2mWIQ)pUbPyWVLwB1)5;|Rl> zd1-M~Fs}mt&ODF`BMb~*}T z=4hlyc8E3OKYX@V_3HO(U+a+eIHt0fZY<-k$EoErv3=4QP(&VOR||c1PeL zP+=F{SW0#2D4D0mJCnY{eWLdk5NacwfodJ^E}Ga;F1|MLvcD25Q3ZJ5sB-mP8u7U@ zLv$dN7DTOgO5&=6(dBNgn|W=@a;z@hR*ibB5NbiI;kARa)Rw&8*HzW>Q zBOa&51ckHB=!jrq|5+<4%An}e&B{8yUXy#AA|bcx9lK3X-MCS(euHIi5kYa1p1SR) z!d^O20gHH!-kM~e5LT&zyO>ml>-e)>-KCwuLz&1Y%FlDGz4y-gSfc7NrMRCrg6!tZ zPoiX?(Lv0%)6!1PcJuh2S{<6L1^p7S1xi0b0?}7Ili-9*d9v3lAH94*=kW^_-2!*c zJJx?!TMWTM;)Ww$R~?*CeRbbcqGv5r;;h88x@FtVq)q)+}mnbJQA z?*4h0n+D*pEjr9^U}I->r(nhxFK*~mw4_$lb0}>>r)@(Z=Uunhl)iZvMemDvO2IZ; z@&`U!N;Ts>n91ckKV|YWVzP{93#iG(->*?;x5fP%9raj$l6prpQ*@gXX>JPwv5tI*H0e<H;(Ws%~H18_3bLE0JAK}N2j`m`o>kZ1rC7~A@h z9m81d3M;iyzm@SPf`VsEVCW)~bHLh30>joAJQtAk59)6t}>drQ8l*J#d^Ri z%dpS(aD~u$bPp2W?IuJ82$MK*>Y{D!;C6|K)-~5@^NJ41q}BB^&>TRG3*6%;81L`_ zunF~*SrB3qjvxf6bcG(BytH-L^j$?wl(zgvP-V4}hkfb?d!?K=cUYl#^g%FsQ#5oS z`*o$DK)gg`0&--7xWCB=x$Ot|LwSSD z15_8wFGr%~AmNUpp5A@p%9|;-fM@Ajl?zS&CQz+U*Q5fYfAvWqG@^x{m_O=HCko=56)qpWcZk71o=?F+*%2e z&ti8K@{%9Ix({dLw$7v8aYL>z=5_v3<4!m}+er8%QMFI6cDo@u@TadmaO{o=3nRxtD+eswzAhjM9N5eHiOKD0?q4 zdrJA>t*>6;WRF8vz%aDHn@w2U$felxMB9nBw(>(AS{>JY!u_xQat{vEBF6Wo$O1&u2WVlW)k+UsXeZoNz_dytYkl*qh~G~Z z;I!kmorI?$#1buuf`coIqu-*EOZrLRbjb{Xr~jPZyQFSwk?HYX)!V(At;by+`yY6p zu2rI+S>7%E)-mk!a~eOD#^h!v3J8GQS@o9RJ}17Gx;cU9SP>!{`vkgX9c_5G#iKKn zzaMk!_Qm~QZ#IT#te8G95~1sWXs#Y0pRWI<^qz(HD~sTy&!VxM`Mp==O0GwFQ9(G@@*d)-S;DXjG&$^lrz9JJLwYzUegK71{ z&wbGrbZ+oTOpt&RS(5I>cuzkcK{n{tdhb=&_#9tNoapgpy`6S-J#N~W-O<2V@C*9G zPb26TluEt}GPFbNvQI-q7UyhjZQ_k+d1+BWh#l(}Ui)Uzj|k{j>?`|pD78e z{;~C0;nUr)IOKA<8A{XnoJ!60vV%C|Wzaf8b2%}Cr@6E-O*!0rw*S;D;wkDRY#a_Y zyMde7?Xl1D_}*|dhctSjqVcPcxSyjs&Y!NY(4=L)zccezT@KMiHs?ml8(Asn-&z$Z z{Ty6?t?Q7y*2Qyg6Vi|nZo0vr((hUxtq+99AVWo8+{}dXmx-1xY$u*5 z`*CzN&7yJCqqv?1c39fZw#cE*Q!~L5H2@}S6W@`Kx_1>`eckz4F_LvfY4T{#NlWtL zZd%29vNF)@+R9m^{UxvlU`w zG7m40q*432n1_cAf!EICrJJVeiQ%`y-)KuX%U{Tx((OCAkQjT^0ip8gf)^`pakd?c(5mr{_r!2yYs4U*5N1SB-PS>bhlt3n~T%7bkoc zb2@JuOY(y9AK-6EGiw#46X@R56*4&aUPv>speZs3M(>WHWQNv7{3a;RO52x37g0Ek zG(9n?n~%Nj@3)>l#)4`Z)`{lVG&lSP99=tDHpviL-hX7i&GSHEwx+466DC%@gJ+YbmUV8yG;b1j>%p= zu*(K*Jm3kdz*j4-c95_XODf!tR8P>wq#|VG8yeF2?uG}MNcy~Bd7pfv{f$ri8wiW5 zVk`Cmp65@moC465&}ilYdePIeF4UlwmMBi&=X`hBA4${BWpSj4Y-Yif0&olam^{KMLM7x>jd+*ku>$59+YJ!G$*Qzogm`9qy` zTQfJ3-qpvWdPwH3gNh{110lxoALbk8{%rp%cZU}% z*X!Hff_Rd2w7`{-DBdp~n4wHpy64LsuonCt)Q#0a>QlafE8R#{_QOr^5alNvI&!3J zFRBNWs?OD>UkQ3`dimys#3tO-gIm*YT|$QX2DaTv4zob}%=luf7?!u?0I+o6=brJi z4@sJs%yavf6z+lz-yZeO^x>ELn#D~A?*aTvmEO}J#YOk$?*VZ<$dO|y!R>D!K=!zY z%U_fzMhhA4$S*pToNTP4XG~UY!nHpU%QefgEx&XOUp?+KEpq+oO>F4L{C>r$G7XPg z;K3TM*bVO4|0k1%xn#n10*v5C{>KeOFb4AkaF1Z#8dRmw)w{G4hI@zvloNk(Nn6T< zTLSyemmA+)Zfp_}KDW-pxdf=k9M(Wu)+<0hvkTaNYpCWvH5pEp==6`Fet91aF%5RW z{`BkVTzq#*n__-#i|MGS6w!ysNv`Na3ZLj2bS%Dd^$jravFa*e`Ndj4cv2Tb8&`pk zaju^s^XU^UN2pWai6O;EG=e-SLm}qkrOM77dUT_(o)R@ermI#U1hIR|A5O0j@f@Iy z=;3_^lJ+XV#CrA>Ag(KLz^BMRro#yO&d-AD(5brdj|FBwp7JJCo+}Ua@N~uNkI(SI zX0z}W_;yQoT6i0lUuUK^QUZP%xLfhOc0u_)v$@-()wxf8oA#i$XU{YBG(rN-Ja} zKgf@MdNcpiD~bL|oDOj!WNLjE;fHoX{RI(%?tpP8IDqov<%TX1P?+~0P z7U+hF-*$Yv5huy(LI*?I&|voTk-iJVL|W#E*iryDL+JQhnDWw39GnY72>Lh{W|N#} zL0(2B`E{Pm^-}lm5I%(`ru&4cVLpKE&{e}AOywyq3j@~wMVSdSMK=KV1n-ZS9G(qb zy+L?&cdjJ87#X*zHGzKQ**f)IxYcv{NW7uMp%B^g$0y-Wcenn8Vqk;gyQXzW9(r?`*k^xJl;1%cXG zh>5x3n+d#%&<1UTL!t_C>;_MT3Q!v)SSy@#8A??ieK1Sj=!V6*w}PA$3n4XP&N_T- z4VctT^GT66w>;S`EL08$g>*fHmqM$P>B8X@YeqKxAQLz^NZ0luO7*>Khc7#S7o5I! zZH_JB)YC*Q7QwuzS>a3x+`D_t8kfFo|I~>Pn@};ZxFy)Z#wnMiau4ewR-p{v7zn2WoE=o>UJ=)@94)MoA#h}LBk7hTEAmk|L*O0OH)_5kY6|P z&+Aayw9|l9n-1VUF#i5i(iI8Ym03U>{Jb~g582#0q`I!xEtz)hpuZ8|!} zzeFa4E4XhZ^yzgk=;q2vJKHHvTh?#3^!0j!zUrO~@=p@rV8HUfzy^Ru;Q$mbpGv~! zk6FH59`5n;^G{SAzSJg{DB|(_c?E)hO}6O*I$U=DA*lI^8c5 z4`18!bV|9tsqCjsbacyiD2xU~ux55iNg~4|{l(@Z)ehEa3?L1d&17TcN9Vknu~%*HdLb6oC4^2!-wG)5Ni$|+d3;r3V8#Ey`2cPgmm@ugd__e~M6U)x zSYPfd%xf;?-xn=}t$rn?jw^|noWk1@x0=8a*6f=ya?*Ca+b$+51@58+S62UkI~oNJ z{tf4V@e$*4#2gpm=G6Lg>7N87|E!x2uAntgT^r$kk;Hs%REu*x@9yOvZC0)>eKSr+ z+Ua>LH%GBfc3tW1_yGA@9dBGOG--z9h#Rjbtb-{Wr$#F+d>{_3{5-*)5+<2h=M%0m zp;t$zYzo2KTaZ8`mUI<2N&1DC(f!HfMRISHCl>7&-)aYgH%#>C_?JVk`mQb@#$RrMG8I{zp>KI!vmJjwRZql32$(m$sXFWk=1Xv3w#t`ATZ z1lIUUeI*B?enb3=M(zp3R5pBv0{qS^EZ4sUv)^J_NSbgG>8^am-e$%%UR&`RkNyMo z8;>XaY1-$F62#j-6wpMp47a|2XVcfW?cFVSK6t?3Iy+l(#u=OThw$^Gl7z*$GF~?i zeI;B)*@twRfty=EbGXa-QB3Udj}^gHxl%8Shca-^4#9;AlwrK8)RKq`R$(e<$1r>r z+bZw~sK!3#cxc3pL{E0?d2VRVJ-TbA@*5K7bW2UXEQ!@XlQk)0RmTQyY4t*>>s*$( z%B>oUd9+V#=SZhrhVRy$7deyZ0rgY$CI7#Q=nni_!i3}a*;M8c^32+4uzzcUp2XH# zVF?4PcwQvT{2etM(3q6NdN8r~i* z4`4T-(G5MF0Zvvk_10`D2dIE5yme#zZnq8U1S#;@w9!bu=4oL*IxB6@i+V5 z-{`3C)6O_Xng9#mh-CJ=}ve9N-hoaA`-k+U!&@Thp zlv*^=Uvl*j2hfvni5+Y4rOQos&KCLv#REJMlV=ernQ_OZj_DaUf7!2EFpD1i*`SwB z=$9Gp*y95wyY&wAAP{WILMQT`oXb;A+6TtQ(vULcRc2?Rw?cBUGOlbYA4EcR_u^ct zKm3NcE?-~npFiP?bZjgbS}aEF{j$w9`we;6bsq{taWgW&zW=~@i8=ElRf*toMw+*Z zEptIaET-fF_;=Wnk zOi3tc+4eEV|D0Zae<@-84=S6dfO3QG89_r9(y%Gw0Lo-khq15iZg!%{f`Q{ZkgvzY zW^Z(%r)!3)3at8(rj>4x64&$R;yY6V+Py;(NWTVhVgrwE<{4Dn{!yLA(t4s?)y+Ms zy@aUzX0I8&HooRzcrwfL&W}FQaPi{7I!tLDTrc@s90@-GrKC`eX=*6I0C@5nBC!Ko zojQIgbI{);(`hk9sIohb%Tv~I55gr6mOL%66T3`q9PKiCj^al(73f%#oLl#0%AL%( zyq#+F<%m5;x!5$KxZD;rO;e5JnVO*VE^M7ZcViB`Q^vl}v9v6K5|UtLH7Iwk@FYzB zw(&|9d1Vn9_jRK0II#Jm!Q3Jf_DkE6j`>966=0h^Zbwj8_@p!#iQYMNhf*7%W@)9l z6EInAltlhNti5?4l+pJ;Zppsy`#Oq}Wf)szN|s7R8%3y8LTEt}9xZkf;wj3SrLvSg z`>qHfd-g)v2E|}ZzdK`!7VppZ{rP?V7@p^K&pr2^bMHO(+~v79563+{&yC6RNppHa zW7t-t<8(DHvZkhdI8yL28{*VM@lDq%3O%&>XO1r0L*@EtTg1Fv7t5eL#@z2ph0C2P zwhrlEmU(o%Sy)}XZx15ZxRSQ^h152-vv6>A##*KN5tl`_lBI#*>_ z;XeLd4>+`i%V>{=f-|j}bS_6KclGo7oBCSzJ1H;<>R%DFe#*yJsm7OaGrm|*_~4yN z{pfHsv>2TU&CeBTURNC!>{m>lKq_@V%}wS6>m6EK^$bw`55f0dUt;9pD|i|gSVoJE z0eaEfyW7%MX7X*1pj6{1Uq#B>FO$1kN__EJrGKBs3U((B&duAdyeMIG19tlMh?_C^ zn~beil18)O3^8Sx{8ogYWMqR~LQ8`8;6sI}IcgXV+TD4f>;fH(zl9#eLNAkXcv}eU zPsGa>@oPYb!;c?2T&3gKVbECHcNQl%l!|&F2{+`1EgT8$C=9UH9KZG*?~n5Y6WW9L zikP{@G~j%_F2xosauD{dKOk(>5xOu{uiJdSB+J{HogwU5clD@ls#ZxwRr=ta?8kva z>8JD}5}nM@`lWRvD4gohNp%gzVZ8L7snIK?&>Q@{l#G#jy!fdV7(HeJgY6mZF~eqD zc{srv%ks7*Jf#+NSevD}s+rsE&J^=)lZl73&yU{I4M9do(DI&UiIVhnI~XC+la}|O z^i7wGWmV&0{|d9*(Cjb8E0WlWf|zDAF6?UEBPMu?YBqpl~)22#%FKJMx#^Xp9|`T*KN~h6S`T5e*M+T zwDQo_&AIMMB5T$9(lMfu}ytJ4TK zPZxF@TAUS)l$zkV)2(0+${nE4b95)RgKHIMvQIxgpZX2QQg4XT++5dztJ0gM8cgOa z&iopKrCW{zd{05;W$0J$)F>;X<%XBrb3xyq*EVNd6@yh($4jT}EMagFvUii=J?iS@ zr&0Ljp2r!U5Qz|-TkrE8g>@Qn{z7j@FSlN&lFj9IxK=!y*95qIhetQH?qKmgAz|&w7~r!v$*zy z3<2ZCd^mrQz28&Oouy-IBjK!0z~w=X4Im_ql?<$87&%GM#zE;ZtaShc~#6 z=cT>$iwCixb9Qefin>;|G+VqW2?kz@z6-imu|5{3fvLF#JROw$%iFmgqO;lPIqrcn+sUuVI(+=LTMSl(8lX~5LG?C>EKOnX=< zwc@kR$ILBb;6-5v+#X1H%lI7#%Z0}8#orst81Y!ZGhbSPC$q9DErS=oq;*SvQ>y$L zgK^B5XiXM_uzHR;ltuWhIljN2X0!y*R342TC4w0H7h*R)74qlg<`JsLt34Ia{Th!;Ps$&8E?&tAa_e^o zYE`o>_*{;`znbYU?_(&;J>!7#8=O4!jXKay_FP_T#-MwZ!xjZ&Niy=;Kx2-<(Zj8A zx=*`}TUfxQ9Bc-_-Y0u|Dl!3Velv1c+Fu`IyF+uyr2laGtcb}3AUU=$|=5Y&$1QrD>1(v@mr9N;Khny+#dwNPIC=cnP zatgAI-f`i2OZQRitAr@kv~24ZbGrt-;ES0v6IgY+83Q)|(1|!*mXVdGV2$Q7NRTQ4 zoQN#y(Sq(oQ{q%Cosz!hL1&wbRBm20xv{B8yQMA^;m5ouSAtEd2@}+|poiNUgOyR4 zWSMi@J2M}IN!E{=?!Gu^@)dla1!1-oxx*H9 zJm4x7jW9f&*bGEj>#WH(b0KPmvaH*^KWb#$wJ*p{#w!CB*s=I5so%mZ2Khj@r}Z(qZS$9Hi|#>Pqu=OFC~Ni!v8L*zbT>BB3!&Pkc<=N0d>p+`)ue%FpJAq1Ad%R zws-Rs$I+QBzFFP%;ssK-c=o>jkiIFEH(!pvI!8d3H~uBfcXgfB)A@ftod53&CLWM4 zul!94z6butWfcFW1*G9k*epDA5-1Tt?6r!57+*&yAkTHB*XwBqt9691Kzr!d`Mviu z%Nfj0n)dje7ZvAA@)xaga6I*r4DXWy<~#5RBM|u{^#0C_F=$LCkRJGyz;+z$1}()n z)8N>ohiGnXcQYGm@?FZfmBUb~($K6_%KY_efA`{51-Zy2yW;P0b6na1pHIELds)DC zN{0*|oRabXeB*;B8v30Y$p0TMi1>^A7j+Tka57HlGbk`S?7kHkblTK8LmB=e-NF#~ z?3T9ij!bE%TUwMGSY3fz7H$;wScQxX*V`>CM3teFyQLSku%@66heF^g5=~K;soDz7 z?X4c7E&>LHb8fv3rp;-IFLP#xtt^yKJ>%n9BMQD9b-7{81GyhQj(-R9EPr3=0>0z^ zV7zu$$GWRJ{&m^QFKS2th#>Vp-3{}L{QsO9Tt@T1U_MI)?8*yp1w`rCZtM$Cq0$iG zBX^~FuwM{($6p<=Vokm{=_D7Q6WGG*v1a|M~LrN z^M(Q+CFx%H6oE+Y8vHvB-WOCpCvjx7U0;4erW)OYax` z<8@}!&sC^0XCzk0R*CH3ET3jH54|9{9hgg4qa z=f<&aV5#{|5HxTvM%ZI3)^;GcJSHHfr%!su z7c2Mky~>rJ<TR|D?eWpYeA{9U0&pXneI zKGLgkq|55EdGFO5hdMC18Sxwe8qdWBnsTn_p5+P_%4s=|zJFoO( zzD#2`!z%&?6&Vl?;TVk9$tWF5_pLx;kKdyY|9UJhJIr~iM-x;E9|NM1>Gt4)1_ho% zvobapLH*0XZn^ILT`DJo3E$Yym_6R9aQ1aJCL(-Ob13a*3H|fuRVB``+D;X0t=pOu z`QG=CVd!I_$Tp<{t7=wo#099hF@6HFaTw!rlPE|dvwpfb%zX zLXPMe4%pMbBQIB)bTslunj)f)UswoRZt_GAwh~*Xa*US+R`-htF2F(_-wv^$2f!V> z1{qXi(c;)vOl^5Iua9x@&4V13OEi%8@Xa)v?-kSjOmYUEQ8nd;pX1N%(w&VB#5Z<) z2HRif<~vs4($oFSSmR;is$j-WY}3%^cO52jl?Rzs{eU->s4B_ z1FQkOhCL7CQEdtI0TEA+n%j&#ykzEvT1gU4xSQGrR(o&0NDqFt_*t?F!YZdjwez|k z&~|8cHx8^rJl0@uM^DA|c2}RuC{?8w5UXs9V@GRHaJS|Mw!2cJl@w#jIrD z1Pq3PySNwT$D1{U-PofB?IZCzeXl^*@BEOTAp_>lxOB!d743qbZP*sbWQ(Uu&8u1Y+66IHK^)#3b1|F2C)W+HAS z>kCaRZMUayxZ0Ynd1~fri~W7oT}>u}qb(xTZa2U*Cpv~>uv>j5w?&xH1bU!cItHEZ#A0O|Kg*uS^n0TwJ^D=dW;|hO zfUZup9(w9AhV$I|5%OCpHKZxK0-Sj%uw>V~H?w+K2`6oIgcecWy-W1o@CtVUlP#Vu z8MrB!8Est_UNJj1u7W*`uif-7e%G9 zk`GxyoreqhvzLcneiK3xnuQ3aiL2O4hwWWV5hR)5wlUJGAU9V>9E`%wjo;hYE z;|~t!rS5{Ql-+&62J4NbRKfB;Seb zo6M{{jpYT!G?nwE7(28-`kZNhPlh)324=s5SAp#!k83SXto!su*^*Nx#ji4X#J}XF z_c%y5+ClX>drupTa=0F0$j5 zJA$eo7#q35f!RzuPE1=hTV6J@jd&6_I~ouX<_O)Ypmrr&rUW<25(eP&E)_l1rGiB) z7;uI&!*`L}^=F1;_Rm#CljVTb%*5T&@XHUURk#o9`$V9>-4NSiATLFxF6yug%QJHn zPYV_gsEk+Qigup^hZ3s{LB4Ze46(PSHJmVbbvr$i=6GaRHRPa~d~S0=?g1V8SC>*c zp?rEO7mc_}CXU*ILqVI&=|8;VPt5bi$;7?0ckIj<7OPr% zQr()0H`Mx~Srw#xwM?V`09? zNx98!O2>nu>R%U!BajuU-rFhjq5GdGzw!M4uhs6QgJ?ReahW++s|Le>nL?{hGs<-D zCDe*$1!rzfc24eYLBmSUGe=YtbZC7&w}E{ZRF2^6nqUZLyAoYO?$Hv>m4}g=zw27Cu!02-(M+kO04xuGAq11?39MjI{2lU9VYOb2T#Ej;^KzV*DYV;`={y{;*a zwYH7x`Wh*du&v4c{LZNu_Jf;D9~7QrO?FoHM%d2F&9LAE_AEo#JR>aF{fLvr?s+l3 zqyb$$Ri9+;!d`i>FrraG(TVZ8GZOWlwa)F$0@Wt_o)8N#w9Rqr7X(hrGjx_*E%?-D z`i=}wr8)n556Dh5z7I;icaX1g+YUv` zTzGibE=n*V#Ov%{8jdOpq0)h_CT5Qoe#wc1YZ#I8t1k6b!lJ^wS)Oj2XJf;T%tcT` zK^LO--&5m~I8(B>KK5v#`i&G1a8Je{jN`gCpN+K|$mp_R;wcBWRlpOkhaNgT;UB5m za}GGbMq!*UyDOiXBsIzHe>wNKs9gD@TDRw|*B+-q^YwFc$0BCz(RAPtd*}?yy`wSy zeXODNWq2O!wKUVtk22M5Qz_dsvWJtPk7C7{#jp37HiXkY)bjLZyT6O(+fm^2f-%W$ za6(u%j&MSl3K-~oGWmyl1vT#yhQNMkbVbvxzD=*dJtjvhcNpKnDJCwKTDG9{RUMZZ zAWhv!gKd4A_J+Bo&nmYGJP8oqr!*kKSvnBi{}Pg`6%p!pJA&=Nr~@}UP~+h98y1(^ zzLzptcYCeWhVWO4L|xfgShp;$9dsSp?az6cEF)v=pS_jdzZ#wG^pX_i01MV}0mojC z4@w?1#;z~ARl4;mF3-O zSuUP^E=C3%*qfF7fl+CLVTWJaT2IPI3fop4IuJ5_9=iNm6dNc*kdfDEebD;w$-@2se?H zQq67HQao+_GF$E7G^30D;da;v`(@bnu$`6N#W^#X>T0i~_wjQkC80uj>`(A}86~z4 z-t}Ne!$Uw1uM8&2!N`V66l<~UZ0qVAH0;1Tey!Fjp+ED9z8U7-5+ipv=MKjsxQ_F@ z@B9%T#hmLOh$xY{e_uAWF2y)lOxO-F36#aKYm&REbrhW(65x3L1uuk2t}GOSjz=95 za$w{(I>I}duNtA1f>meWQk@3Nm)pU>&I5fBGGN8+#FUm#-;SYIDlGNad}mK?eyI1kK4;-Y;Q51YVuKFdd8R8qXv^TP<@w#1 zk)TP*Wk}XYR_!qgeAeF`If5UT@ZWby(BDH$um%L~L?!=kY=8*a-yUf2-)*j=|C=Gr zpwDCw<~p8s^D@0P|7NJ87W8y6jAz>1j;eQ$?O+Zc5JDeocd{NLD=;}}(zOp2MJ~{P zQM=Cj^vP>;-lgFFd8Qal%!HrqvMn}aprdl9O8J88$3A}U4iV!)!8V~tIpS2EG(MFP=zR>6+yg>pPLrJUfH zDGP4*I8jVPOqB&^wKbw<%T=-4n{)PwW?P@UuAt83mFvmUQXjyARn8RY5`yd>dI-63 zzQ$T?A8M2_XQ6?tLyPi~Z(WFZan3$+Vm_DcM(5}Swe48E@ZM8OlU~1)`-jt*p7m#bBYVz$Av&Qc2+lsh`tVR zsFQGZI(|PR#f3))dsAd$Wu{}k4)g^X*xUVO3l_QO3iLdkgyggeyj3mvF{1(zYM7tG|a~DBTZ}Hy|HQaPc))BN7Js1*pf2uAaf@=xPU;nH3!3BU^7-0>-^gz}qj!U}MBGX{v z+Kg!x#0hp0S`He8T+i5-3U;y^bwdG9KL|@Z`VVk$vg;=&pzdq2X&#=%#T{L`_Gixl z5vK|MS6#>dLC>MN3nI+JZ<=4ZwGJL*TmOHO9K-OT|-|I2~v)sz%#j#WBA&f zUP!0u?<#2X;O((?^(eNs49>1k`q*ndlUP^5t7uo=eVuHVgvT8Iy$VDL9OT=Y+e%iF zyzhI6>`6L>_SG#4d7C7()9_JAOBkhqUY?rKCgF!lxMKY@w_|`ftJh^1%#mYd?0CG~ zxG*okL5@(73Pz-|S)nrB*7aUaQt{J*s`j^YJtq0yQmJ-zG@^8xhdl>Gj^f{GDuLWu zf*qRZV19U_6~i)^QQm7>7p7U5sxZlq3mF-zmFB)dX_ zpt`Jp2;G>75Zbb-TVQ|&$6+vkX2sVj4Q+-^7--_b%9$3md^;Q3Kjx|%bYXl5Jt-fv zGv(?z_SC2YvxD$eT-*22U~svZDdAQME#UcM4o!DP%dxl|oxg_NIV>#$)s`Syb&A8G z+^qC33l{dl+RbNz&(@ibrRL>bl&4!X-Rw5Nl9hu7n`N~M(LH+(t=!6ny6Ok}UeEB4 z9&$G}%xftTvF?a)l}V0h0RyrR&KCsG*;G~12x{kZyu#;hmVmY5zh)01DG|_iIDO_* zSMH$tyG!SjLH~1?XK*BrIcg>f*b{#6&C#cvnTQO@9;5b}%7dBrx(|l*m~wHyP|V!1 z7|i?G3v%k#S!{EBVP;q^^Xj$V=2a7MbGhn z(c{_1Vq(BPa6H}X0XZ(~=p9Im+{7ZLL#O?lX}-g^6|Kq&SF@b9#I`Iy@kl<)T;qEu zdiNy6F{YAlVzY2)CVa&Mdo0m_1DLJAmv_>j1L?jqPqq?JYIo2;yB%%Q@7*mKEL)@+ zR-07+!gR609^QPwzvv!s{9{2+T|K3FD$ry{D_Fpl8WTDgfkD|E3=hF~X8K}IKO;0c z@BJ)p#anGcPS#mB`rONRLV}ZiWN*rP9$(1SRP?B{c(pJ|hjG}y5)F>SR(Y>v(nK+4 ze8s}Ln=xXakXK?AgE*p+@eH@Zi|qlkp7Ikcn>yMY4QVP$$)SOVpavS0cHKGlAt%Pu zg4RuOFd*oRUBpbb( zxcc|NbqWM?zamU7dQ~2j?=`8gN1F6z9F5+8oew!VdGE{i5snH0nxIh6VS8ph4dXd>!U!ueGgsN}A&rSBc(oZ1r$|0>Q1#Z1+*uz|r_aCR{%*$D zdpfQSV0J)dVj0-{Y+w*wJnA6!L2T`^nwuDA^iI)X2Ro&wt`SkTpAWtKNCrt5UJHK6 z$PqYvZ=@suQ#;YLtcFGC&G7i(*aqa7$0E#S_gQv`wr1DcYJZ40fBya{-vkTwtA&)M zYK-o-Jt?s)?+BmhV}Q$m-Rw}oB6tzaj_1QMVA6wzSQyRAQ@*%*6+h$Q?l5YU@IQXF zYFdx{mf9!dVo4mw_&jg$@RlKu56vKBx7~F0@3?5_7VOOYinh5lGVl)Z(Ms(H*(vA- zzRmp>JUB?h4SASgYw@&UK(I*cts!@dni2&p&7ky(sd-V1VQE2V(?Zs%kFRc=laMdJ zaJEX8+JI}p!4GXU?b)X|W8^cO!HbpL-S)kS@p)2kO5c~<1s+$|xVixAr@Enn!n!rQ zO5@qU<+aFW!Lft87rT~^;|{$BR}1LiL@@n<8b_}~c1%yZBNA*!nk1A5d_O$)+-hOV>0Ql&}|V)4>qt5jKMMHHbkL;wjOk#8`Ix*!rGZGv#<9;_{e?nPs5I(>M_nr zUq2YqM7t?s^)a;rjkm50THNiX3id(uycjP&WNW5Lbz8EiG(0)~;d3&Edg-iJU-xC~ z0pC|h#lyN*G|UjTbpA0YR%dYhR_=9wR0h9Cn+PLHPROP}C_BPgWk*`+V@%JRo^JW8 z3WX)7nJ@3FQMmRDQ#wz%;;+ew<0)Brj2Cp1!Swog=BUrN$>KEU*-m|#GtZ2F6irv< z+mtK8hX_jG`u>@dd={#OIt}L4R+8|1V-U56I!^h$8?P1d)U*DCgR;E_4!dY zXYTFv_e5k)@}3fYBHdI1ZX%RJ4@8TQiT?ZWRUDQHDKcWyAIi%qDmi!z@OuP;lR{MiE*F##g=62+ z(&jk^((R%qmxtr3X@xhdB1PpzkBG`0Ie*Q;>8j=VYoe-(tH>S)d&l$kjsQ@A%O)QKVOcZ!L;*P^h9{j~j!{cF`>UC1P>6nGP}^nhrx{}>5*`*B?AkLVPHVRGs%=eberISY z)Ku4%fYv1jFB-VUoPixKJ&9nJ_^S-mNXq$D1|T}I4Aj&JWuPCn3+CRWCaR(er#Lc* zk|Tfdrpd!NGT73>e3uv);nZqc4#~d@7%8u?s_kFgk@83adnMS^9YkYX7Uv=sGNyx; z0p?lYU)U|=Ixf$cpQ1cgXdWS<>UNfasns~#Yg!J87?Y=Yo`YaJPw;Hf4A z^=dIqcreIO$g6_8dYHs0KNj#~1wXbtj|6rrR@gjFg5y_0Z19dncAY%C zZ1w zwQOM+N={f~frC!qSF1s)iXxF~?M6)$8FuC#7bs-;2cSx)EO5wEAc+bT^;ufr$Xih$4MN)Wq^c)Wj#eVUe%mw6JR% zf}W`MJP>QE6g;IWMpA0{n7AJ~A)i30TL~k|LU6OvV?}x(F{Uyqqi7~D;RE$iuo^L*}Mag_dn^f;lF3=9| zXFTtB!1m2JvtN66#6h2@xmR>+)30bpU1t{CRa?g_9nuk`*Ii_DfAn>3)3R_-pQ7o! zPM^x2pyKYLX+edAk_LuR*LM{Vb+~|h%cz&*4zpIU3#r57N#zZfFOl?eG$(m<17D~c zcB|~T9hh2g)`e0Z_dnv=_k?QlO5&uCLhV;`y#v!_HqC(-$IQZw({+0&v&0kK`)|eM zR_z_AnRoX(V8(lpa^i19Z`eux@-{+P%|eAH158?;-PuH#^K8 zjfCsK#yZBqdQXd4W_3}F_xJYa2#xta7?O?ES?WG*^od%@(bRaapst3xyRr-H|R>l;k0c`h+gSL8?jH+%6p_KYUrC@zBp(5;6Uc#{c=GE zCRW~=Z+SajlvA1+f>kZ9z2W3>9dhjm^?lUX*pw3`CU8?R+kI8^x0Xqq)g2|w@;fBe&0cT`p5u($aetWp>CgpBJj*N)0 z`C;d?sFvf4WE$+@`Og(F!4#pFs1Mw3G^10IJ^e8~9qGBU)E69kKb3s7UwvtInSNVdpyAs*X&+>zDq|OsiMh&2hM9y6}r=+Us9X;Q63Rr6GR^WgocMk<6DDk z7NzWgCSfYs?q_V5Z`@^-)V`@rO$G`7U^1TO>3McrqUR);l;RQW9K^WgVZXH!#7+;we!yv zri5TsIDK!jcqE?9jC5kJ6O}MYDR=YpwzqlySXIZtrpf&+xBcnF8wjRh$%*_q?)#qy zgrG}9nmpV-NfkD??sQu1E9-cWa``^wI_+c{aa8(C&Ju;{_v51;lOxx+9r$q9oqgoc z_4{DFe_Mxkq%q7IGR(_g1nJI=8$ zalRz{PCqAhht135Z6i)WuA5^GIZDCM<;|T4R zc~;yxMqcC=Gq>A7g!aAtiv}DIzsKWmZyIi}O63(43*NZ!^3!J{@q)c!Hx=8i`^i2S zQ-{vZle_CRJ^s4fu=7#UiB7N9LyBki@;!PD9JR{~^qjEpNSx__mpXNBXTm*q2FHbX z@ohc*yIr42YtPc`>v9+nSahtCYu=gi3f)_#!e`J^@p>hxo?a+>{Qm8i+c+Dvn*hXFyRTFXMZIUaF!tSO#*_WyGKO4*%u(byLL+P- zVmF)9h?t?arBYFau`la|VM90`VW@v6QL5QttQn%(9xql$e;jR@z8pXGiLy-tS7GLk zYnwF3z|1SIZtR|EFKw$0>M*-QaRDsVMl(67+UcK5>F!zRdHg`zl%lr~e2r_4SI@&I z$HW<7hBEODy$glEybI8k61@wBU*3f(Jb)GFWG1;Lde;<0k%|iN;rFzhF!!RkBJenP z#T4X4kqW;&4h1B5BRc2wa=Lbe7d4_2M*;qWaf2Z|x+tyyJSqMq@h#+v@a`q?Qt$>G zQA2K`pF*#xDXOXY%VSbdUUk(UE{h+Og3m9Dqw@G95H8Zdg$Xr4AjuWrDg%TJ@cHcg z>16Um3=tPM0j>n^i(PTmmt6C{6yY*K2{Gcp1wtyT5t1Qj>Wm$|3;7eH9{V0$R`*e3 z#`l`{b+U%cUvIyyq1|{VfZF#c`BuYkw<4_dg3fn6c$^KL%tn96g5wV8JNuh>TESWB z9g11k^sF59lsqzzV~v+1y`weXGc}Bu?yHw+i&xg&k^Cz3P)^RJmn}(l0xvEWKJJr+ zE-!~(m>&DAA6fh)Ci=b&Q?aW~hQY|Ukx3j|y#fQflcgzFHY(Tpfr=Mn%k5YvEso`Rd9d~DzP=j8N88&`rrSAXWo5q@Z|lE)d`7GNSo$`9$EdB-4zu`e zN9(>Rw327XUFFErdEFSZ*vWoVY1aDF-0Nk?ExPSxx0&)dIP@T)I{4d#TbBfD-dqV} zKFO5TNMH5Vo>>ai z3%&(O*x>kVQVq)!#BV%hWx^>ua@oY`ZY-u1v7chw5Jk%UE99rN3z8h{j#r&G=9Im?+i3@GIEoOJ(qx|TyOT6#%5870YH9Jq5lWBa}%gS-X?84xsp-W%H zqnw%+^oG;N93HYw3vsaWb(|DEc7`RZadAi0<|h!(;v4iM_EUTf{#qyIT%(Hih^lmL zyAYD};}etm8^1RN{>Z9?UCd*8B^4rLa@|+89=7=SpSq`ACoY|@_qpcSt0=eg#~kbF z)64eX3pmzR-Tb_r>c}1b20y6^N#Khduoq|(C-SiL*0E%r`pSkh~Hxyz_!QJWLq=s@YJyH z?QE?Jv~lz@s@^_z?gwLEJ;)cDew7_vAb}Adl9D#N9`jthE;eS4s%CI0Lgp)UrgxuK z>2d{K7h??V6SVDqzLAx5%fpxSuWS0drSBX!;WF5A1VSJ9WGvkESXsl>Ad$)aBc)gS ziK((JP6OIoR-Qk7?#qOI6A<6LnE5uW_IP}g%FQoZoGP`M%PvP!z8xI8dyibG{C1Vk zd@W;`KB3GS;3I}lqad#?p=b?!)%j)+t(Ut z{oW>h54z}-pY^9$x3cl9c=Anr>7tus^3iOByU*;PpG&;PoHd@Xc*Iub)UeVctD+cgEjrYd zN0YW+#%~95$p#*C;+C9yY2q>WIK#5#9|Lej(&%n&jHn17#)>NeD|kjoLLiI^~ z)m7sR{$EL^M8Z6#2m=a5;)qZz&(9RGRTP#qK`_B)owS=_Zc_wX-uIgbFv`rj3Fb^N zPr!(r^*(}`ia6y0cbXtX2q4RDZ3jyyJ22rRDrYE)M3H(AeNj{JvO0W2qE?6fNEFdX z(<4!hX+L6Gw?<3G`^hhNVtVDuaR$*lltW1K%Lx|KWBv^$6UtNVs6G}+ZWD;Dl z3agQ*)kxIrp#&xh@~g0dJPAI#3X{g-3SeG>FeZmHS7A~oszB-hVTv#!6GajeMH2Oq zRhT5D!cQsVS7DNr3O}WsU4=j9BBNlJyEQiiU=BqCL}2pe@c0T2%|_+qJBylzY3G2ME#U< zb`>T`iTWv})*}R(h2Bhby*~ms7a?SU!_xE!AqHcE5xnr}G6X9C_#y!i_XzkR^6*8% zbMpW~9KPRypynh^NE%v+{%Q#*czYy5o#6OH0a8B#ed8Mh4FL3`5TbBPB#27_!U=#_ z6aaQ5AQ%C~Cqk4|;(jWIlUxir z5)+$$ieQG%t^@QELF(C0*Tg3>P=gnqu1b-J0Ln;QxCH5C!vYyCca|)o0>1-5g6-1GG7u0IU zLXRSKEU49vMHCK7L5Px@!G6ggf1o}y@cmp~AT&n>(&V$gr~+7#=p@oSL(uZBT5q{O zNl1%*m1^_bBL9!$RM5cSA9t!0PhIc>*bng2MIts0U#wBO#l$Dh}^~r&+83EC>MxKlM9q|oq&=u1ez`_S9zYQ=cP^1>R32h8Op}M;gROFj*&#+T&jV4ie^F?{gLA|{WC7sElMe#z@`%xi zz(oSkl?M_aB}+tK6VNUBYh?7VLGGOMLGDQTCz2Tx(As>W5JVv5HHgRedQAre6m$tE zUK7nHctgN>gy3~tKu!F<8cZ-&6rf${77!~T;T4uE1T}*#otP6M;7I^x3x3MW0XGl; zbm1C>a1nw9bP>(vYmh?`p!rz%Q`;PrgvB+{qSZcxkPJsLf(>SyUsD4yt@8x5rD%;$ z9s!UjUK4;wMpXh(=i)UoV*>D|crA~K1c19_jbecSTr63m&?yBu%qbz}@Jbni3w~4z zbW2l8Y~w^=v=r#psFcWq2&k0-Af0|<;j2i{di z)Hv}?1(qydGYmvX6?R$&9j-ti2n)Yc#M}b{P6`Ein5zOrA{FZ@hJ>vuezN6+4Jv_v zl@)6mUL{h5DJy?V4KPE&hY8p^W~{)YT<1Iz=CW2RHGv5nTm@KDu6k5NpqBu|H_@`g z1Mvs}V0DP7Ts1&r);R~OIZ%W9tB4hW_^K!mBddQ;oJA4&TjFp?+*&&za(1WzoQJD_ z=>sk%0P9^FA{k39K%tsHQ~-vgr8U2&2xkx|MQYayju?N1fRa2%BB1{U0Hp*nQsX6} zPu_r1XM6+lNHRo3^eX{1dGoV^f%VO|AadH9Khp3gpi6Ivwd~DXP+JbXC5pve2c9F} z67h?K=ZRH3m+-u!j)>FNgXh3HBE1pexqB5)sRzwox}I2-=Lyd@>WSId`wq~&t0&S& z5T5zp5&4V}o-e&4;>r!+xnLFdB|I}W5b<`xvq=MyUaAp1r#7sq!Ic0ktp>hp1d7<- zNbG#dT0vb^mWS^*5(}L03VSvIfkUd-)UZmV0Hd1L>Lrm<^gW;ig-YlTSBX&Yhd+tR z2t282dK3IAf;tZF~oR21azK&l3Ex*CE&fSzZe07;M+=UxdeJ(zT4Nm zslpN;eiMg_MQwfaP(-fFHQ3}L4_E!9XIvY<0a(5Z!4FrrAp{88m*H6-wz^oaFNy@a zT+X$T+Q#O8Qz-tHIM`hR3TOjnC|IH$p|Sy=-wX_xnkS4vH{hcTm$d`OjW8tLfI74KSXV?QmSnI#S_+auLz?0Bv|HYFJb|8dhbZ?|w zu&0KlXCu{tjW+H*B+dL&cM9@gLgDx5YNTmBFfsvszSu|Lj;~du^??eo!^hteULU9k zmv0;s1+(=2ovZcHmEbeI2$gl(P*Z_-^?_0=?j@Dl2ANWUSN@7PK!|vhwyixIL!SCm}!Gygv=j- z7M~CraKYbr+b6^}c=IPxwb&>#Dsa#zlFBwBQH5tdZ7hu%yzBFZp}>0z{!A*+KeFvK z0Q1bBmF;YG^V1PoxB40LUpa9!{*|4gj zfFb(=*b_X%jo71L)~^Wl4I~9q((5HvCHguWaZ-j~0Z!{H0x%d+mPwr zgg`Uka_|kyFMStwHYZTBz$1vcZIDFRe+-=NX7dH<6Dy; zH@FE>RyX2~f_F~-qr}!Hp#%pKNdDCNdJ<*0e-fd-&Ws_~hpPOhb@)s=V%tX9QH4FH zelP!ZlXFUHaO)I8MRHv=UKgq?|64MkyWRZ+A+jm?4$?MtS>9 z@gQ#muYIGu{bmGEuo&>wH_Db8JVQwNKjd5m%nZ(Mm~#~*96bAbBV8w#%J0et9Svb- zgJ8cl$d8I5jGQBBVj~h1e0Of+{3yY#Aan!GfTgE(MgwSOo`Zw1zR9yuS}O3BZ~udO zRN*oL$)8PXy%yA9mhZnesdb^MztwdV-28zwVBR24sz~_dAKXZD`EtMoQ2V1^UTGtl zs`cE~(<{M;=YKbiU>-ny0oWUbcEr~8wQ+r{-)rOQAZ5MH0z7&NJdQ0Ow*G-{#{xWI z;djdl=ph0=zX0k7L6v_*YQO@^s*Od+{(*}w0vyKv!yE&S4vT=}2$odi{)kyZ2vl1n znbbc5*_J?{@6W)0s}3x*!fzMxXg62);Z|5Kapgfxwoq3t(1GN=QNt`~%y-_+d98 z2^%n$k2RA}&l4Ax*g*kaW?UuY;UH0oWRBHw(b^6%l#)uGo|r@uH~d*hLId8aD)9}> zK!~bIXi?-%M|8MpWy$hyZQAS4 zx%>Wr_!03Oj!bS(_MGhb>U*D#Ujdf~srgMly0_WXJl?*Lqvz4qx{%E`QN8!n4-GYb zd6X~N`gY&4qhO4L;@h{`Vfm8Hk2!A5^%|4W);$>hmT_y-2UGjv>kHSX(}rdet~X}7 zIs3oeUNpRGNS*@{85iLlW|_<%&yQb7G(Xkf?c}mRUMx3!b3*j0KVIkjT6ktshgAejLwMx^9k1({RvSxKeGk zDCObi!y=?ETaY@+bxKZHb4S$e(blma+A;M@pD!+CE}xKicf2S8b zEJE2k!Bbi|oRP=#9WQbr;s;Of%5eWqu`%q-bXZg4@~8l#!P{W6_Q0FpSf;{6BAYDV zVPZyBRDPUx*`N9#k`p#p_6Tyfn*1g>YT;{*`z{z!=`0v+Ir8RsjZwzM>rp2-zE^l% z=WXYszNVF=n^k`a%6v+lryuq7%F&p(wiC9Tcc?;2uPD%DWolheJAdI_)hwA!Fpi1A zY9*n$!!JHgO$D8G!Gs~^Q4+o}?ltvSi~2K5WCH%LN4{E*1wB3*C{SVLoXT_mVXQ&^ z+`71eFfuh|4OW;JCt?eB=Q5Rq5z z-|X&yH^x;i29F4cftxs&4qcFKJ$n=5fasAj-YYj-n{R3#hVt+(d}(rF=NDI-YKNvD z`+pz64r}DS@O?qk?s6(N*aW=0<)kw`{vGD4z-kgUw4 zVU=uURYsYGQpW$>zJ0&BpU>}k{yooq;`N?$-t(UKyz89v-j4+-y{g%#olg6byh`Kw zGcEBy2;KK7YqL-7$nkK)vcLm|UR!&T4&SILTsX_5A`&?nemZN^BTBaOSx28Lw^e8N zDXpX%wFcy2!$-=eJ)c>>O6K8?+=z|;EN`mNRLu7=>F`^Qk~5@qhlIOhJ$PuL$o%H+E}>vWrX&WoY3}DX%zTo3U8eq2 zcg}7rLaR~Sq2%rT)>^MRj}9JB!?Lx0(4Bp7Ln@ua4lm5U=dk%sT-4@0_hm26Dsi~D zmk-xllu$HH=k-G~S5ALy5B8VU-Xu*m<#!C`6J8j`$lsgT&$UowExFGC7RYgM+kEKa zT1=+kVOJ&JXS*nDZ_Pe9-=I`)a5;#Il8x zA;==8@GADPlFPkAXUxjD1JdAgWh@E%3sntpjEmaXvSY`Jc?ZR8yae}4v~@)^B)g}t zQPr*9|rQv*4rpEPyITMO}m)5iDVxRN7Z>AidNbWqsIj77L z#gk|0{&SdY(NOJLmhfxXw#@j)Fqent!f>QV&TLcSsOhI9{M(`)h^pBMpR6o3FY^r; zxY!J*tvPUCx3IoAw&(}ZvcvY2-Pdr9v6dUNwzAQm<07I@Y4!ECvS;LuX7Z(U&$b6@ zu=unGYn0tfi_Ud~ldG`LAO8BxO1H2w)y`1nl!MmgxP$C@ ziRatXLsG3agma`jH}D~_`J{PIg{3rddexW*MSlXB0lE}g^PUSxmC z>nqO(BwZ@DFtiPbUihz~XY^G28e6W5%qI?sheTLlA&uq@RhUPJ;{B@(r9bG@i>~0IQ zs+~|73pqxR;b<}n>l4Ysq?PKMQ zUD%C>9o$Yon6VhPwpH3_Z7W_AX?0q?#=wRNd}P2UZt957Q+U^-tT(mYSBx9KZ;i#B zcHXNeSspS#j&j~ae$%7tmQfTnu*Y%R#0HjQ$@7_egXc5rxh8a?#F_CC^#_8}>lHyi z!r)U}?Z}p+&Zu&FLeS^Xmlo#ZZ9QA2Gdj)(2^aCOJ-Op)FrbyAIdH~xZ(x|#K~9^Y z@IJAf-{e$Ns^Ld$zR%q{GIX%d=VWW%$y+%DTTRwB1tcEXA>%uAh2J-Bl6)6pUvzo! zHe2OfbRQ_w>ME7YCmg7>{j=b z4oLfTGPJZ?J5uK$_OeGf_e-zg*uE2*vFrM`lIoJ&mfRyH>*v%ewWrlDyT(%(Y~oW- zh_8LSsQ>P!in!giAM<)m56mC+noz2uO|<;BeHl40Of?R7tnz1c6N#&5cG^)h{q6cM zw$8!_@SdJV-^{Xwq=K6-Ta7!O!jTm`*wA$k5A;1NdsduUwC{QTFvgKu7TBWM_>`En zuiS)xGOvz^X|i_VuOGdzq!8>7ZK(ke|C(4V}sTM5haDfLGPL8&AJBT zawiWM*S|7haX(QOdnEt#V=j@7v@5rE8+tZA{cOTCywT+F>7d8YIG5mV$0n1Gfy2o>>1#&u=52-0qCKejw@m=|1aay91}1{Yod_ z`AkyJ=D{Wc4JR%q9F6^M)#taM@a^D8)zPbm*USGfu8FE$G5AqYyDoxD%klEO@Tq6d zo+(^z5vp>1@3ds!d#X2jJjd(a^-s9(i(##jnN?wm3A-)^I7Q~znHe~iQd2l@mQu~Q z0^VKs9-mH_6W<$mD3$z5-#>Z7S*6mo4f1x4_)i=3v*CwVlb?R92;H6?KC9N_c1}=2eR3n12Z08%ppPZt%ll{US<}ww(j)9c6ATV+$oZcyG0P#tzuw} zOVZ~d%Gqg7!0Eena^~84M>XDE^s;{;=62`jBl!!V*rtN46j|*{Z`rNf)rIo%p671L zt$cXy8z(DIc>hNQZ}WF$pN7QqFZVvSjEpiSf8*+S_r%aX-}{KsL}hu5o|cU2!C(9R z^h?`~kGm8mR0T|bow^ovq|zkpc~{RwZmQykj@e}ETxYk9a0GWp{ZFwkxq0;;EZ^T= zIs2v1?D6(;=Wp&aKireP3kh9MzE*T?IGB-IbUU+alYD^ZebTh4X;nt;=bv{LgDwUV zsUx4%Cj8ZiKg;m2N&fqxM)~@+ER@ZgCCucbw|cGO)ncd_!fHvhI?KTLeD z2gc><{La)jM{qpw^AnXlMQuMsEX6s6KYsjp?5v{k{h}`0yVE^PUPZ@=_dN?1e^vdU zQX5?2D;jSmrzYg~i}gGl{IxyaKdkLSx|5u~^~&z9$F=k3s_xxa>#Y(uh`fm1)z=ic z_pwFVi6Mi83Gx#b<(q7q9-fV-3JNRT2d`MJwZ=-@=NIa{%#Vl9O%QjpH13AmZj^bo ziXZXpdK|j5}#lTNhQ&F4Of> zen2+L%6-#hj`cN?H=Og{=5a}Ze$8EuIq&$ZsuvhvdmaDvH9<*5GBLncZ&D$2<{F-L zUGsxT;mX+ZPh;J&6PDe@QwqIdlNPZSHAFCC_O{Is}*1ub6K^5F8TN3M(-#WQxt zGjmE9^W*K>l}~Sp9OmkDE|QIZY}Bq+=gwo^Q!>*E2YrlM5$*1I_k>uW zQ&uT%y1e)p-(X)>ZlAEFGvxTRAbG};DSP|HV8y%kg5q1rmEM}dhSya?YD&cK;r1^s zURY2avQZW|a_98s_?nmnsLoPn`HESccZJ7N&4`50=bG^)cN3%jn-Ws~{wpV#9v}BT z-S|+=>&W=23tc$Bdx@~PicV+CA~g(7N~bP~&AxiDt3R6O)#$xPL-xLPU-HZ@Z?-;D zeUHbYb}JMr#?q46!b!Q9J1mkfaL~+{VNrQY(%!|Uz%#~@JUixZG}#qxd=Z{=k7!lI zNwhni?ldTf4=4sN?bS@?*``xgZ;xThCehhMsDsq-bV}aQehlC0E9_nADpYUmsAK z8NC=&yk|DtdLsC3<@-!*d&ZFTdq~ak!85z?3Z8J2!A=bg~?w(i7KDj?OrmK z>iC5v*0o~u>*OE%=XR*1v8TAzuJavaP3o$W?EGq#=W>kGTxuJO7-bumkS_ZG8}y>| zjO@f(&jXz&Yle1~t&pmx6FD9G)?V??nj|D0v&ngX)KMneDuIWqZ~2Wpd_ zn|~ZpXVBlV(_zHQOgqJ}ebga0dBcY5W0-)U@PI_4bpg1-v;((}1fL3z<1$uic#3=Q zAt7INgPPs}-O&dn|lGr_i`jsvx&kz_XnCa&kt}D|$sm z{mkUD`@tu(zrvbR{Mhz(*TVg`Rk*&Ho;8~0-KUqGZ!f`V(L{V{QYqWeMJ1gt*;uoW zGQh=RyJw5t+|emZ4e!aXzlNx;A9~Nv7k<3`@%GK-_|dpypBA+Z6;{?S>?8VlnHOqa zRuXct)$+(VSh6SVOUiW(i@ia*!B1X)J_6p3oodPE)D9|$&gI+L#ej8_gqh`Jc&?t@ z_e}6OqpcG!Tib?Hh9|X7v9B*QmFJKeyY1Q4&EC>+$)F>y)wOk`)0?$?o`)q%xA~@u zNvB3@Kkw%WZqHfaJuhnZ_@_D)_`aA}>3HEFM^<~M_2_%ZyypP^&3Sn(KVImSkdN4| z^1fX@KLm#o%S^P30Y2U12KJEQC&VSYI# zH8a~HF;-eGQYYWESSC$;?h|K!AhXtK{6|C3PCxRyKHg!;i_uZhlAO@}7yWTN&OlrG zH%N?q-Y6^KX3Qf&mV&{Hard3=Hl??wZ3+gl>jlX{hI`J|hSv1H(}*?W%7(XX7QVj| zW6vLBp1>u{dUpMAe}8vBJn}gp+g;@&0V?wLEP{w5<}cJM^3qni>gw{Bcl4Uy=TfxP zFv!_|_)%)d`}oC5k;lg<hIg_GpKmt(Dti*9nY=SD_wo)!+3hTPHA8P zbNG7WjkYllog~_IU%m2V$+38C0y{o_m}zX-I{4^S<;@Ob4yUx+r&2yOxb*E;c!04z z!eeqxnyjIisuZRk$;hzazyql&C%HMroynSL#)gd7Vs&Or)fvv3sLg3h*_;S_`|^6> zE0a%KpC90H%(Mm?CB1X& zo_~09D<>yOI9VhA=P}Ec)S8o%30_k#WF;J&r}k{PC?SYDf6Q03+=<bbwe6(Vw8Gku@Exu@^i}2J86?Zg>z(d?HB=t=7(ErjbQjBrEyBMp z_3L-bzF}tg@WJx*Ag3s#`@Hj`_<$i?T0UX>Rj2ZMrrhDHuv2_b^*2+apy9(B85fhd zz1+-xuz5uKZuum$QHt%Ot9D|y)4q=)n?o3G*yCQZt1-M3E!bc{+-&rk!TXC|&4`p9 zV}OTn_>(2|c#$7t5^Mskiv(!tjE~$bqxGyZ*XP5PveBX*oxYz>>!mR&k3M>7yBA(s zQ?n;vr+|RkOLqG)pAl(s*AGg|m71(;KbtOS*`$@;fBrb#)92tF`zeE88+)UFF{Lam zr39T{-c_Jd=9gI9M=`a$eZly`5!aQ$m>3zRYDq3$*Hyhw#v*RQr~ z(#`lo>HNupZvr<3`Aa;wHNCTLQLouxRuWdOju>({`5E`KzpR^1pMnfR4fy7&EOq<$wjPSVvR`bcmXBzi|D6>3vx%GQ z(ERrJp-O_YkAO1RRR5-Q+?(69865%3|yn=VNw|=NG2nfI)yE)ACNm{5QZ)Cfn z8Z4V%>rl;(6Y$xc!de`u^eSL?aF@XvhUs<9H{tm&uFDhfau~cO)Z71iw$4D_x8$Yu zeoL1qrYoVGoRp0pW%B~L<;U4R;d~cDrMX#a&xu`@cG+lgGHW_QTK+we%DI-0nU#6u zzVMpb;>25b6oi^JA7%F#6_KACMqPNbi%5XCEbXlCh>Uz2waod<###G|Zim;C#rR%} z{@M7?E)HeYJ4A9FuMi&v@Q21^JRWWi5`9wA6s>n-#pSh)&9>a??1=EIFY~@=VLw8L3_bCaH62GP!aZF5G zI>o+ldgUH_vte98gsegG^540#WK8I34^{EvJ#&Qm=)iRLjR_93^;$Z<`>v@`&+OG< z=OuE$%2$&xhfU<0&C)BoXAY}*=mseYirPKf3&%I}KQFL&BN-hPyRx-Hz(PPYW@_qH zUHz8Bm6q#pa+p0%kvx0Kq}WBh1kIUiVA9l zKBi_zNomZ8$_2hRAJ?_U?9mNdc)Ccvy29j(*;GIkQ!A5gOwQS}fk{{`dIR?>0frAJ zA3qd?eX>2pB8seXdDgSH?t3bwb2Z^rM1qL0-On51LidEXdT@GXK5<zWMc>nu=zU%}Qg&a6_Vr)l&XJ^co_LHC5PaY}vt<~md z;g(VQ)R4?`|Ec+LvXGc*@##nJj+`z^xS??QVu%8CwaLvG2Zu=36cSm#@US}Xs~&%6 z8!u~+oFGA}zV>axfS{g-+jI|~D65Y>5lFow-S#ry$S}M^Cq_RoL+sEdZp!}8W_W&n zVIHwNaLysI%GBh+0N2#TA>o4>{l+r=DIo!V>sO8rh#PL}g>PTCS$DzmW5c+|#fWcu zsj3;wiJMQ^pXRWsb9l-nUMBs*{Q9o#F+`^9{UbRk-4CXlJo@@#FI~2s)H6$+ClnEG z?wAr;Cp6wypA-u*2Ia1y#^Z{Fq+^j2eA+;dDsXRGlOmhptED(1{j-Zkd*@J^oFmqO;i*nQnDdHK80 zJ-HM^&lJPL6ALK;%Aq?p6A24Fy0RhpU7sX@DQv!6@0t$txhg?U6Q_|D4}r$9VV^+&op(W*ow(JU}#Vzku6ZKTNYF*R56q-6uZY=5;ZYWgL6MvJwe{bT9 zCd2h|IZz-&hL^l1jN|Uo$JD{QRwGs)yI$O7wVW>UReP+a7wnuaWSQ6I^;TiW`BtIu zFxJO{Li+RbE-vb&#m=Su{N>Q7>*(i+ijmY?>z+a$&5|QRUV;lIj$JoH?5S9P@}|U> z{C6VWc#6j-9(KYtZLhI% zu$=ndwl^FbJn(WUOLO@-|L~ zIcu22tBlTk8}W}_mJTiSn)kdLA<3#<;hvSEIY>%^H%J#IVFn`Nn4>q?1n%AxIGFzE zz;(=rGX$4?4bD99r-Avl4{{{$h>{JPtzW!h2p!7S{5%@by!`bAZe^{ub74`~4IkxO z7Y``jH#*C~Ii~AF9#PRfa#q7(blrC~J%fF-IQG(|x}4iiI@?v=HC(Mbn^r&eQV2R= z+@4_6K6D+vGpm+9UM!TaY2ap5>!N<4iD%C8yLd(0`^mXZ(tA9?OHoK~yrk{jN0$eV zrS?|S2GYjyE7hsFZWW*Jsm#VtilnGnCH9%S`3E>wS2&r(6{UOhzPKopFgFlqzYMjK z#yjRy{jx{zx59QAve_b@*wea~vG<9PO5}2%o5^rXwx;b`@Za#BuWQtRW*Z>I$XA4C-WTHpJ1mG+r2deq8fjGR`eej zj`B-&`jQ*nvGA+xInVrLuTb|01uw0&1&oeid#}>_}`F zMT1xTI`8T;tn8HTdXiu6SVV+vN?f-s32u1qr@cR7=MIjpb&L zQk!^{Uxy5y$$_m-g*V>%a3)Ik;LRs%PYv>|H*qwOzsDG!A69rZCX=hqdCS-sAz)Tb zJayS1g(rIN+kAyorqI;#@1;b?RK4Z>KXt#xW~qOTa%Izaq&a_Lde@1nvY~<4OSY~o z8=KY>LT-hOZ~FO^TpW6jBT2AUIDb5N+Ed2yWf>=j+e&sx4$POd;99{w6_Cia7uSA! zp2N?T)s^v;teiuFDc{jiLDf(tP1}Q^?iMYrg3zI2?1wj-Q%B;$4yX(K=q((!=d#dG zY0%7xs>x89mfW0|5dBMLTgZ#<%RJ8B5Z48@+K5+@6;o%Z_x(Bty7C#Eq*YlKxq@y! z^XYVI7tHj{xUmb)Xz~5R;Bj(Mf@jA&HoFFoaNCPo^G1g*ey2v596FVivA%${u|3wu zDP}?t_Bbmw?VYzn?Q!0=;8@0M{C6H02v91|nqAmt32}=@TzS7?p?Tj=3d=Ne!KSY5 zgB14dCkC%>mzNs6du!7LB?;5ZL;Z&Q8S_V9bV={mnyFU2pKb^1w3P@S;h8kMaJua0 zCJVt5SvaPixFEjWV2rW$;gKClgH?6q7tb~F8wcGv)WiI1r;ze#u*GP66FyU^GB@@tE%Jr>lAV=IpNW%Q&I9f# z#jO5g<26ZH@-nL6wb;~w>cHbqgi_B{C zO!U9(T49oeGjpk`78n=mBn0lC^jKhc8L3;;Fsf8BD@-PJMGYfcJZy!r;iKNPz_4-~ zxgK#jW#ejlQi?#rQ=8K<5>!8T%vNfv6^5_4*d3$FK>cC~8gKB#WU>8Ik4Qs<+UJ20 zDt_;YNn+yv^Tu{{Kh&mlj3o8mIgA(ssrdOhj64Il83QiBd_v!SAwk7sewcnHYU4$W z8ueokhL0)|hFK>_`coZsb+;56d0VO0ff!-xh#!W(I5H3;%fLti_oiu=Ye3^X{(o)_ zt-4$TVT-eZF|y3m_1R#6wP6@(aQz6}%%K!phhgLxsJ?+1F6yE$W;3-s9P^MG7mV3b z9D51VBuwqf;NhdDK^Qq|VmfA%z@KZas|bP!G=la}118P(0`=U%9Ast$6-^a#FsjT4 zZ9H75);XA94sZ_&!ji%59z1n22Lo!g{{5Xd7bD6DQK-tf7;zyIpqBPe%0-F;{0Hty zW69ty{fS(REDV7gSF8Sp#o}?li<+#00elLH_ClTxMj_#7rC3+Nz>lg}1RelU@U%*| zba>#R9`2vE@V`WXfk~=FuwZnEfEWm@Jw1$s1I4aZ!*HN51_etGqxDB1(odWQBhuc> z|LqS3KjKLct)}Q-;BOe2b{B0G3~+%=Q3ZFz5HRHI03Jj}a~#6s>H7oYl8IP(^z10K zsyM3#p&^oR5H;o@MtU~^)LbQkduVijCjhR~%?kV#P(eWUO~4W;pcnz@2PUZE!Ahh0 zAubQiuVJM5NsVUqv8* z5h=7n%?KEQR{w<_2AEDi29^j@$e=wPA`oGSf}Al4>{cXO!LS4*TLB&(A#+#&BO=8D zz#uG;b=_4I(h$i=;RG-WgrJ&)RR#N-j%dII>WQZqei%%Lg8CCEq=RA*j!Y}3vub$I z1rBU;Iv5}c2Xqt?2JU&%x254xa9}>a<>`-l13)|$u`)CmcpyaAAsAQ{qTMK^Cq@+q z(N6@lB|x7fG64r6B^LKq3;cMAE;T4s=960~{#YLqv)PiHHTSj)mx$?8UmIL5G}hJNQMBf z5sfLpDuY_v|0rl29#5p{?(-Le-0baktq9v2X=~HazSGXfmSkX z)j9zf8Hex~Xm})5bX%m#WyRrvX$Cj_+Z!;OVLZa-17%DkBQ_`vMj;?q7Hn>WN&g-@ z5Fs=dNM!KR2RR63m38>X=_Z2pLJI2htNWuc?Lc=IX0)Y`j$rcHC8}zaO z<3NCho(TzPRHRe`7$SzDwZ##UOGg3*1j#`XsIQ44kfu*zqqnl zgF!&3j~axCh{9>a051-O2NCJE6Ie;`UI=9$K~RsfkF-FaNbeDV#=t2=5d)0ezwG*t zTXXevqF-&5i(^SyW@R!2(7R z;YHH=qoC9>2t!s0q3AzW3~VrzHUr^JIfwyCL?BSe@rhvPqhPBqHrmAp!;@9tETKO=vCx2?o9i8WE&{P>m@le-K1u=v@k|2dY0v6+IE?cj?~W z{Qu8GBSCodOn@taVn4JhEAz)6CxI9WJunG95n2@ScXG7#2W)^qPUkQ14~&AgDZn>D z?NTzZn8>!kH$me8cOHcYgIp2{h)1QDNWeEi;}Owz3-~5zy#Ha*LAvob9+>}M_LIr~ zV^z{ygJQ61_>9GPYRl%j2;9$ITSJfBfAI04P{s%@D&*7 zws;6e%@_pWXhaYOBWI@y^eq~O+MXawkBU!#9{e}W{ntSTN(zyK0h|G;1f(VeFqB1y zaG)`g7?5*7!;pR|aFC(@;h{k|w50|b6BQ-^FBRp7fIK^`)HmHO0WdOZBH%eb3F#n1 zAPs>={9W0dzC%17%^6ez9ZU{Hn@G({)AIjvhW%fc527-Z0e1jo$56Hc!b2#d2Z2mD znobJJ=z$z8$~6Tr^lA}QX@LQ~I{^5-+ExGtfC&M$gqjd2#QfK!|6|c1@Fi?$Jdi#` zt_;Zi|CRSY?f^u9{>LC>v>pMc1Sl>Lfi6G}LR9@9Fpx$@;=x3;9|95aXyr#Fpqw3u z2ts^hheQ%8f&h9Ly-XmQrzu1l|NpZ`AlraSegP|w%B4U+6QH;NJTH`L0iG9%GbFmG zf;K)_W8mJSI|OTjoE`AIP&*A|h0tx0XN9ye(2f^Gg2^bu1+voUwF7nmX$V158m$U| zvO+0IpfgZJgBAJRADI7N_5%?{ttSu$l+XbFqqH-i8pUb|kci|UM8A!}_<%JK1!ejn zG8W~H0T|k*fa4j|wgWm6MIbosLlH$!~W6=oA#Y zKxPufE|3mJ4-9-c6fw~MmfV?ml7K@2#L zK}C4r^y=R%!e4g)08xerWFb+G4H+0wG|^~F4WdYt1ttP}Mb9nZ4uDknf7bC|<46R- z7gCUb+l!I~aLS8PK0slj0(<~NZBG!=LaIo20G!^U%q+;uqqZlAxl!8_z);)s_o*my zNmRiZ=pWdB`7$#2-dB3ofT$VlCL|X?79Hh<0~m@JU`bF}Lox^*5SkE#16Z&E2nxZj zLrFE|f2d_}_>JrkeCrCx4b%4rU}U;e0-!&@6%CNJMdZr>5Ou@=97u3E2GJqy01Y98 z;I78+Z|_;HXaI~Z_yvCh0~3MF{oCK4d>H|RugIhaKqMep4bGt{NMiyVDIC&U2gxl| z))2r@nMuMfa4QGtB7p-m3Q}{?9J5tfl;4^Vd}R&DPEt$XV#H_{lvE5|T|Gd(B_LX~ z@8i~Sv2&IB?X>*6G{lAeeapMbMMSIXYQSubIzPUUdhkjo9wmMUVE*zpJ%NdG4`BcvLK1! zDQusH>a%ma0NJ`ahp{kT{CXlYkG9I{YevnI7Z2G5{M#(@6>ZPD3+Mdko`fBBiE-4a zl;1DboNKtGtuxQO6L`)z;Z9<0pH_2Ole)@_F58ECF)BvImjrU$BxC*Fek{3~>T6|E z64ThvmF{>WIC9hXl8>vp_2G(&y1Bta!{x<{VK*Imt6=l23F4?yD#R+LU99Q$5OrK_h{jf@ zMs#Wx-k($zPOOd~p7|c^%;Rk0v-f1>yXq0C^^H1));9ZN$;uZ;uV{^C%1id*-_e?z zEoQ%86z#hN;Ey!B+VeL}C}u}s z>d`GhedHtkLqdi&9UhW3BZY5}-EX(C&%*Dq8P7*``dtYh(zbRwW7UM}IX{s$RRq7o z@|fM}sD0IA-d^PN{@JUN>75Fs({GOp4%A)NlDL5~SIE&C`8?~_w3gL-|FDdqSJ4ln z;qukn0-UvV?QRYCxZ_-QhG8NnL$12LI21eZ$*H;R{-ORhEpz=Nux|=Y1xIy7)yVd$qV<(lhkVX0yltx;5&e+k4@Qw?2oz z(JH(gE>^l%oAG^MpY`(Tr8PQ4Rmy`8h5K>hNEl^bBxN51`275r5qy}yhZ%hKms9q! z@L2##N=nCcbl80T?zuR5u>~?DU$^r)smh1kL2GboU0YY;#ePjW%$$`|EMeo*@o~9dCe4W$O8D2lDhUvB2i#tiXHb6=^lql-+gQYUR zcr!hFS)^U(4e&jw0lT&rxXq$Tc0_FRraQa>fSt$t4dB3KH1?HiZ`kVg;Q`%_DxKh& zkC7sKQ({5uAuD4xIW|HRII26P8;_2S%$lWX|k6#pik@mZx+ztV~CGEBG=R zxT(PY0WDoo{f5KSt;*5U;s*60%^iC7srOM0X+5(5!X!*C# zzD;dEO%jC-yFVeP4$-rHhD{zG6c%MN1-_jZ+>KjFw(4R@Alc0Ll^igDwF%@#ZIX`3 z*Qm85s+Xys(>Kq)X?$<+HIt-E5NlVk9s@&neCNz0o6w~UAE)e=*dIkpX>zLp2QD~T z!o?0V4KGMn980)kE{bDR<|QGMl@7^=w4T>_E>bXKcHDAKwR+#02vy@vF5wNQnk{gT zYmsb`gnoZRNbLvhO7k@1NfD`sio{;ne)CzQ@Y1oKsF&=~ z*fz)UbCribChP=%l#dYQ)nxK)DpUy*4R$X*k;Otetb8L&H`{hHY z_M06_t7F8Yt@j_Ey8lhHT8c``w0Si$3_w&EZwJ)ZeDi0xH0GtD4HeaBx4oDzB%){P zi;xZFOOa=bdAdfxPNyc+yA>&)yHCXMhl*6kgp&1L%#tVkA9qcVim?E;-OaYTqNpQ z8B@HnlI?*=eU3GE&QWixeIZb3hRx(_eDSdj%MkBGF7D)urc#~{!ru*Z)P89c);+X# z+{bG@hduvWdHx8`TWuV|K(ZByP3W;mL%{#Zv$HbSSri!wT_4=2%Sz1;)J<`RcJ_;@8k{>W(IUgP|N8Wm z%N}=9O?&FMoII|0qdqGsABcXv!}GLp?9c_d&PtcN__TKez_%z)((zIZ9{6<#%FiiJ+b`95V>VYEz{QE;j z@E-dfKOY2CG@O!;Nqs%{zVCojp{BW@@$;Uyx}}a&>yYSo7c%p8d zxT6O>DcHw5c<|W1fGmkKy&f{Qk6*gE%~_NKJ*hE@{l+?77P{$&QwQpy`^B|``43ij zYTw{H6ufXqw7n+Ew|>j~+~6EiKWkK&e3V0mRLR8j!N#Aiy(yr4=7=!x$u25_H1HUf zaq@OnXxv01nJdX<=j`)vCAPIzDQ#uqH>%3E#SOW*Leumu=E_;~;k+xa4#QaWu4!jc zfQZ*W-Ieb}?cnrYkBF)ut3;|ODbfK3T6)6!F9|SR$~6}D(#mymLuNbnP=uQq%>&)f zyf_^E{?3qeyT3rf^V{|u@IXnr_N_GwfeJB>+<2!R+nh$giRWd>MJEkNwG`WmwJAA- z7|5u>u^*iqP#}_-wQlA;$S*{5NAPtF0fxgf1vLe8uj-$U!l)u#SB8}n=_r$`2Q@zg zoJN(3{3OB>wt|71a*fIg`xM-yD>JRG7RUE>-Mc4J<33qGdrb1i3|9P(MpUQ0x7%E= z)$8_(?`$dzk?l4KquVE2r;fQ!-N{|3qjp-<%HMv)vFBX-hUcPCW7|nK#Y=dy8wSw7 zoy6llZu9EHBj3T=g#?*m;crh(Im!N-xYLg4p6BI_0Ep;PBwoL~+ZbL;ui##Mi7Uu6d~CSonVFx@9WIl3 zzOViK8|NqYAWtvq488U|*!2e4`DW1(nBhAlQX+bc8PTa6@I07cbStbw=){{^CYex$ z-f|A^)FQdl_7_yxANh>6q$zZ$!}oWx@!d?u8(k8E2wZ1c11-up-N9RURc&tLroY~! zlr$5?b#!9Oj~j2uky5X(csd%ecTC##9aBjCk)>5M^ZCVF4!6Gd94tCL_Lr1J3IXen zB9aWGsa%d6e$&OD?xsDtV9mxsIsK~dHDOYgZ!yH6( zoQ${T8IDNH@T$wm@NuwWe=4o!|zZD9TvqvHd))eAyBz2aCKX}m>a0Z#qE5U`~r_cFt#v- zTr1mb@OX81rN__h$l_Q~Q=XsP!JUh77lIbABQ{iu1Us%!oW~-t36{9D)9~4$8VzdFdxH!A?&C={(Ms!~b*}$0%E`!(p)qWi!oDZHK$2J+D6rhculMqKA8{ zYQ-=kra^aM(CwW~{#{Zt=-7W|vpgZ51ywMGPG_)D*KJV|cqKB)|FWcU}>v zUDwr?f7C)|a(G(K(JyGMPJF@=d-;e6bs?kwCAD9;ML}&sr!PLgqNt)dcv>ydgpt2? zG^oEg;0wMv+W)o;tip|yMs-sCbZe*F`S4;|Wh2cWJMie{DrbZEcN+6S!F$=|PaRlr zjOTqqh2xxuz*>j#TSJI$>dY2)tN>caQ4IExF)x$dwWV^BrM(jhJ)0k%WP zlZfEb!J@e@38thcUS3w#vd1II2MvLQoc18#GuD{5a3P1R-QG1rBn?#{CDNXy_b zF?7fV0B52@R_qDr%|6dHW0KD_9YV<*u7{ENwLB?XFCpYaR4<7|3U#`eRT1^{9BTop zPX2(X3}+<9nGV@K1uaphJ};(23TRRAJHw6GPvF;+Imd)(9oOlQ0pKFK!VM*Io8{=Q z8crCHNg`n5s`6p28A^w=U!lIntmI`<4YrKQk1^`NR2t^+)_RlFhuSTzh~2yw9-Z63|9^nA@D)BnL#)!mrj zvAA&d5)^_e9f(nV;wo2lfu&2>FYcmPWfs{i$fW#EVZW*%G4|!{vUuK zl6_f?b_|b+^5Q069*{FqG-FB^*w65A>8Qp_AqBh(21&FuK{R3Qh zj}CE#aoRhZ6C@N$nXhh&$6h+mxGEcccjYn0=RVcf1m6-FG_CDz&YT{ua91n#4xdK- zEy*9uzzHn7N}Mx|%?w+TV84r;<%bG`g3A^D^IwZSA>}@&ZbDdMGt7ncSv=M zSZlV>Ayv2&t5D!oZO3*m<{Iki45?8_1BZLiNQdxM%{ivS7k`ZI&CwxD=7PjD@@W#P zo+kd;^I+$ZW{dM}bVyL*`sM!hoExz47i-qXHV({VoIG)uktHb{2}M4CfjigeG8Gok5^{Qg4b*rUSve6mUv2~x6*1oAb&wvKpA`Vw#0l#Ce$2*_yN+0;M zjneGsklS>~=s1n-1HOuO@Ioj!wFA^}B>xZiVU?ATXpBKDQL zr&xSm3=hHaW^j_MSpwhzjfHc=oXy74a1N~vKJjpNzsEyGe(=)J$}U@Ubx zkx>2u7x#bPo`&H~)<;~4Sb{5sM`7BU!dp&blfhBX)@4W&kdd5^5wNM{gB9!JX8kjY z^CnTRgTyx3&cEYzi26QP_DTwLZCH{@&V~MU%+xE;x#koUd>N+L5rp7h27SbL^V56I zL!Ko_G8Nwo{-V8%Qy58W7VNGEMuli>QB!7-VkVar8bqar*ZiJ*=o%>Z9Cr_oIt=w= z4|~{G8%2Gka}cPv$4o_KNRU#=YP5FjzHHJaKCMMZvYNmdc3;TLyWb2znIv7QBWte& zDCI=x84h9(9+j!kv3aUIFtmcHXIOvKrApkeLnyFok(4%JGmvgow+_Pzuc--M& zt1MU-pZh)tvT-H38}h6Z63jy3Ql9c@l=Gw=u*65=2g@%C z%nZETWtoakr9)U4kPVriCb1Om8C+(akb;QFsd0;5Ehs^382-8ZbIhg6phLc9JpF}H z;uX(#J*L$f3fM*#cCa*7e)4`JaF8+TBg7H1j7Ry$rQq*CvT%8D=j116rpk!%RPl`+ zh`;24+y{pCQ?5J=d*^@-f9a1wi&r)I)`9 zMGzP8gP0hEjOtd3`gO(9mb4(x<70PxdGH2eo76epA)p}$hgt8YLmm_Zgvu4EEDjj$ zNH;qjf&?hcX>>?jFCD_V@R$z4z-aqA>5!J?kJ%42=n#^bmO6Ep4#D&J(9{hmk{LoY z3i!VV)#(tSqK0SyG6Q;XEj)H-fexv=jv-A#@fm?W&?Mw4VYSG}C4d>$%Nc=48vMd%rcQ9olq>oQ+D*SBz=D?o6a0c~wB zlg7$`cLfh&Of*I_0<78mQpPdDt4H2mdCt7y_U9jj!w_pvpWZoVPEp;po=#hJrLnny zM+W~=Jg|F}fm{XXkB3MZXf`|GtAFmT}ch1vMRW84^1lQLOT|A`o?mSTUe%n0N z+~iE1aH8j~05z}LavDmKTBq=c4&%^a`LL0c9cTTI&yY8xJ0@f7?Fsc00TtRU&0FZ+ zYNfWfGsQkdH?|sasM55Mkjm!JC0bObT=Qrwo}}PpKmtnPC#@Xam^2>as?QXnOw}ypA@`XEyH(P49bj_IZMvXv7#M%WTwl zYAB5iLqPE54Gw33ed!X-%#93jJ4*9Lujr7P6N*$BqLvxu7+z0P$hy@eb?4elIX1uJ zHpv)<&uij-Tcf1}N+U#v$N_kskU1W6cs$e}q5by=TdgP+Jk`t=AbYSxi*8!Zo_J{E zpMgjE@PC11+0iK3NEX&#Wwb|tFPu+AReNYcfkHN zxAA(3)o0*j>iqm}_#GR~inpQ3`9#H$j1e|h*0OU$|YD)xFp9}8X zJeH2->vhF^yT@`rc6d7to(#tu!XAJ_7hqhTAmDE=%q?Z8WRmqQ^o3S_u<2rSzc{V6 zU@ScstIfJ_30({Nk$5+8V^%M>br9Kt&pw;QKZeVy!3C3HQCG33Zl6X%1&HD7j&8I3 z55(&HKNzbgZheFfp&3W6f-c`hmJV5hWm`|EK+N@c80bC!t#rtr5K0iG{59C`5diZH zJ?3mX2%E|R@D_$hp+MuS^3V9PUW$}Nb>yX|N-RzJADcE{sbG`Y6Q@?uG|?-YWCc=Y zYf9g)ZEVW?SF;6{Rdtxyv$d=gip&Nh?Z92UG}U$o;-0rfaPb;-j+ zvK3(E{8grV7?A1IQKXpT7wX{=mFvxBe1>8ldsb#EleBFLcCQ2%e`3q|a?Gkks;ktn zHy16flIUR9D|YHytk{D3&17sgEW?K+m)Yux7D8Cyu`8`OC0B=|Rz#vk*Co_&rmW)3 z;E7pa816ysq9)LoN0D8y;>-vdPhHNcl~s(~X2J|mT|u2~)vohDg;{wZvkL;2kAf*` z1ohRo9dDm zWlKu4FGvNZ7 zDz?e7rz&aN`ZWX9!&4JxG1DuS#?dKP9|P5e+ZzeiRKH799H@JLWaky+H%zg960VBG z6g>YCR%{sGB8m4=7QNfX19*UaH*w^HM7Hy*VXdJ>RsbOx4bruTV62h9?Z z|M>MGZE?5elaHdMiWqZ$-sNEU0{$+3{gStS@bl`x{)fH*vm??e)X#uxc_WtZ2Y@%F zLq_rHS+$sLFqkU;IY9E4w4v5*m2*ca&yKo1MM^gkpxy-;AXq~VUSa_{; zVEi)BRGIKtN8W>I9h^6dKJjg6NbS6l((KctVQR|XAHd!&e>mUJJRMIA#JOX?#-3%C zUm#g)n|*wrOwj>xOAtf*3C;|DsMj=W#*n1)SIN^pJl`+BCta72Qc2SI5;CPl>sf;Y0Qj!x!M|gAB4Tn+6=3B zoxO(f+MKkpJ-=~1D0enJ&zREmG)vxa-viitR@H+Cyl497GgLBux_&~I@uOqzq1l<* zQ2IGc#cay}2AL=L~oNGhvnuC2Oe%QWwExq5Qy-?s( zrv&6%+!2Y5pj*rd5)WV=Ao%~!QFY+X`FZ#J`9Xaru?YPwZ+8fg>CicYumu%ZhY7FT zX-+tD+U_!6@4_)c&7K%%z&)Ojm*br0^MXI-7&%(Fi%*u{tlc;@5T{|BJDF&Wd_oZh zXFNjdmwUHtYq0Wus{LEb#GBWJvIZx=(IJF&2FAB+0nccC!%O9qBf~@&_$Wa1?$eTc zuPZjZZ7CREbQjo^YpUbof^D_m$w+02FD0D?lkq8%(KK{ihS)hkhqytbqjUZa6GmJ>)0XBns*e8~Yv%CIB!at&?jPq_)4y3$ApaE0I)+T)O8Rt)y z#qYjOBT9zdiZJ;f$Pr+Uk^sH;bISJf^RxAXdEVdBf&WXg@)x$`S~ZyTFg*ol-a^?& zEz(g!4#10jS}>9Rdy&mPhaEi>LZ;dIX3Ph0{f+@4CJJW;!s8D_OSI9=mLUX%+SL(~|pS;h0!6C7OM_nz+nd@B2liiyOY!kll zvB|)l2F}r)smK-3X1;jhO&Tg)BXS7x`SQi@iqgDOrZ=yoTi&cdxe4Xn*JDmN$)F4E z=%xq-kS!_usWE^HHU|F5VAW;1Q>IQwgJex$bO<&63S6%=QS!Q}SdgMD4Oa|kmXyg* z%ZQcff=#{1WYM}zcB0j1meV1vFh-lUJ=az&cVG;S%j(&6_U*hvZdnVdV~0-$9J~99 z0PkB^NXS3?W$qwfb$+1ba~vO?G(w40czL@;r(LXTjl;FQBV z4Z*EV>q73f3eiYTlE<>%kk;9tJ0F*f3fv{GMKeGAxHin-toYoD1*!aWWXFu8nl_aX zLt_QEr)V`VcmU5!8<_|!wiu|saLD2j8X$(0UyF-=#Z_}-qW0anAD&wmrdcT5GBnm? z!qw^OOkthlh{O1l_tUBK>dNr$e0^K@sP;rl$jO9^0Hq}O>oPt*O^4^2w^G^c?Ci2z zdCa9QUOX3JY5o8r4AI#4;2`;GsI%(R6vIQkpM#!>e3rn)br@R5`3r8E1^Ka zIC7j0IiPZt5HKzLc;_4e@n~+_?GS!f(qz}!=QGZ7hcXqG{=Q$BHf-Gp@Pm0Ytrncj z$d?)TLT(RBujm1i`Q0fDyB+DC8L#KwSG(Czv_+*+(arYmu4Qh<_^>%)Ymj+A@Eo(n zHvn5-0=Fgah`FvXKoPT`LwW$aD}EZ~=t zg8!gEz#lq$z5(FxQ=;E|_ST!}AZd_H+5rjz%Z;eX)%3&nigKGg0R95*R4m`t~Y1$*M4PZ>oY+eZ8U8B8GW!gbD(zCdh-aFYU4;( zr{UvgiTyvHk~pKxsrjZoYj(D^vPua-GSFgO^}H3D`s&1|%a7|=_R5g+U2=Y(c``Y7T9qv<6W7HcQ40b^;l}X2gC5YGZGaTLB8Besm2D`4@mn8#67Xtv zmJ0t-=J0I$#U(i{Re>wH9|UqyZ9mt*aHD9C#v-XyI6xCiAYII(L$bael7I`QKmQ45ve!g|4~~_=(}AO6_XhQLYQm*L>3% zFD~7YS?l;OIG^)x@O%glsD(hfLm%yh416 zoeD{++fmIsb@i=A{~NT#3d)~&vFmQQeeTLLk{oCyHPoCAy=>$+QQ=Gl`A%!gYg#kU zn7k4k;VjgoMR*(z(nhlalW#N*xO8EGnL0hmiH#n!xgl`d-nM(4bP!9 zJnyVL3+F!X`Yd)JV2A2w2&J1ZWAt~WclGYmDkne`iQ-4j%8r(T{MWMt@= zsMlj`zpm<+1;FfwO_gCvnvgC`2qV1RtS*kx4m_;Jg9pP4`Gm}<>>xiU z|FOKWav8o%H{Hi47OQ42g}kJiG_1UdX|d{7d{4~;SVaRCw1cdR;?OEpna~!2vFLCm z3XC9A3)dT@WuRd9U{fjZ;uExPXt8$u`{@Hrj>if9ofbPM7UCu06Bk3~oX@`SzjdM% zncROOUc=cYo54~S!tsDD6tVyQ%b{f^?@Xxub&-_t}C1jm0)BxV~xyzoQH3= zqj7ehzmpv;^X=^aAUYr}`p=hL|Ga99sN<0=zmBP50%Y22H7G9lfI7~IN=!2#{B>?* z@w7@B1H*h0N$k?L>urYJtq&hb!O2T4aROgVFP}_Vt>G5?U~@I>t-4#DKP90``WMBIVq$ z9Hj;bX<0#iEyo|(F3@MeMQR!yYFSk~(m?yyhy?~=#XwqpT>=Qs8;IiKoZ&v3qarr-l`Ipq2J_}~R5nIPXXP(6%3uEe z5?A=YV6FceA;a;%R^x*;R!q09S#;fDIr)-i2~6d|OIbi>@isi6GGL_@uc1wg%X2(v zn8?>Qpek|J)>LC-26}2C{%G+d#l4511G-KvO(F)8Y>_;H91A`%6w8AxqH2w!*E2+~ z{^;&7AeB31W`aazJob})EpJccsx?7=STN;-kVGU?jvR0C9blM6ThQ@5@Tr@?MooBh zT183tb3Wg^Vs&Y}DCg?sN#9_(`zyISb2>+!DDo=H+TXh*(;xiVf!}BO1Il-sk*H2Z zA=Jqd2=4w`!9_iJRS9#Q_rdSO?)uLxoTzOk7jWX^gCuI-gT5#e8jp@^y4F1H(rMvU zMlPp%^^on=3`Nt~%yW!v6|D@x)tFWL8O{Fd=Wlt3|AlrydCsy4{=2MBK2RMwgK5Dq zBfH^j+dzztcG}mEeXr$DTqU>9!S0>xw)U?RO*exKzjv4%8?iMB4Hau-I?!iyBK_UF zoRf)x5Dp{s><)2_T2So;E(#-QHxJp3g?Pk1;OLN5MXgBK)_9Un8i-D;MP1o5vs4)c7Yy}=Gf5N^~<}pnF?Jvu!Euj zs*if8b%00ym-fv+25Em4O_U4BYz))bT*>~nw6AauY@H)hs$Hw-@rNk`vCxloSQ-1% zv>kH+E$`XcLs09J?Vqj}sbw&-2z7aXH@ffh*~IGZErC4^S*=g1lR41V_MqZvEJ?;- zb@8s&CaoRHEEy#EyfAom?u&SocgnMwL^DdsiBW^#yf|hF`dew(8#wstyomyhz`Ks97)YNwE z^2F18stqit*15-Z?>xGX)UVkvOX6(^l0^&9A)?veeV2uZ4xv23XDJ!y9(;&z(pnZR z1sW4JT64Bncec;z?dP$cS(u1G-L+-q2cW-1#-V>H-t;i&@myi~3uQ#nqM|V#2_)HS zprouAZ$M-rF~45c;q8_Ca5JezM{b}qBK_MiYfVo1rT0d2)FG)Nh9v`IFa=}++lC$$ zQe3GZF5%__L}5PWucqPGI3r<#I=uDl+ti|TKLwTLs-vk_On6dn98l5^{^;_QE0znM zlUX+0*ge)dolwVaP)~<^vW{QT5e_5)N8=1VKn07} zgOM)NA-Aw3Da@)OS&%mL(hqr@THvE%Q*Cmez;+N=s45@YYWVcxm|V`qiFCpoe{p9Ii1qU<`}Wcj zAf{9mT}cWncblks;NAKR{vs3a*07jEWA*t^T81sne3EgDd`o91y=+{lirQJ@xw$|z z*X*NxgW@f_N$2kpp(JBf?57nn#+|J49jVxQ`AZh(M?a7#hEsT}X1eRmxRF@s=Y*Bf z?4`_N+C%x*s=1X#?|PQU%F^Bx%QXXo)ogb<(x7W$iG}a0-=cp+5{0QBkwK)az`_w zG+yf#*r!CjCVsnmP`(nwQ04Ziz(nYtxqA?{tt?hJ( zH)puuD=cVSu}&3bJqX8V*UR#8jdBfbel=aOmG!X)h;j9k0_#E-oIWy;&Gdp49@QQr zEdWj5h0BMk^`#+q)tS0Xx!Q%v{B{BXe<#s@Rv_U2R?+V&5b$rT*>5Qj@LL$suMi0MU99?_ z7YO)WtoolfYT!S!&HkZ4z#p{l{~{3Z-=657U$ftG$v|ZEAGyZ=h(O@@|Fp0EJp=-N zP4M^)1Ol7Ye#2;6EJi|5hMT1AuIa>~$2NqIB4n zFnn72Oz7LSmg!}8yybJJRYXVUKJ}hcr3}+R1h_b|$&4h;zbf<&;Z0iA+R=fQec>0( zYcYXZEgFJ79L>v`yO%WDgb9w$q%e|kUU{q|oh^&k0Pq{O)Qy6<|Q(RDCM`^(G!z*iqn z|36>(hiM$}dnK0ths=Kw2>8De%Z-3v=szHt5fv*kmZud~S?~HXj-N%CoQXc4hoYSn zl(dLIVIO&}b^NL5_%E&J6hUe}%mpCH?@uOQ3dBq=tehp#A&pN1PXNuzcHKTW1^&fb zL^-*c@_2r}z6TCmsfY4-2h|nN-ZRqX{8QKTS%L!b3TcPHLR_ML);XRK4TvBm>ScR_+6@*;RdF$Qr*&R9BxyA)13 z+1di<2!!FV%|Pgl(0iMWG^xH$La)!-SC!8;$rcZ$8oY8ubpK=5&EOMQK zf<5WVLl~EJ{-q7l%vHj~-s7AZxm$DowQob;6@87YZyzt8vl#VWNP@ptxx%tx2{Ik^ z0>Bg+3qc+Tj3Gj(&jGhiMpF`FQG3BT7wY@G>PlJ?@W#WmQ~N`8+iAvi&Z3mC=KOf`&`$1v74-BASR2Dt}s5b!5dsXmJ zPhwyVrti(cY53vD{*otjNR$I=C&`)oNxGZdY!FOd3c{5=T=TX!5cas98o#e#HqlI1 zW|y0qqjh&0O_KIm9e2g=;0EH_3bj9=V^2Z zv7E3aK2TB?Ecr^Q&(KuzOZ#%zi-8?4+9OQ%(zK2cLI;m+Kr^_*II2$^2s7S>{hm|*j?n$<#$tvK%zT~d)hu1@VJa#Md)9;^+dkyz`w_TfcL8yN`D(rO_wo#IfS*SFmxos#h87=PQH*U4K-ze zMY#9hb(a{Lt{edo&kHDmFRLj~WZf#NitldBJm8_DNk|^M?jC%N)7MukoY}DF=Jxu? zpRY$bBX!Q9hTE>}Qf~Gukb&9-3&og`)OQ%%CB$JU3q`?qZkeWH5J7}y$Z6e`2?@$v zYAuk9PX^7YUJ&L2Cs|<*%ER2uc*nhlwCn^-L}un=%&tB)$bShm22@6%zryzYs0DGv zN4d1`o1PgbH?4J*S=V^DnTwuz+!a0*!oS#hHa+bF;IMZqHUCGYS8YOWLkh9%*~!z* z0%Olw1=!wY`GNQBY@tUrKHtR^K1yIW2UdAMSX4PEl)yN@z*nKHiOut)>6gZ`sRP<$ zo|~v`Y+DTo9?mebeE$=Q1wH+*qFDcaD-XC#`T%ERH|Z4TDyT>{6fuExc4}2^s^z6b z)=SzOhMA6JTDK_jiR4MBIlR2*vdA~qyIdV@uOAY%h6x~}%gjOgh*Uxje^(-0R0>Kd zD+fl0^0f}!Zhfw}n8>O#JG>o6Wfx_pfMQ!b<24a&=7pv`U>n!;+i|6b#Gb<8}J4P|AfV#$40}) zFb64i_{VFPYLgHzBPx&doy{8q8$)KH>7mC#`0pd>NPeIXk+-~G>zvPK;EzeHFTTk>%HJP4;l_QCH`Z}jT% zQreIeKa&pU0Y?LZ^9;5=^WjUIL7E&XrSV+U!<%-gx+%J_FNeQ@-E0h3?VRx2o&(nk z&UKjGO!C?jf17UmF`r*E(MbNosOT3xq!`5>6mkl25MR~ljL$rWA;>O1Exx3e_vHlU z_^SjGJ2HfXqOpNcATtv+f7?h)60cwyDh&@~iM$PD%+E@%ZE8GAqRw$eQ;*2vbDk)g z6H6CcB6L2#7Z*~v?6Q-Ks8xtl>QG30m2Tw!2s4vSF)MPeMtp#fT@?*L%_|0 z?G)FbK!nNk`|ypSJ-6_?V=c&yy42fzG|7{R8bbQMTY<6)hwkL&#lMY-SLWwkJ1;X~ zvG)D#c+J+N%-DBFu`-`md697}=fg<3Sf}b?Pq*!@ZGRBOr=Q`$ z>#*0tI-+XPv?9s~*fuvO@(`s!WjW#fSIu+(WWU$HM%Dg9ruI)874sY*ouWfxN}=(X za*1U8)yCOwYGPUNMf10G2r8|Gqj7n&0KF6gA6Dr;r}s+j z+0;*5Y|Py=vBMkvpuMW}mO@8cX>7;yDW3SgAhSUl2tA zDd$P3w~cx3Y6l#R^0dud%m>pePdzYFrPL!V!A;qWKn&NOt)E$sm?58S&@_254fIJ| z`Fj7LFkgjJ7m;zUUg)grtKHTFLp^1 zl5Yu}YOBx;cgehJ6YFB>YIOY_a3`d^|K+$2+Hlm-U}5|2m-rC#!ZOq3177O)yT!}D z?gjtwhklk^&BvpN7`)A}7Qgz){TLUzNOLl)d#VYLye*9Z$i_z__rJfzG&Ueo^&7udn*uZWq-K%)|z*up&eW zInuOkq#@-t6%F%*lcW`z81YF{#I(CES%$40eYcH-au(+SN3%NEg;Ew>)>&F6vIB+( z178@HL!|&=M7|h7@WvdoR&z@ABRdbfnLkdg;`1%<4H;hct56WfEpasw9IQ|ES;w*Y ze5XUoUwY!*{qK#CQWtg~yPEg4J9&5pC)y%Vl>L-vyX7E3Eww=5VI+PS%xuVHD)jbi z^JcXy1Mt-1b~VnO{Npb|&)Hl1;B#mP#>r*~rvU(`Le1VLQMA0NfP3zjTg$xVuJ zMmx`*td&w#Nd|Q^c09-Pm(WKl?;RvMA^U_3Q%=t0v+ehMTXfqXI;r7mAX23MYD430 zVV?TZSo!z7B~OzuXItYU_b18j{xvx}K09_jUX*VnZP0L0)P!2Tli&qv*baxcq{Dj1Xp(0`tpNU3lITMmyXLs@r^q$Cv5OxsVlL`JDDQ)#Da$I|zQP>{ z66NAM*t%NZmYqVPPCiQt?#_D@yOBXJvg#w4qP3qr|EK#bm49wB{Uvhym+!Frb1~Jw zeQze{|IY)QT^Q0SpXLOTCGZIyWiKuCC4M^%v)u)oq22MB9B&YT`Q^eV*1ch@=ssJ~B>4J~eVJV=7(w^yyCY%KG;<-=6I-3aGe z+Glw8haPem*@8WgHs!j~Jk|YpSTV>tCHS_5V-awMKY+3G1=OLa+4l!C5)%z_hei8x z3_M$8m1-D$!hZw2QJX$xug6l0tDopU4NqD6GnL;QL9LzpKk=#+Hf!IYV`Uet|s4#jX z2zE1*bjml7Z6Ud%&PtC~eW&*R_0ZkM-#hj>7NS_5-mHXF4g1`ld^d5Xo|BMFH}s4aQtv2eyu zE26Y3AlvA!&l9z^FkTau$8ig4*{C$CCg#M_-N1K&9?EFTF-2=v=C?roC9oosgVwi) zTA7>%=dm(+TlB2cdt-x<%Q&c(JlN6v?e%e!@~>6Lo<&plmwhR#DL1J)1Mu18U1|Gq zK}Pz=IpM>eq*>~7%v3s@7jqlLr9`DjHTg@6`4jbL>==V%+Y2VS9e?U`l^#Hl?dCO3 zyhWcUoglj)hro7RxQIf7LhPQ)AnhUJ^yO2e^mc)n&{qq`ICYgGd+tL^T#S#Z@5TEw=8d0J)8r?())Fj#VHBSG8 z)A-SEAi8i> zKBs$6`0~Oe_t_xxwV#j`MTMd)TruwoaPA$xp<-#kJw^1BJbjKl_kz|ce}n$}JpJ{z zGh7-AJ{K4YD^bG01n)A!wV``s8V91~1r*nS4I6t5xNr$ zcxj*?eBN{So*79je-%~W8;TALRi;C<0iPnFUAt2EaiIPn%zgAsHNh-mh7R%fET{Q@ z1uK0DCKR9vu*i;d5Zc;=ZO3Q;9M=^nU@=80EC}Y#Iqo^7uri4!F0>$z4a=4aedGDu zFS~XkWq!%K-rVT8fzU+Uy94d*5IRIHSR#rHw)xfD!Q89{RIg`@>wWUcum_+Y-PK}| zv(69tFjV3~qJO;38$3WcoG|U_N%We*3b~sv-q2G&-y)s+szoz=lb7d6zhu6>Xfl-* z0VS#9*QX2~%F=`>iv2nLeqUAVu0gdDCTG2iRwol+5dn>wINFg$i;9|%LAcozy7C1ib|V(nF>kvA`vrf zwiKZVF^LkgWoOJ?vStgRh)LEGQ`whh>?+C@LiP|@W-Q}2TffWmU7zRa`~IGu_j%v< z4}Z+yzOMVa&UMbY&iS0rk@2{MpB@A5mOL|&DAdPLfvvCY36oz|)>~6rk<54SbpP9F z)%`;zL=du239s*}>q-^ft}OYsza;kt{h6X&TeiUY^y_&$EI7K0jeX-EmE+QP=sT+_ z-qhL~tfk3!pz6yut@Q#dHwZ1gy)yD)8Qv9e%bdpeRrQ8qHb_=4qf_0=Z&t(6?DfnXg>=u zCI`I7wGObs%F4CSHoI2)fqjJr&UVlHzi5XZX>PM#j+Hy6!_SgVN~5c?fNRq-Lm7b< zDBaY+PC3OHu5ix7cu?BL51L9wF^F($ydyUPrJZZ2!uzZ+?7;GvLMwH)}bHvK7d|_6>GoJ zkymOrzRrF5;F`zxGv}S24dVfAMVJ!VP+oqSa}Rlp~qk zugt_iW%cFjt6sDs^mEX?@9lr*B_ZsAqv^*On_#tXy!8#xylmvSZz##h!FD`QKFXY zOm(M3*M(gYoh-MR+3MZ=p+a=s=L&@SxAaXxsrz25sf+uZ%^?_1N91-z9=MsS{v;Wa z(I0m(y2dO7x9{ic(KO@xy|jI**;HICqTwj}cDLDaQob4M8fhp5$vHuhtHNs}M1`-{uLyMJ71315sE*6SLjEq>@XNV0 zH`sPmP%aeOS|}%C*FxNKt!7|}&&zalD>?~nP-@N!dK|7mQZEn)Vzvpd)Ix|BpdaFbGmHkCZE7G!M^m*&nYyWU zYs;qC4QO3l5wG$^(}mc%YZIQe^M&Rrv6LK1^OloXt$LF;u4U&3B*f!~E1#+xeMfLD zLCP=bT0$}J1>SSAYaw#i-8=sEcy{;vRk{jp5M4-^<{m}DhWNs*5B+k!9^L`@7*2NM zDa-bEBKou&H>Y&!?AJP>+P&CG)&k*7s8_VQ6`mNDCGb48-7Rv3(4@Fo>vO`P1yQaO zsE6E?&n8u6!J3_>y;&ldEf>G>q;^ja+NOW)nf+3!5!`GP=-->`t=_#tn9T4K-eq{` zfTY)vb+P*PaQYz6T{wNT8_1so^#U_xG^wuvB^LD^VHE0`HF?>hUtw{NUfp*@aa8&M z(T}Xm;K5L(DQ?frItTKGOPtUn)>lm3mWu7FXpceFyOEi2sZGUBNX74x3G%l|Vqv$O`eaB2d?Y)872rp}vdrk6}UxdC)bRr$N>P%W` zPK1na^8UHn<#(q<87`$BjE_l_mlrqd@z0M)p5~&rIV@<;rH7?ou)O~0;q)6f$1kLc*gi7|Id@6B?2Bh4&iZrCIB6$~4}j&1xftBMBbVVA3J=Y}nJfJjG0!v8 z(Q)+6K0$5o&y8)~v+q05kICI31$9=y6LKpj8CT2rWM*WKu}!q~S%%>il3ms8F3=J% zTn-kC=31RAo}^r@CxCj4Y(VODqqb3MusqB}>7p&7knU^GC_#%O_!-VlVKY+`5yLfa zIjWD*r!{<`YA$%Mavgz3mp7DjowOtC-mBp+1pqg%Q?BwQI6kV%zhSO{w92W!~eKt!qpM`stzBBqwS(Er=DlrQ}p5f znT3i4M|0UTavLj85epuD^f-Vn@Y6({?qW#4_Cg(lx2)QId_l(cCNc=UQ|G6&QI4k> z{`r*X9G`eE#ck zP4(CpAmUl)v}%#h{H#U(dk+If9NZ6XXD7f(cg3}TM-)OJ>}!wjwXVMDy@}+Gi&uAh_iQuw^X^r&sB*gIKT~n<*a(RaJ#>qT;)PX!w+)FgU*UG)-kEe8vOxV| zQVU&`x706}Q{9T6@n%Lu^F~hOwJpJ7&l*ed31uW$21q|q-2ntAprKei2X0RfV@ro4 z&k@vatVY**;?=yoW`*XiBpVm$*uwUc2cqTK0GSX%29tv)YqvyW$ePHlSg99a)?UEo zs1o-~LY5L`x>C7)ecqy_yyX!Qx19*GVzGRkU#ub)YHmGtJcnTa;>_4?y-F8CR|5ZJ zgW_iZIST;qTdk>f6a*Lr>n%}~213^4+Z+r1F*^ar{8}MnE8)$@)dpoYs+@{E5%a;{ zoV7`XZh+Nc#-WE&p<;BkjIL=}H0gBU7Ci*aRe@I>vTq%0(>J;u*8Qx=gsy%sgrTqv2FuJnxt^5-On2;2TgD5fZyu>(_ulZCT$baNe2g-TzOzbtp};Fl=X zWfw8hejjUk#r+JFs(nshX(b-KTOWA0X!;iv%~s$m|%{TNblVV^-vW)_N>2>hQI;yUtjUDGD_JWSoP zGEvO$XyEmUyHd*{`VXG3*-Y8{cF_uuOZk6WrA&f;-QHv-nZDmckiU=~@J)zPc;iaU z6BoYAGwyrt(+=J{{$-m3B^^+-pvlG^)FBxCSR)rES3{4ziKeEk-QH>ATh_EwL&qJ@ zHSI{5@NIZUn4jBH{1yaYmeg)Q*Ck*6FDUx%UI_*jxTV!W;5QHeqi=KE$?^k9uc4Z6 z|Auh9a~;0`lr>mcUd$>S{eNa!;648VD*V;F{ZK9nz;0`v1ek}0J3C3B6jq;=KIm8sp5cUWwJI1ZLxar-m^G%qI? zud)7MyA1Pq)ljRi?!pkqC9CMVd7o+ zC7Y3h_wGewmL&OUahQzwah3>tVHmUd>tRdb_9Uv5!$#4PupP9q^J4b9K&{|b_G41k z|5EvXlYf&s+lP-9gle}(r7lTG`tDoAh}-yY#RNw2a_Vvkr|%+;(f*EG{jXGokCxRq zrbw>#B2fSDwRSczaeXpoI99k%M-C8<5s5B}~s8HJ7qn`+kS%^E_6Me5mNF zhtkdgA3_^duI`@_RCz1ZJ(J2kJUO9)yISj!dDTGE)&G%A97NMu`HtZ0Okn2@e1Dnn z?lUH(;}xSN3o7ErT7#`V%OtW9llJLzJ$#XICtO{iv&?gfJEKK!{s=eI6?UjiQVvv8 zw;sl9tt##kaNp$YykqnFD0%K<3^tqrRDFPC?SKXnvvw~WGpg+*!_XStrJKF|y-el3 z9*^L|^F3V6Tj#g>MxSPCgR9TKA2|py+POd!w_S9-`01+m7WvW(Z`T()+DZD%*wA{J z`bL5C@9w+IH(Y@tO&4_EswaMf-H&oeMgQh0z=lG6@Fi{s!u4Y`gZS>h>;UvY4YR@D~xkl=qb1PCb_%!myG@92dIz~D);Ha z!|pA^lo{hYRF8xXa5YAKN8lgA`k=s#?Q{Dc*mix1<)#FPH>gM2;YhT;Ec@V=1)V$H z*ADf0`NDW(cn18PC~z-BrsC{SoOf5;RW7BQO>XQmy8)eS*9mJHE%o7}t-+MhBp5rw zsS<6K4ql@Y)4PS=}*AM9H!?aOnN+Yik5ISTokj%tN0L|pmOOz{k8 zZ|z9#>~V%BpcwcNnUl5gX2)6HP_!MiNzjL);WCAJX4FDUG-_n*sx>T6_$c>Z?=#_kgZIO_(g>|Ck%I>QyZk>9=pNTu zUN~E*;AG&dr1Q+g7y9p~SS-90vIP$m&Cng+kK$vnGi5D*dVl+;n1?e**T?@%;oJrIGA;{2D6`^Cf?{LakeemRmD z4jqzS-abQFl1;4T&bU&UI5j(H?DtSlH>l|;T}{#7yq`wuu>J!FiIA*r^auXz@z$EV zqyMhjpj8JRN&dC7ed`Y$am4igeDweKEpyiWm(zlNxdn*)ZeIUe(}MnM_4M22`pc&U z{nq#Rub1mDUoGe_*~LFUE$AP2!`~?Ezj9h!aX)GVS_8d|ysrv*t#-3`_qk#(R`xNsl+FJ?86;1kD6g{FN=j0yl zvi}6BH@G|8Mg$s?1J!77qJHB?~NR%Sq1&woSbA%8Z*sg;Xsz+fwRRd6aIgWP1 z*`r*+EdEY4?K~1)@7w9(gs-IKB+ZBd`Kp7}VllnM&_7AI4?rU*h-)V^oh3I35U40N zIDKS19pr$nT+~(E-!E1wdPDL%V%@!GEF}gA9aZz7whdxWGam!ckOurh=}qe%kGm#c zi0L_TuNOW)x4h>6S%3z*MMP6_>FmRaA*cwBzfAGkXXE#F2eN;vupM+R@&{q1 z^+m80fCoBLk3+2j^Ku6Oy}`pS*_-m(rR(0cFbg=N^Ki8w7E>d)&ZK_EL=dGh*we5~ zXMSNCkW2AY*4}I_Z>!N}@L$yLh(6pn?k)h^!j#ocspr;UVxc05>79=B6S#g$b~y7{ zZwR@Q_y}Xpw{k7E=4@-j0MYruuJ4E!fhOOsa;s02L}4K*ch z9{{ZFtPmB_@NUag?SQW&GcO*`vTKt*Ol^DqH(DcWj>oaTDQhxGl)f_A-Z1=1?GUqT$0Oh^J1ZvRipNMPEipso z2UA7ztuOd3WemPmM+>g7h)uRvz&jrhdG%DCVfsLr1?c&U_ z(c+?<9JSEVX5%UUhm{SY%pk&7rGC+qVj&#qVyRUo^HL)l@Kg{@4|u{1KIr70*tK31 zzHdjf9boY7q0KH6K348)nru>fnlxfE`{G5s);C+{PJ$xVOJF4Hg==*?{7py07lUcT zwtQ7)nQ!VOOJfnBm#@sQ!<#za)NFPtF}*=>hW9b5YdK0i2EQydLyO`|4-gO7UbKzv z+|f1d8HAQYXKM0~@|z2q(mTbzu$%x=c8O0-QU2qe6RB-FaiJ* zwg14Z=HK@xnbp8rszO}RBxiJ#4qct~jwnr8s!X*^mwDQ;I4z`5QORtV|BlGN{E+LF z00ZcO0A9xCgMdAo8rgN_Lx1_cNAIUxWtt+up%!QF56QcUA)6DTVu4y>4+hjKb6ekB zwUFkde`=lZ=xl7_*gmx_7Ck`dLw_O+1+ZE$1zJHwfivjaa+iyEc`Gk7pN%fZ+J4>{ zT|>H(LwW)8pS1rpIoQ(ExCq^E2?}SmjY!B$4v#w1{l6nHE0RmDKa?yHH7K!=}% z8vJ!IZslLP&M3Fy1Yo2we0wAen}XIXOqd<6m=4q3rv5>m{iXwfP>+OVY-iR1uM}A= zb7&|y9Mylaet}>1f_`)H;kunMU+1~ksk`_~4Gv>teTWVrO=;9G`E(5jiFut=C)WpB z>+a;4q95yDcw)BVruxYFFvsu5-10VKB)2;7$>uO>{F12s}%a+CoxoQqyNvI z{{Iee@-Vb-DF-O|l~Dl4OVeh)=p0puA2fGAe$1P9s79S&TcwBD+!k$G|F zxcuT^2uRZVFr}6OX#FbvWFuXT^)9I^ExQGDwb~1+7*R=UYB$NQSr8MZ2p#|6>(qPn zF3ir_=tn<#_~Ed>A2pG({z#&cTc(|P2MY;v(sKsKJ=fB91HaUXB$oRJCB$28NX)8xVcrKQv;ekOJGoL8BNG~g}y9yK>J@AFhxpVFq(y$Hl4u1GP2yR#E2=2hPSv5jGRx-S1smrB`Z6xb zp~Kozc%a8ORnT2vWfq4FT>w5rEhIZ;Q?J0WJwfBCHVbB|@2KnwEt_s^zSR_eWPO^U zZ!7!f&OcU5o7m#CF%se)h6)J{{Ya`8j2uCSmlrpxswcm7=6vMSy=A@@Nof88==mjz z+^t-ST$R#7(gi;_YRJx1h^ls`oqqJ}qi2MXa~tvlFN529B%z3|gZreaHN8A!>yYEl zzqkEyn93H&vuXU1m*^M<-U8{8*0FfLf|W#Vhy8{v1JfL#Mcw^*uh3y1u`^58#PE60 z5s0jX4=7J;nCsQL#LQwq>$Qp}M;!{>-?II{%Im9h7ZNMRxS8f(R-r+f{-7b%uGI!p|M>Z5 z^Yz`(njbQP{>Tser7;KePb#rMAWbyrogg3t!_aq4d`Gm!bc38%wW4E@)F3R5ui;eZ zXH>k!vS(*(VfF}oWJBMs#{wB*1Ch4?46Xwr8-zQ6v>yUSQ)54}cp0kQinlWzM7WE{ z%=~O;C{emyZC-zKZpE13`3UPgUoOT@Bx}O>B_c)H~^!WO*lj z;Hz=(8M6~+aSK{vhfdEso!KWu-f;6Q0=JqwO;7aoULExtP1;6=g{Sb=Z6DP1cE6uj z1__23%#RW>aS-mUDz0P%-{Z#OAUyXL4*z5iI*O;gyxuToH&mS5azM+Hft)FMc1ghv`bE|LH62%b+?4DX~&P6 ztP}fkk1GW|6pE52E8!X2d5662&Q1impFLXjuDd-}t&j(2-|wWpp<74UcbmhAKCI}h z*L4FLilQl&7thIls~2eQDnrg!J-BcE(aAWU9Vp^Eu3*Qa*J8=C-oAmulX;IT>@L*Zv{c6_DtgAOYklyhTxqKDa!_oKX-3QV8CX2k6tm7fw&GA3VoS3Nq6#50lS547!K)>mF=%mr`7&4~LqT(X z&3x_=lLHw;JR9q_dZt40a4yB95koeRV3rWKU9x0I!)_v(B6mh8wrjoVM&9C)QHkS) zaY&9_&9{M{a(CTcK5Tk8a)cKP}w3ut%2N`_2-w|)_ z|N3KmU)wPut-t--KmO}CAAkNK(CXO#?Wg?thd{p>`!^qd{)K*WD$w7S`hR3?|HxFJ z|DA3Ak5hsE*Cp=PQ-S_3g&#}>`o9!@Aneep68Otb^Xl3j|A-Qe(u>bpg>hh5r?NqX zVfYXfgxXm_>8`Ud9Mo#Rk?7v7yrpP^@0$Jx&?3hgF)|yZhzF*G!OcF>GNVG^x{Z7l znKu?32T2wm?AxOnm01y)Tk;@XT^sXYdAE%TD57Q?=U*~@(KeQejLImCFtPBhaK{{# z(ch`k@}lg9+=T(>;ky7A7Q4V02w7Y=Lh9wM9bnUh`0RRLSoG!v)^ zr2s5a4z78CbD#vbx6L_4RTl5z;Z;goCmb#}er%cWb}pDiK85dPZ=hWzg`)O*$JW*^ zT*~n(zP#+EaECb}|8PmLREf%U2O0{*vq^L4jLr!-n57B5>e1Sg6gRo>d;%GYK27I$ z8C-WcIqHy7imB}V;q14)x)e3>B#6SZ$1-@Uy#C4<%>rF@rw#;&ETRL;FGaEkW4Uwe zpT8FBm)@@2TBz|Ip~$_!6$uTM;f2&R6kftthh87jSvaDP(s9m0p4oUG5HxbrnuZ9l zxbpO&p+Zg6R#tZkdaO9pOKpt!bX*z(kNGk_OHXSgI{buNixfzF9**{=j=kXm886K(ByPY)B4uvslNi%Z5hp}Nk5vm>L zTj~sUQpo(|pP+`MQX{zhJ3VVzn|#3U`QRDVLB@Hau990RI#lUesp!`j|FrwGwQSJE zH(>%BN7tNpK@4$t?0_gvMB$rb{QexA<&k5{`4^V6I0NiDy9L9Z8g=&INDs*^)7Aj` z-$B)eMQzFXT^*-CI%9ieoe75X7XufxeS;2X+b++Q#)|Htw zIP*3CkXz`lAG`n8AHgWMjzpI*fK&HaFBy0jo1m2)zx`a{yMNt(cntKf+5Wel^JgFP zkMaNF*ZSkr0n7DYEYBZ*&YyhD&+XPbf90KnE!Pd{P*$yFJutF?tVG4 z|5q{y{j|pT*-altej=XL#DqeHf>STGwp7CjUKxh`=b*Yf8V!6;_U>$?i_*ILJAZf$ z{)G=MQGV^8Y;OxHh&bU0_&~x+W)jv$tegD;U^d*cHmN4I`#7CHFszB>HdkCZTj?1R0 zxk!GsxtzYVx75)Zq~U6!xg;IE>Uu36AQyUP#aK6_R#eHPS_s`Y!Q!wQ0ZlQQ+|-hs zsK-6rq|EH@aji^nZ2qzpzxU;m%6tFSKu_9BP1Dj zIe2%>o4C5coy3>DlW6+U&!Awp=#zIGYop2#znUrqKG{eMG)^w4e)nRnE&F?_y!_K^mu2?K3-_)IrUeAQ zwjEk#@T89}*IHmGH;?xtxsm#E5v*(k5?$8_V6#+N9YG}Q+5rA?rt!u zKluu>KeUoHvs}oyo8I6)sbkpu^>jo<;b$BjL$+O7r-4(oN0ZH=9@XyJHv8hk0%M)i z;R&yJ-b978R-jtmMq(W(QY_Aj(F$@Gp1qRuYPM}qX=lF3OqAcsxzH1MSkNT=Hn}3~ zrQulAmU^#b159|u%091=BenaVDW8WW`fsex1U9rPwX!l%uR}RZ;?4{Tm*x$XW28kfhB{7FUP#>#@`Us|=;~6c zc(deuqsi$<1Qp0Dz56Y;Ya-6%uB&(F^s7hG`K-_JPbz7I*NoK04~G4$9z59?3fHyc zlmF^zxb|2-PkdH`_h;XSVxm(s(}$wJhEvhZJE+a9H@*j2hMPP()pQm8WQ&pmMf^52 ze2FF`@;CMywcK==f%LT)BKu_+bF)X0EoZARR?L zUQJd$_}@%cZiEhS=H@yG+E%`fu?iNKp8ay*SyV<`)vk78=%#NG@NLS@Dv$-Pl=K>n z4lU0l#$hr@fHxXpTUmmv0f>Aa&96a#qwGL}*BH7KUh%k#cHv1X@x1li3*q~RM80?s zIu3GeQMmB+d96y$i&Bl;`-^dy{_wdrQf$n;3ZBOx+i0qEBi+6~{h1|!L3BhO!*zq* z)ej>HElJ|G;@%o}mpkEc;~y=nB0P@vHzH0vO$b$XBvmd8OLd{G(XePr{YUEHPLe~h zE#$S=z18q+eb^Vlr@JOlIoY35J89u`QC8L9dbEwBj4W2cnVCDHe6~d3eea#4JLGHR zkA!h8YwD@SVO&f+T}L(ut_e;ss&QvVB8yB|miiy)1Pl7jX}2NoYx(=0HB1;$<0iRY zA`>x9ubW@xRC3U2(}A+AY*Nuxy$6stGKwEaTTae zvG{u+vKq`ig63WAS#TRIQrMBnxI`|-=jFHOD!ybZjXhfyxeP{bB6`Et>3-^tOG7cyQ0_H$?ZPQ%&Gan{!9MBY1nl-&EH}KBQZ8Z0~Cd zcfCX_Gnm5wxFsM8D_6~&sY^1MrVo8b>^m%OxM1qoUhKPlz1TCnwW!G_8!TJucvr$s zPFel�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 z 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

M%rf?Rwd&>l4Dre))(ul?B>zW6d)3 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

oaL(o{rj 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

6Eva$3KIFx zt}Qf)Z_9Favnc_8STm;K1!?UuyeaHl-CWsxmBbQ9AE?r!t3KN%t`{W3MmfBsN#YtO zL4)$aB}ib{|h>{Jhq&D`%&Eyq(7Gat1&?817f;I@cyb^8%3kF~aQtG}H>gZdz4-^*HV-9ic$~v)U8xvO zYzyTg8kfIrsqslcP*=N8kV?8fxGO_AMmZHS7-Vncw*iv2Pi1{n$Y^fE_R@73>px#k zy%6WJfJSAr?HB^UTI>-J+n-B-=NU zO+i}}SGJHO%Kg`~=ELFRg7~PW-LG99PAEsZhDrp3+U|P0a2kxBehUAC9M1NDtpU8< zvd~u?yQnA5(0M29e_*wCHqbbBh%ui|%uuUx58z$<6F>80vbtm;vCr?Ou&m>2v9hcK zSaUHB8Yj5Ba%$#mrunxND7S8k`z zaW>_=0d8$W2yy*vd{u*~g1~=kY3-p%3FMT-y-9Z1^L^%Kvin$E>UW6m`NYOqyR1X{ ztHFAvP`tS8(?wAQU5s=XH~aNpbkxKy@&nXW(e+oQJIs1BaM{1Sph4j=x9@}*69#lX z9_>qN+7^0c&P8Q#M?Bl#XmaCl0kSVP(BFi5yP8wDG8d)!`#48Q>mTK@PJzVCEqNwy z&2{rvx!nLSf>%rD#XUcj2LAZ;3FRwF+q=nwh?}RmPRs+AF;ML7#4yWPBnbllj`J&K_(T!%-u;~ z+^UHtaVR=1aCuOm6n^{TnVIv(S}cG$0kq8V@F2%aXN!EfwcEPw(u#rTuxaCt zWluuf`afq>GaYFoYmZMPe;exp!j?Z2zTCxs3>U$EG`eaiCB<-ZK!UB6k93($ho7}) zoGPIFM9c zI@|EHeRo?!2+QBMf7K|9SXv{Q{queZaa0QrwyAj4E`AOe3PfXS-kH14mjO$xhj@yk z?%0`oeen1VZ(IyT$GJwBn8-E7A9NS<`d|6%oYQqRGKz*;3<6VM;su>d5Dc5@&Bjfl zt{D@&4*x|S2Qs@_d|+||?C&)BOnl9!b+wJ^eH2=m8UJgGlo4iY08AhBM&w-jaM8wy znBl#sT$QBYPfj#|@f6)7P<+H1jh)#0z^didMmlGs7Gt-#M^rI&L@533HL(=SDP|oU zSLVutzvG^5!sEspA{6E1B}nZZag!!VBigf>FfEgxl@#0dhAfSZXJybA4!;P(?BG97 zZ?Np9`{?u{p6((DbDGoRH|&56oQZ7qskU4k=_+#TMTkr!x#xDr#a0G*kY}3NZnkP^ z!KGxGrDm0S+m#8Hg3H3`Voli&0t?aT1ls;~i5bC!(&|8&-=pJlXa^Zd891bmd?a`p zoWi)#d04aqNLPn3ZY0k=vf)kBUA!`VAgg4Sw}AI@xHfBP>TiWqzvX;{YH*kp%n|cA zJHUjOsf$%6*4Itha_dqouZbyohrjQtOuv8;9NIzY$fr zH6_U9SD&c~yL5)ETXSDhrFoMP3r$GlvD){TbbLA82V%G?qdZ=i$_-kJwBR5dSYF2; za?hQO=x>|7)h_)#l+Hd88_?P3F09VDbjQYCYS4X73!y*O1wO(zXI|R(8 z=IEJl)RE$$)LJZyra$dHpv-G)H@Ug{7Nmb~rMz+-ULF zEo`tr_JBmzGL|=ABcvF#)g8bX9lLFX=$4GKViqpJ;QGQAImcGwg3PC literal 220105 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�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

($=r-W!E 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 wdcgfZv4{T+aQrti_CG Date: Wed, 7 Oct 2026 13:30:26 +0400 Subject: [PATCH 16/94] android: target API 36, AGP 8.10.1 (#7656) * android: migrate back handling to callbacks * android: target API 36, AGP 8.10.1 * android: opt out of predictive back app-wide --- apps/multiplatform/android/build.gradle.kts | 4 ++-- apps/multiplatform/android/src/main/AndroidManifest.xml | 6 +++++- apps/multiplatform/common/build.gradle.kts | 6 +++--- apps/multiplatform/gradle.properties | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 419fef90b9..c16aced912 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -9,13 +9,13 @@ plugins { } android { - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "chat.simplex.app" namespace = "chat.simplex.app" minSdk = 26 - targetSdk = 35 + targetSdk = 36 // !!! // skip version code after release to F-Droid, as it uses two version codes versionCode = (extra["android.version_code"] as String).toInt() diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 64d7a03cf7..5fa9c7fefa 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -35,6 +35,8 @@ + + android:enableOnBackInvokedCallback="false" + android:theme="@style/Theme.SimpleX" + tools:ignore="UnusedAttribute"> diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index a03a7d4b27..ca19cdea3a 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -158,13 +158,13 @@ kotlin { android { namespace = "chat.simplex.common" - compileSdk = 35 + compileSdk = 36 sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { minSdk = 26 } - testOptions.targetSdk = 34 - lint.targetSdk = 34 + testOptions.targetSdk = 36 + lint.targetSdk = 36 val isAndroid = gradle.startParameter.taskNames.find { val lower = it.lowercase() lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install") diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 6224646940..069a1a20b8 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -31,7 +31,7 @@ desktop.version_name=7.1-beta.6 desktop.version_code=167 kotlin.version=2.1.20 -gradle.plugin.version=8.7.0 +gradle.plugin.version=8.10.1 compose.version=1.8.2 # Choose sqlite or postgres backend 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 17/94] 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 dd7768e543ae9f9704ee40ae7a2a95cf80dbf1b5 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:32:57 +0400 Subject: [PATCH 18/94] android, desktop: upgrade moko-resources to 0.27.1 (#7661) --- apps/multiplatform/README.md | 2 +- .../main/java/chat/simplex/app/CallService.kt | 2 +- .../java/chat/simplex/app/MainActivity.kt | 2 +- .../java/chat/simplex/app/SimplexService.kt | 2 +- .../simplex/app/model/NtfManager.android.kt | 2 +- .../simplex/app/views/call/CallActivity.kt | 2 +- apps/multiplatform/build.gradle.kts | 2 +- apps/multiplatform/common/build.gradle.kts | 29 +++++------------- .../simplex/common/helpers/Permissions.kt | 2 +- .../common/platform/Cryptor.android.kt | 2 +- .../platform/PlatformTextField.android.kt | 2 +- .../common/platform/RecAndPlay.android.kt | 2 +- .../simplex/common/platform/Share.android.kt | 2 +- .../simplex/common/platform/UI.android.kt | 2 +- .../common/platform/VideoPlayer.android.kt | 2 +- .../simplex/common/ui/theme/Type.android.kt | 14 ++++----- .../views/call/CallAudioDeviceManager.kt | 2 +- .../common/views/call/CallView.android.kt | 2 +- .../common/views/chat/ComposeView.android.kt | 2 +- .../views/chat/item/CIFileView.android.kt | 2 +- .../views/chat/item/ChatItemView.android.kt | 2 +- .../chat/item/ImageFullScreenView.android.kt | 2 +- .../views/chatlist/ChatListView.android.kt | 2 +- .../views/chatlist/TagListView.android.kt | 2 +- .../DatabaseEncryptionView.android.kt | 2 +- .../helpers/ChooseAttachmentView.android.kt | 2 +- .../views/helpers/GetImageView.android.kt | 2 +- .../common/views/helpers/Utils.android.kt | 2 +- .../views/newchat/QRCodeScanner.android.kt | 2 +- .../views/onboarding/SimpleXInfo.android.kt | 2 +- .../views/usersettings/Appearance.android.kt | 2 +- .../usersettings/PrivacySettings.android.kt | 2 +- .../usersettings/SettingsView.android.kt | 2 +- .../kotlin/chat/simplex/common/App.kt | 2 +- .../kotlin/chat/simplex/common/AppLock.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 2 +- .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- .../chat/simplex/common/platform/AppCommon.kt | 2 +- .../chat/simplex/common/platform/Core.kt | 2 +- .../chat/simplex/common/platform/Files.kt | 2 +- .../simplex/common/platform/NtfManager.kt | 2 +- .../chat/simplex/common/ui/theme/Theme.kt | 2 +- .../chat/simplex/common/views/WelcomeView.kt | 2 +- .../common/views/badges/BadgeUserPreview.kt | 2 +- .../views/badges/BadgesHowItWorksView.kt | 2 +- .../common/views/badges/BadgesLedgerView.kt | 2 +- .../common/views/badges/BadgesPayView.kt | 2 +- .../views/badges/BadgesRedeemCodeView.kt | 2 +- .../views/badges/BadgesSupportSimplexView.kt | 2 +- .../views/badges/BadgesYourBadgeView.kt | 2 +- .../views/badges/BadgesYourLevelView.kt | 2 +- .../views/badges/SupportSimpleXBanner.kt | 2 +- .../views/call/IncomingCallAlertView.kt | 2 +- .../chat/simplex/common/views/call/WebRTC.kt | 2 +- .../simplex/common/views/chat/ChatInfoView.kt | 2 +- .../common/views/chat/ChatItemInfoView.kt | 2 +- .../simplex/common/views/chat/ChatView.kt | 2 +- .../common/views/chat/CommandsMenuView.kt | 2 +- .../common/views/chat/ComposeChatLinkView.kt | 2 +- ...ComposeContextContactRequestActionsView.kt | 2 +- ...ContextGroupDirectInvitationActionsView.kt | 2 +- .../ComposeContextPendingMemberActionsView.kt | 2 +- .../chat/ComposeContextProfilePickerView.kt | 2 +- .../common/views/chat/ComposeFileView.kt | 2 +- .../common/views/chat/ComposeImageView.kt | 2 +- .../simplex/common/views/chat/ComposeView.kt | 2 +- .../common/views/chat/ComposeVoiceView.kt | 2 +- .../common/views/chat/ContactPreferences.kt | 2 +- .../common/views/chat/ContextItemView.kt | 2 +- .../simplex/common/views/chat/ScanCodeView.kt | 2 +- .../views/chat/SelectableChatItemToolbars.kt | 2 +- .../simplex/common/views/chat/SendMsgView.kt | 2 +- .../common/views/chat/SimplexNameView.kt | 2 +- .../common/views/chat/TextSelection.kt | 2 +- .../common/views/chat/VerifyCodeView.kt | 2 +- .../views/chat/group/AddGroupMembersView.kt | 2 +- .../views/chat/group/AddGroupRelayView.kt | 2 +- .../views/chat/group/ChannelMembersView.kt | 2 +- .../views/chat/group/ChannelRelaysView.kt | 2 +- .../views/chat/group/ChannelWebPageView.kt | 2 +- .../views/chat/group/GroupChatInfoView.kt | 2 +- .../common/views/chat/group/GroupLinkView.kt | 2 +- .../views/chat/group/GroupMemberInfoView.kt | 2 +- .../views/chat/group/GroupMembersToolbar.kt | 2 +- .../common/views/chat/group/GroupMentions.kt | 2 +- .../views/chat/group/GroupPreferences.kt | 2 +- .../views/chat/group/GroupProfileView.kt | 2 +- .../views/chat/group/GroupReportsView.kt | 2 +- .../views/chat/group/MemberAdmission.kt | 2 +- .../views/chat/group/MemberSupportChatView.kt | 2 +- .../views/chat/group/MemberSupportView.kt | 2 +- .../views/chat/group/WelcomeMessageView.kt | 2 +- .../views/chat/item/CIBrokenComposableView.kt | 2 +- .../common/views/chat/item/CICallItemView.kt | 2 +- .../views/chat/item/CIChatLinkHeader.kt | 2 +- .../chat/item/CIFeaturePreferenceView.kt | 2 +- .../common/views/chat/item/CIFileView.kt | 2 +- .../views/chat/item/CIGroupInvitationView.kt | 2 +- .../common/views/chat/item/CIImageView.kt | 2 +- .../views/chat/item/CIInvalidJSONView.kt | 2 +- .../chat/item/CIMemberCreatedContactView.kt | 2 +- .../common/views/chat/item/CIMetaView.kt | 2 +- .../views/chat/item/CIRcvDecryptionError.kt | 2 +- .../common/views/chat/item/CIVideoView.kt | 2 +- .../common/views/chat/item/CIVoiceView.kt | 2 +- .../common/views/chat/item/ChatItemView.kt | 2 +- .../common/views/chat/item/FramedItemView.kt | 2 +- .../views/chat/item/IntegrityErrorItemView.kt | 2 +- .../views/chat/item/MarkedDeletedItemView.kt | 2 +- .../common/views/chatlist/ChatHelpView.kt | 2 +- .../views/chatlist/ChatListNavLinkView.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 2 +- .../common/views/chatlist/ChatPreviewView.kt | 2 +- .../views/chatlist/ContactConnectionView.kt | 2 +- .../views/chatlist/ContactRequestView.kt | 2 +- .../common/views/chatlist/GetStakeBanner.kt | 2 +- .../views/chatlist/ServersSummaryView.kt | 2 +- .../views/chatlist/ShareListNavLinkView.kt | 2 +- .../common/views/chatlist/ShareListView.kt | 2 +- .../common/views/chatlist/TagListView.kt | 2 +- .../common/views/chatlist/UserPicker.kt | 2 +- .../views/contacts/ContactListNavView.kt | 2 +- .../views/contacts/ContactPreviewView.kt | 2 +- .../views/database/DatabaseEncryptionView.kt | 2 +- .../views/database/DatabaseErrorView.kt | 2 +- .../common/views/database/DatabaseView.kt | 2 +- .../common/views/helpers/AlertManager.kt | 2 +- .../common/views/helpers/AppBarTitle.kt | 2 +- .../common/views/helpers/ChatInfoImage.kt | 2 +- .../common/views/helpers/ChatWallpaper.kt | 2 +- .../common/views/helpers/CustomTimePicker.kt | 2 +- .../common/views/helpers/DatabaseUtils.kt | 2 +- .../views/helpers/DefaultBasicTextField.kt | 2 +- .../common/views/helpers/DefaultTopAppBar.kt | 2 +- .../helpers/ExposedDropDownSettingRow.kt | 2 +- .../common/views/helpers/LinkPreviews.kt | 2 +- .../views/helpers/LocalAuthentication.kt | 2 +- .../common/views/helpers/ProcessedErrors.kt | 2 +- .../common/views/helpers/SearchTextField.kt | 2 +- .../simplex/common/views/helpers/Section.kt | 2 +- .../common/views/helpers/SimpleButton.kt | 2 +- .../views/helpers/SubscriptionStatusIcon.kt | 2 +- .../common/views/helpers/ThemeModeEditor.kt | 2 +- .../simplex/common/views/helpers/Utils.kt | 2 +- .../common/views/localauth/LocalAuthView.kt | 2 +- .../common/views/localauth/PasscodeView.kt | 2 +- .../common/views/localauth/PasswordEntry.kt | 2 +- .../views/localauth/SetAppPasscodeView.kt | 2 +- .../views/migration/MigrateFromDevice.kt | 2 +- .../common/views/migration/MigrateToDevice.kt | 2 +- .../common/views/newchat/AddChannelView.kt | 2 +- .../views/newchat/AddContactLearnMore.kt | 2 +- .../common/views/newchat/AddGroupView.kt | 2 +- .../common/views/newchat/ConnectPlan.kt | 2 +- .../newchat/ContactConnectionInfoView.kt | 2 +- .../common/views/newchat/NewChatSheet.kt | 2 +- .../common/views/newchat/NewChatView.kt | 2 +- .../common/views/newchat/OnboardingCards.kt | 2 +- .../simplex/common/views/newchat/QRCode.kt | 2 +- .../views/onboarding/ChooseServerOperators.kt | 2 +- .../common/views/onboarding/HowItWorks.kt | 2 +- .../views/onboarding/LinkAMobileView.kt | 2 +- .../views/onboarding/OnboardingLayout.kt | 2 +- .../views/onboarding/SetNotificationsMode.kt | 2 +- .../onboarding/SetupDatabasePassphrase.kt | 2 +- .../common/views/onboarding/SimpleXInfo.kt | 2 +- .../common/views/onboarding/WhatsNewView.kt | 2 +- .../common/views/onboarding/YourNetwork.kt | 2 +- .../common/views/remote/ConnectDesktopView.kt | 2 +- .../common/views/remote/ConnectMobileView.kt | 2 +- .../common/views/usersettings/Appearance.kt | 2 +- .../common/views/usersettings/CallSettings.kt | 2 +- .../views/usersettings/DeveloperView.kt | 2 +- .../common/views/usersettings/HelpView.kt | 2 +- .../views/usersettings/HiddenProfileView.kt | 2 +- .../views/usersettings/IncognitoView.kt | 2 +- .../views/usersettings/MarkdownHelpView.kt | 2 +- .../usersettings/NotificationsSettingsView.kt | 2 +- .../common/views/usersettings/Preferences.kt | 2 +- .../views/usersettings/PrivacySettings.kt | 2 +- .../common/views/usersettings/RTCServers.kt | 2 +- .../usersettings/SetDeliveryReceiptsView.kt | 2 +- .../views/usersettings/SetSimplexNameView.kt | 2 +- .../common/views/usersettings/SettingsView.kt | 2 +- .../usersettings/UserAddressLearnMore.kt | 2 +- .../views/usersettings/UserAddressView.kt | 2 +- .../views/usersettings/UserProfileView.kt | 2 +- .../views/usersettings/UserProfilesView.kt | 2 +- .../views/usersettings/VersionInfoView.kt | 2 +- .../AdvancedNetworkSettings.kt | 2 +- .../networkAndServers/ChatRelayView.kt | 2 +- .../networkAndServers/NetworkAndServers.kt | 2 +- .../networkAndServers/NewServerView.kt | 2 +- .../networkAndServers/OperatorView.kt | 2 +- .../networkAndServers/ProtocolServerView.kt | 2 +- .../networkAndServers/ProtocolServersView.kt | 2 +- .../networkAndServers/ScanProtocolServer.kt | 2 +- .../MR => moko-resources}/ar/strings.xml | 0 .../MR => moko-resources}/az/strings.xml | 0 .../MR => moko-resources}/base/strings.xml | 0 .../MR => moko-resources}/bg/strings.xml | 0 .../MR => moko-resources}/bn/strings.xml | 0 .../MR => moko-resources}/ca/strings.xml | 0 .../MR => moko-resources}/cs/strings.xml | 0 .../MR => moko-resources}/da/strings.xml | 0 .../MR => moko-resources}/de/strings.xml | 0 .../MR => moko-resources}/el/strings.xml | 0 .../MR => moko-resources}/es/strings.xml | 0 .../MR => moko-resources}/fa/strings.xml | 0 .../MR => moko-resources}/fi/strings.xml | 0 .../fonts/Inter-Bold.ttf | Bin .../fonts/Inter-Italic.ttf | Bin .../fonts/Inter-Light.ttf | Bin .../fonts/Inter-Medium.ttf | Bin .../fonts/Inter-Regular.ttf | Bin .../fonts/Inter-Semibold.ttf | Bin .../MR => moko-resources}/fr/strings.xml | 0 .../MR => moko-resources}/hi/strings.xml | 0 .../MR => moko-resources}/hr/strings.xml | 0 .../MR => moko-resources}/hu/strings.xml | 0 .../images/badge_investor.svg | 0 .../images/badge_legend.svg | 0 .../images/badge_supporter.svg | 0 .../images/decentralized@1.5x.png | Bin .../images/decentralized@1x.png | Bin .../images/decentralized@2x.png | Bin .../images/decentralized@3x.png | Bin .../images/decentralized@4x.png | Bin .../images/decentralized_light@1.5x.png | Bin .../images/decentralized_light@1x.png | Bin .../images/decentralized_light@2x.png | Bin .../images/decentralized_light@3x.png | Bin .../images/decentralized_light@4x.png | Bin .../images/flux_logo@4x.png | Bin .../images/flux_logo_light@4x.png | Bin .../images/flux_logo_symbol@4x.png | Bin .../images/ic_account_box.svg | 0 .../images/ic_account_circle_filled.svg | 0 .../MR => moko-resources}/images/ic_add.svg | 0 .../images/ic_add_group.svg | 0 .../images/ic_add_link.svg | 0 .../images/ic_add_photo.svg | 0 .../images/ic_add_reaction.svg | 0 .../images/ic_add_reaction_filled.svg | 0 .../images/ic_all_inclusive.svg | 0 .../images/ic_alternate_email.svg | 0 .../images/ic_arrow_back_ios_new.svg | 0 .../images/ic_arrow_circle_right.svg | 0 .../images/ic_arrow_downward.svg | 0 .../images/ic_arrow_drop_down.svg | 0 .../images/ic_arrow_drop_up.svg | 0 .../images/ic_arrow_forward.svg | 0 .../images/ic_arrow_forward_ios.svg | 0 .../images/ic_arrow_outward.svg | 0 .../images/ic_arrow_upward.svg | 0 .../images/ic_arrows_left_right.svg | 0 .../images/ic_article.svg | 0 .../MR => moko-resources}/images/ic_at.svg | 0 .../images/ic_attach_file_filled.svg | 0 .../images/ic_attach_file_filled_500.svg | 0 .../images/ic_avg_pace.svg | 0 .../images/ic_back_hand.svg | 0 .../images/ic_backspace.svg | 0 .../images/ic_backup.svg | 0 .../images/ic_battery_2_bar.svg | 0 .../images/ic_battery_3_bar.svg | 0 .../images/ic_bedtime_moon.svg | 0 .../images/ic_bigtop_updates.svg | 0 .../ic_bigtop_updates_circle_filled.svg | 0 .../images/ic_bluetooth.svg | 0 .../images/ic_blur_on.svg | 0 .../MR => moko-resources}/images/ic_bolt.svg | 0 .../images/ic_bolt_filled.svg | 0 .../images/ic_bolt_off.svg | 0 .../images/ic_brand_awareness_filled.svg | 0 .../images/ic_breaking_news.svg | 0 .../MR => moko-resources}/images/ic_cable.svg | 0 .../images/ic_calendar.svg | 0 .../MR => moko-resources}/images/ic_call.svg | 0 .../images/ic_call_500.svg | 0 .../images/ic_call_end.svg | 0 .../images/ic_call_end_filled.svg | 0 .../images/ic_call_filled.svg | 0 .../images/ic_camera_enhance.svg | 0 .../images/ic_cancel_filled.svg | 0 .../MR => moko-resources}/images/ic_chat.svg | 0 .../images/ic_chat_bubble.svg | 0 .../images/ic_chat_person.svg | 0 .../MR => moko-resources}/images/ic_check.svg | 0 .../images/ic_check_circle.svg | 0 .../images/ic_check_circle_filled.svg | 0 .../images/ic_check_filled.svg | 0 .../images/ic_checklist.svg | 0 .../images/ic_chevron_down.svg | 0 .../images/ic_chevron_right.svg | 0 .../images/ic_chevron_right_2.svg | 0 .../images/ic_chevron_up.svg | 0 .../images/ic_circle.svg | 0 .../images/ic_circle_filled.svg | 0 .../MR => moko-resources}/images/ic_close.svg | 0 .../MR => moko-resources}/images/ic_code.svg | 0 .../images/ic_collapse_all.svg | 0 .../images/ic_contact_support.svg | 0 .../images/ic_content_copy.svg | 0 .../images/ic_content_paste.svg | 0 .../MR => moko-resources}/images/ic_cube.svg | 0 .../images/ic_database.svg | 0 .../images/ic_delete.svg | 0 .../images/ic_delete_forever.svg | 0 .../images/ic_delete_forever_filled.svg | 0 .../images/ic_desktop.svg | 0 .../MR => moko-resources}/images/ic_dns.svg | 0 .../images/ic_do_not_disturb_on.svg | 0 .../images/ic_do_not_touch.svg | 0 .../images/ic_done_filled.svg | 0 .../images/ic_double_check.svg | 0 .../images/ic_download.svg | 0 .../MR => moko-resources}/images/ic_draft.svg | 0 .../images/ic_draft_filled.svg | 0 .../images/ic_drag_handle.svg | 0 .../images/ic_drive_folder_upload.svg | 0 .../MR => moko-resources}/images/ic_edit.svg | 0 .../images/ic_edit_filled.svg | 0 .../images/ic_edit_note.svg | 0 .../images/ic_electrical_services.svg | 0 .../images/ic_engineering.svg | 0 .../MR => moko-resources}/images/ic_error.svg | 0 .../images/ic_error_filled.svg | 0 .../images/ic_expand_all.svg | 0 .../images/ic_filter_list.svg | 0 .../MR => moko-resources}/images/ic_flag.svg | 0 .../images/ic_flag_filled.svg | 0 .../images/ic_flip_camera_android_filled.svg | 0 .../images/ic_folder_closed.svg | 0 .../images/ic_folder_closed_filled.svg | 0 .../images/ic_folder_filled.svg | 0 .../images/ic_folder_open.svg | 0 .../images/ic_folder_pen.svg | 0 .../MR => moko-resources}/images/ic_forum.svg | 0 .../images/ic_forward.svg | 0 .../images/ic_github.svg | 0 .../MR => moko-resources}/images/ic_group.svg | 0 .../images/ic_group_filled.svg | 0 .../images/ic_group_off.svg | 0 .../images/ic_headphones.svg | 0 .../images/ic_heart@4x.png | Bin .../MR => moko-resources}/images/ic_help.svg | 0 .../images/ic_help_filled.svg | 0 .../images/ic_history.svg | 0 .../images/ic_id_card.svg | 0 .../MR => moko-resources}/images/ic_image.svg | 0 .../images/ic_image_filled.svg | 0 .../MR => moko-resources}/images/ic_info.svg | 0 .../images/ic_inventory_2.svg | 0 .../images/ic_ios_share.svg | 0 .../images/ic_keyboard.svg | 0 .../images/ic_keyboard_arrow_down.svg | 0 .../images/ic_keyboard_voice.svg | 0 .../images/ic_keyboard_voice_filled.svg | 0 .../MR => moko-resources}/images/ic_label.svg | 0 .../images/ic_label_filled.svg | 0 .../images/ic_light_mode.svg | 0 .../images/ic_lightbulb.svg | 0 .../MR => moko-resources}/images/ic_link.svg | 0 .../MR => moko-resources}/images/ic_lock.svg | 0 .../images/ic_lock_filled.svg | 0 .../images/ic_lock_open_right.svg | 0 .../MR => moko-resources}/images/ic_login.svg | 0 .../images/ic_logout.svg | 0 .../MR => moko-resources}/images/ic_mail.svg | 0 .../images/ic_mail_filled.svg | 0 .../images/ic_manage_accounts.svg | 0 .../images/ic_maps_ugc.svg | 0 .../images/ic_mark_chat_unread.svg | 0 .../images/ic_match_case.svg | 0 .../MR => moko-resources}/images/ic_menu.svg | 0 .../MR => moko-resources}/images/ic_mic.svg | 0 .../images/ic_mic_filled.svg | 0 .../images/ic_mic_off.svg | 0 .../images/ic_mobile_3.svg | 0 .../images/ic_mobile_4.svg | 0 .../images/ic_more_horiz.svg | 0 .../images/ic_more_vert.svg | 0 .../images/ic_music_note.svg | 0 .../images/ic_note_add.svg | 0 .../images/ic_notification_important.svg | 0 .../ic_notification_important_filled.svg | 0 .../images/ic_notifications.svg | 0 .../images/ic_notifications_off.svg | 0 .../images/ic_notifications_off_filled.svg | 0 .../images/ic_open_in_new.svg | 0 .../images/ic_outbound.svg | 0 .../images/ic_outline_terminal.svg | 0 .../images/ic_palette.svg | 0 .../images/ic_pause_filled.svg | 0 .../images/ic_pending.svg | 0 .../images/ic_pending_filled.svg | 0 .../images/ic_person.svg | 0 .../images/ic_person_add.svg | 0 .../images/ic_person_add_500.svg | 0 .../images/ic_person_add_filled.svg | 0 .../images/ic_person_edit.svg | 0 .../images/ic_person_filled.svg | 0 .../images/ic_person_off.svg | 0 .../images/ic_phone_bluetooth_speaker.svg | 0 .../images/ic_phone_in_talk_filled.svg | 0 .../images/ic_photo_camera.svg | 0 .../images/ic_photo_library.svg | 0 .../images/ic_photo_library_filled.svg | 0 .../images/ic_play_arrow_filled.svg | 0 .../images/ic_power_settings_new.svg | 0 .../images/ic_priority_high.svg | 0 .../images/ic_qr_code.svg | 0 .../images/ic_qr_code_scanner.svg | 0 .../images/ic_question_mark.svg | 0 .../images/ic_radio_button_unchecked.svg | 0 .../images/ic_radiowaves_up_forward_1_bar.svg | 0 .../images/ic_radiowaves_up_forward_2_bar.svg | 0 .../images/ic_radiowaves_up_forward_3_bar.svg | 0 .../images/ic_radiowaves_up_forward_4_bar.svg | 0 .../images/ic_redeem.svg | 0 .../images/ic_refresh.svg | 0 .../images/ic_repeat_one.svg | 0 .../images/ic_replay.svg | 0 .../MR => moko-resources}/images/ic_reply.svg | 0 .../images/ic_report.svg | 0 .../images/ic_report_filled.svg | 0 .../images/ic_restart_alt.svg | 0 .../images/ic_ring_volume.svg | 0 .../images/ic_safety_divider.svg | 0 .../images/ic_schedule.svg | 0 .../images/ic_schedule_filled.svg | 0 .../images/ic_search.svg | 0 .../images/ic_search_500.svg | 0 .../images/ic_security.svg | 0 .../images/ic_settings.svg | 0 .../images/ic_settings_backup_restore.svg | 0 .../images/ic_settings_ethernet.svg | 0 .../images/ic_settings_phone.svg | 0 .../MR => moko-resources}/images/ic_share.svg | 0 .../images/ic_share_filled.svg | 0 .../images/ic_shield.svg | 0 .../images/ic_simplex.svg | 0 .../images/ic_simplex_dark@4x.png | Bin .../images/ic_simplex_light@4x.png | Bin .../images/ic_simplex_tray_dot.svg | 0 .../images/ic_simplex_tray_dot_light.svg | 0 .../images/ic_simplex_tray_light.svg | 0 .../images/ic_smart_display.svg | 0 .../images/ic_smartphone.svg | 0 .../images/ic_smartphone_300.svg | 0 .../MR => moko-resources}/images/ic_star.svg | 0 .../images/ic_star_filled.svg | 0 .../images/ic_star_off.svg | 0 .../MR => moko-resources}/images/ic_start.svg | 0 .../images/ic_stop_filled.svg | 0 .../ic_supervised_user_circle_filled.svg | 0 .../images/ic_swap_horizontal_circle.svg | 0 .../ic_swap_horizontal_circle_filled.svg | 0 .../images/ic_sync_problem.svg | 0 .../MR => moko-resources}/images/ic_tag.svg | 0 .../MR => moko-resources}/images/ic_task.svg | 0 .../images/ic_theater_comedy.svg | 0 .../images/ic_theater_comedy_filled.svg | 0 .../MR => moko-resources}/images/ic_timer.svg | 0 .../images/ic_timer_filled.svg | 0 .../MR => moko-resources}/images/ic_toast.svg | 0 .../images/ic_toggle_on.svg | 0 .../images/ic_translate.svg | 0 .../images/ic_travel_explore.svg | 0 .../images/ic_upgrade.svg | 0 .../images/ic_upload_file.svg | 0 .../MR => moko-resources}/images/ic_usb.svg | 0 .../images/ic_verified.svg | 0 .../images/ic_verified_filled.svg | 0 .../images/ic_verified_missing.svg | 0 .../images/ic_verified_user.svg | 0 .../images/ic_videocam.svg | 0 .../images/ic_videocam_filled.svg | 0 .../images/ic_videocam_off.svg | 0 .../images/ic_visibility.svg | 0 .../images/ic_visibility_filled.svg | 0 .../images/ic_visibility_off.svg | 0 .../images/ic_visibility_off_filled.svg | 0 .../images/ic_volume_down.svg | 0 .../images/ic_volume_off.svg | 0 .../images/ic_volume_up.svg | 0 .../images/ic_vpn_key_filled.svg | 0 .../images/ic_vpn_key_off_filled.svg | 0 .../images/ic_warning.svg | 0 .../images/ic_warning_filled.svg | 0 .../images/ic_waving_hand.svg | 0 .../MR => moko-resources}/images/ic_wifi.svg | 0 .../images/ic_wifi_off.svg | 0 .../images/ic_wifi_tethering.svg | 0 .../MR => moko-resources}/images/ic_work.svg | 0 .../images/ic_work_filled.svg | 0 .../images/ic_work_filled_padded.svg | 0 .../images/icon_foreground_common@4x.png | Bin .../MR => moko-resources}/images/logo@4x.png | Bin .../images/logo_light@4x.png | Bin .../images/privacy@1.5x.png | Bin .../images/privacy@1x.png | Bin .../images/privacy@2x.png | Bin .../images/privacy@3x.png | Bin .../images/privacy@4x.png | Bin .../images/shield@1.5x.png | Bin .../images/shield@1x.png | Bin .../images/shield@2x.png | Bin .../images/shield@3x.png | Bin .../images/shield@4x.png | Bin .../images/wallpaper_cats@4x.png | Bin .../images/wallpaper_flowers@4x.png | Bin .../images/wallpaper_hearts@4x.png | Bin .../images/wallpaper_kids@4x.png | Bin .../images/wallpaper_school@4x.png | Bin .../images/wallpaper_travel@4x.png | Bin .../MR => moko-resources}/in/strings.xml | 0 .../MR => moko-resources}/it/strings.xml | 0 .../MR => moko-resources}/iw/strings.xml | 0 .../MR => moko-resources}/ja/strings.xml | 0 .../MR => moko-resources}/ko/strings.xml | 0 .../MR => moko-resources}/ku/strings.xml | 0 .../MR => moko-resources}/lt/strings.xml | 0 .../MR => moko-resources}/lv/strings.xml | 0 .../MR => moko-resources}/ml/strings.xml | 0 .../MR => moko-resources}/nb-rNO/strings.xml | 0 .../MR => moko-resources}/night/themes.xml | 0 .../MR => moko-resources}/nl/strings.xml | 0 .../MR => moko-resources}/pl/strings.xml | 0 .../MR => moko-resources}/pt-rBR/strings.xml | 0 .../MR => moko-resources}/pt/strings.xml | 0 .../MR => moko-resources}/ro/strings.xml | 0 .../MR => moko-resources}/ru/strings.xml | 0 .../MR => moko-resources}/sk/strings.xml | 0 .../MR => moko-resources}/sv/strings.xml | 0 .../MR => moko-resources}/th/strings.xml | 0 .../MR => moko-resources}/tr/strings.xml | 0 .../MR => moko-resources}/uk/strings.xml | 0 .../MR => moko-resources}/vi/strings.xml | 0 .../MR => moko-resources}/zh-rCN/strings.xml | 0 .../MR => moko-resources}/zh-rTW/strings.xml | 0 .../kotlin/chat/simplex/common/DesktopApp.kt | 2 +- .../kotlin/chat/simplex/common/DesktopTray.kt | 2 +- .../chat/simplex/common/SingleInstance.kt | 1 + .../common/model/NtfManager.desktop.kt | 2 +- .../common/platform/AppCommon.desktop.kt | 2 +- .../simplex/common/platform/Files.desktop.kt | 2 +- .../simplex/common/platform/Images.desktop.kt | 2 +- .../platform/PlatformTextField.desktop.kt | 2 +- .../common/platform/RecAndPlay.desktop.kt | 2 +- .../common/platform/Resources.desktop.kt | 2 +- .../simplex/common/platform/Share.desktop.kt | 2 +- .../common/platform/VideoPlayer.desktop.kt | 2 +- .../simplex/common/ui/theme/Type.desktop.kt | 26 ++++++++-------- .../common/views/call/CallView.desktop.kt | 2 +- .../views/chat/item/ChatItemView.desktop.kt | 2 +- .../views/chat/item/EmojiItemView.desktop.kt | 2 +- .../chat/item/ImageFullScreenView.desktop.kt | 2 +- .../views/chatlist/ChatListView.desktop.kt | 2 +- .../views/chatlist/TagListView.desktop.kt | 2 +- .../DatabaseEncryptionView.desktop.kt | 2 +- .../common/views/helpers/AppUpdater.kt | 2 +- .../helpers/ChooseAttachmentView.desktop.kt | 2 +- .../views/helpers/DefaultDialog.desktop.kt | 2 +- .../views/helpers/GetImageView.desktop.kt | 2 +- .../views/onboarding/SimpleXInfo.desktop.kt | 2 +- .../views/usersettings/Appearance.desktop.kt | 2 +- .../usersettings/PrivacySettings.desktop.kt | 2 +- .../usersettings/SettingsView.desktop.kt | 2 +- .../fonts/NotoColorEmoji-Regular.ttf | Bin 571 files changed, 250 insertions(+), 262 deletions(-) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/ar/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/az/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/base/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/bg/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/bn/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/ca/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/cs/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/da/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/de/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/el/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/es/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fa/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fi/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fonts/Inter-Bold.ttf (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fonts/Inter-Italic.ttf (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fonts/Inter-Light.ttf (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fonts/Inter-Medium.ttf (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fonts/Inter-Regular.ttf (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fonts/Inter-Semibold.ttf (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/fr/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/hi/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/hr/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/hu/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/badge_investor.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/badge_legend.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/badge_supporter.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized@1.5x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized@1x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized@2x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized@3x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized_light@1.5x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized_light@1x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized_light@2x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized_light@3x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/decentralized_light@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/flux_logo@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/flux_logo_light@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/flux_logo_symbol@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_account_box.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_account_circle_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_add.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_add_group.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_add_link.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_add_photo.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_add_reaction.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_add_reaction_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_all_inclusive.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_alternate_email.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_back_ios_new.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_circle_right.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_downward.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_drop_down.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_drop_up.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_forward.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_forward_ios.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_outward.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrow_upward.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_arrows_left_right.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_article.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_at.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_attach_file_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_attach_file_filled_500.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_avg_pace.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_back_hand.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_backspace.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_backup.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_battery_2_bar.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_battery_3_bar.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_bedtime_moon.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_bigtop_updates.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_bigtop_updates_circle_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_bluetooth.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_blur_on.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_bolt.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_bolt_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_bolt_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_brand_awareness_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_breaking_news.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_cable.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_calendar.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_call.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_call_500.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_call_end.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_call_end_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_call_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_camera_enhance.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_cancel_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_chat.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_chat_bubble.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_chat_person.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_check.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_check_circle.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_check_circle_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_check_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_checklist.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_chevron_down.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_chevron_right.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_chevron_right_2.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_chevron_up.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_circle.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_circle_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_close.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_code.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_collapse_all.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_contact_support.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_content_copy.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_content_paste.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_cube.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_database.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_delete.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_delete_forever.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_delete_forever_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_desktop.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_dns.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_do_not_disturb_on.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_do_not_touch.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_done_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_double_check.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_download.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_draft.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_draft_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_drag_handle.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_drive_folder_upload.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_edit.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_edit_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_edit_note.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_electrical_services.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_engineering.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_error.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_error_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_expand_all.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_filter_list.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_flag.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_flag_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_flip_camera_android_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_folder_closed.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_folder_closed_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_folder_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_folder_open.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_folder_pen.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_forum.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_forward.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_github.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_group.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_group_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_group_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_headphones.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_heart@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_help.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_help_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_history.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_id_card.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_image.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_image_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_info.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_inventory_2.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_ios_share.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_keyboard.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_keyboard_arrow_down.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_keyboard_voice.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_keyboard_voice_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_label.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_label_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_light_mode.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_lightbulb.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_link.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_lock.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_lock_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_lock_open_right.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_login.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_logout.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_mail.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_mail_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_manage_accounts.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_maps_ugc.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_mark_chat_unread.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_match_case.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_menu.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_mic.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_mic_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_mic_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_mobile_3.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_mobile_4.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_more_horiz.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_more_vert.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_music_note.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_note_add.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_notification_important.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_notification_important_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_notifications.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_notifications_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_notifications_off_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_open_in_new.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_outbound.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_outline_terminal.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_palette.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_pause_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_pending.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_pending_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_person.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_person_add.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_person_add_500.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_person_add_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_person_edit.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_person_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_person_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_phone_bluetooth_speaker.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_phone_in_talk_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_photo_camera.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_photo_library.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_photo_library_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_play_arrow_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_power_settings_new.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_priority_high.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_qr_code.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_qr_code_scanner.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_question_mark.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_radio_button_unchecked.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_radiowaves_up_forward_1_bar.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_radiowaves_up_forward_2_bar.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_radiowaves_up_forward_3_bar.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_radiowaves_up_forward_4_bar.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_redeem.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_refresh.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_repeat_one.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_replay.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_reply.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_report.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_report_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_restart_alt.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_ring_volume.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_safety_divider.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_schedule.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_schedule_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_search.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_search_500.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_security.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_settings.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_settings_backup_restore.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_settings_ethernet.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_settings_phone.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_share.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_share_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_shield.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_simplex.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_simplex_dark@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_simplex_light@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_simplex_tray_dot.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_simplex_tray_dot_light.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_simplex_tray_light.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_smart_display.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_smartphone.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_smartphone_300.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_star.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_star_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_star_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_start.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_stop_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_supervised_user_circle_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_swap_horizontal_circle.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_swap_horizontal_circle_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_sync_problem.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_tag.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_task.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_theater_comedy.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_theater_comedy_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_timer.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_timer_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_toast.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_toggle_on.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_translate.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_travel_explore.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_upgrade.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_upload_file.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_usb.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_verified.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_verified_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_verified_missing.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_verified_user.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_videocam.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_videocam_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_videocam_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_visibility.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_visibility_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_visibility_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_visibility_off_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_volume_down.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_volume_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_volume_up.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_vpn_key_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_vpn_key_off_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_warning.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_warning_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_waving_hand.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_wifi.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_wifi_off.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_wifi_tethering.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_work.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_work_filled.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/ic_work_filled_padded.svg (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/icon_foreground_common@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/logo@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/logo_light@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/privacy@1.5x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/privacy@1x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/privacy@2x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/privacy@3x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/privacy@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/shield@1.5x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/shield@1x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/shield@2x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/shield@3x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/shield@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/wallpaper_cats@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/wallpaper_flowers@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/wallpaper_hearts@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/wallpaper_kids@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/wallpaper_school@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/images/wallpaper_travel@4x.png (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/in/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/it/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/iw/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/ja/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/ko/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/ku/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/lt/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/lv/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/ml/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/nb-rNO/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/night/themes.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/nl/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/pl/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/pt-rBR/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/pt/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/ro/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/ru/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/sk/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/sv/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/th/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/tr/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/uk/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/vi/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/zh-rCN/strings.xml (100%) rename apps/multiplatform/common/src/commonMain/{resources/MR => moko-resources}/zh-rTW/strings.xml (100%) rename apps/multiplatform/common/src/{commonMain/resources/MR => desktopMain/moko-resources}/fonts/NotoColorEmoji-Regular.ttf (100%) diff --git a/apps/multiplatform/README.md b/apps/multiplatform/README.md index 0d3c5b24f4..8fc0f701f3 100644 --- a/apps/multiplatform/README.md +++ b/apps/multiplatform/README.md @@ -101,7 +101,7 @@ Tests are in: ## Resources & Localization -- String resources: `common/src/commonMain/resources/MR/base/strings.xml` + 21 language variants +- String resources: `common/src/commonMain/moko-resources/base/strings.xml` + 21 language variants - Uses Moko Resources (`dev.icerock.moko:resources`) for cross-platform resource management - The `adjustFormatting` gradle task validates string resources during build diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt index 6c3d96bebc..96403588ac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt @@ -16,7 +16,7 @@ import chat.simplex.app.views.call.CallActivity import chat.simplex.common.model.NotificationPreviewMode import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant class CallService: Service() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 7415b6015d..d1022818ac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index 289ecc0a31..7a6d9db47a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -29,7 +29,7 @@ import chat.simplex.common.model.NotificationsMode import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import kotlinx.coroutines.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlin.system.exitProcess diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt index 5ce69aeec2..96ebd6179a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt @@ -22,7 +22,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.call.RcvCallInvitation import kotlinx.datetime.Clock -import chat.simplex.res.MR +import chat.simplex.res.* object NtfManager { const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION" diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt index 771ada0cd6..e1edc5ebe3 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -45,7 +45,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.launch diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 64dd34afbf..ee64060206 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -48,7 +48,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}") classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String)) - classpath("dev.icerock.moko:resources-generator:0.23.0") + classpath("dev.icerock.moko:resources-generator:0.27.1") // Workaround gradle sync issue: https://github.com/gmazzo/gradle-buildconfig-plugin/issues/137#issuecomment-1935739759 classpath("com.squareup:kotlinpoet:1.16.0") diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index ca19cdea3a..0ff63fa8de 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -56,11 +56,6 @@ kotlin { } val commonMain by getting { - if (hasSimplexAssets) { - resources.srcDir(simplexAssetsLocal) - } else { - resources.srcDir("src/commonMain/resources/assets/default") - } dependencies { api(compose.runtime) api(compose.foundation) @@ -78,8 +73,8 @@ kotlin { // Link Previews implementation("org.jsoup:jsoup:1.17.2") // Resources - api("dev.icerock.moko:resources:0.23.0") - api("dev.icerock.moko:resources-compose:0.23.0") + api("dev.icerock.moko:resources:0.27.1") + api("dev.icerock.moko:resources-compose:0.27.1") // Markdown implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0") @@ -95,7 +90,6 @@ kotlin { } } val androidMain by getting { - kotlin.srcDir("build/generated/moko/androidMain/src") dependencies { implementation("androidx.activity:activity-compose:1.9.1") val workVersion = "2.9.1" @@ -165,20 +159,13 @@ android { } testOptions.targetSdk = 36 lint.targetSdk = 36 - val isAndroid = gradle.startParameter.taskNames.find { - val lower = it.lowercase() - lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install") - } != null - if (isAndroid) { - // This is not needed on Android but can't be moved to desktopMain because MR lib don't support this. - // No other ways to exclude a file work, but it's large and should be excluded - kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf") - } } multiplatformResources { - multiplatformResourcesPackage = "chat.simplex.res" - // multiplatformResourcesClassName = "MR" + resourcesPackage.set("chat.simplex.res") + resourcesSourceSets { + getByName("commonMain").srcDirs(if (hasSimplexAssets) simplexAssetsLocal.resolve("MR") else file("src/commonMain/resources/assets/default/MR")) + } } buildConfig { @@ -286,8 +273,8 @@ afterEvaluate { } return this } - val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$") - val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree + val fileRegex = Regex("moko-resources/../strings.xml$|moko-resources/..-.../strings.xml$|moko-resources/..-../strings.xml$|moko-resources/base/strings.xml$") + val tree = fileTree("src/commonMain/moko-resources").filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found") val treeList = ArrayList(tree.toList()) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt index bfb45d7413..74c9cf58a1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt @@ -6,7 +6,7 @@ import android.provider.Settings import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* fun Context.openAppSettingsInSystem() { Intent().apply { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt index b1a0307cde..73be3a203c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt @@ -8,7 +8,7 @@ import android.security.keystore.KeyProperties import android.security.keystore.StrongBoxUnavailableException import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import java.security.KeyStore import javax.crypto.* import javax.crypto.spec.GCMParameterSpec diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 4f48ccca52..71d7ff3cb4 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -39,7 +39,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index e5dda23f0f..90fae139ce 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -10,7 +10,7 @@ import androidx.compose.runtime.* import chat.simplex.common.model.* import chat.simplex.common.platform.AudioPlayer.duration import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import java.io.* diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index 79f0a8636b..6592557a7a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -15,7 +15,7 @@ import androidx.core.graphics.drawable.toBitmap import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.io.BufferedOutputStream import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index f6066d1624..9dbcb6d263 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -17,7 +17,7 @@ import chat.simplex.common.AppScreen import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt index 61b404f7d6..9949a21f6f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.ImageBitmap import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.android.exoplayer2.* import com.google.android.exoplayer2.C.* import com.google.android.exoplayer2.audio.AudioAttributes diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt index 2e7404f241..9e44957a2a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt @@ -1,15 +1,15 @@ package chat.simplex.common.ui.theme import androidx.compose.ui.text.font.* -import chat.simplex.res.MR +import chat.simplex.res.* actual val Inter: FontFamily = FontFamily( - Font(MR.fonts.Inter.regular.fontResourceId), - Font(MR.fonts.Inter.italic.fontResourceId, style = FontStyle.Italic), - Font(MR.fonts.Inter.bold.fontResourceId, FontWeight.Bold), - Font(MR.fonts.Inter.semibold.fontResourceId, FontWeight.SemiBold), - Font(MR.fonts.Inter.medium.fontResourceId, FontWeight.Medium), - Font(MR.fonts.Inter.light.fontResourceId, FontWeight.Light) + Font(MR.fonts.inter_regular.fontResourceId), + Font(MR.fonts.inter_italic.fontResourceId, style = FontStyle.Italic), + Font(MR.fonts.inter_bold.fontResourceId, FontWeight.Bold), + Font(MR.fonts.inter_semibold.fontResourceId, FontWeight.SemiBold), + Font(MR.fonts.inter_medium.fontResourceId, FontWeight.Medium), + Font(MR.fonts.inter_light.fontResourceId, FontWeight.Light) ) actual val EmojiFont: FontFamily = FontFamily.Default diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt index 0c23e95285..ff3bf11db1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt @@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi import androidx.compose.runtime.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.ImageResource -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import java.util.concurrent.Executors diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 9ad787a6b1..dfe38f197a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -46,7 +46,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt index 719dfeea55..95438d3d0e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt @@ -11,7 +11,7 @@ import androidx.core.content.ContextCompat import chat.simplex.common.helpers.toURI import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.net.URI @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt index e81827cb9a..b09ef7c918 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.* import chat.simplex.common.model.CryptoFile import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.DefaultDropdownMenu -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index b0bc6dd970..e16f99bf56 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.FileChooserLauncher import chat.simplex.common.platform.saveImage import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import dev.icerock.moko.resources.compose.painterResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index dad8872012..61d2f54318 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible import chat.simplex.common.platform.VideoPlayer import chat.simplex.common.platform.androidAppContext -import chat.simplex.res.MR +import chat.simplex.res.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt index 8c3b161a5c..256d24a48c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -18,7 +18,7 @@ import chat.simplex.common.model.durationText import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.delay import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt index ab6d375d75..a4be67473e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt index b472e6be3a..a81d9cd1ee 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.SimplexGreen import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt index 3dace5f9bc..4c980acf91 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt index 1c7ba1dcf0..d499b7ab3f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt @@ -33,7 +33,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.json import chat.simplex.common.platform.* import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.builtins.* import java.io.File import java.net.URI 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 de6834ba41..371bd7676b 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 @@ -24,7 +24,7 @@ import androidx.core.text.HtmlCompat import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import java.io.* import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index 6cf432a15f..416bd359c0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -27,7 +27,7 @@ import boofcv.struct.image.GrayU8 import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.google.common.util.concurrent.ListenableFuture diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt index a4fc74f6d4..0e1cf9472a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import chat.simplex.common.model.SharedPreference import chat.simplex.common.model.User -import chat.simplex.res.MR +import chat.simplex.res.* @Composable actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt index c16d1ea90d..e5ae2b8a9b 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -32,7 +32,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.helpers.APPLICATION_ID import chat.simplex.common.helpers.saveAppLocale import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt index 3c1771c8b3..230dee8851 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity import chat.simplex.common.model.ChatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt index 5e9706d713..4da9bf03b0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.jakewharton.processphoenix.ProcessPhoenix import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource 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 9883828950..656911443a 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 @@ -37,7 +37,7 @@ import chat.simplex.common.views.localauth.VerticalDivider import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index c38e20aae8..9b42792e8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -10,7 +10,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.localauth.SetAppPasscodeView import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* // Spec: spec/client/navigation.md#AppLock 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 afad095a60..c10471317e 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 @@ -18,7 +18,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState import chat.simplex.common.views.migration.MigrationToState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* 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 e3bea95c03..706ee713fc 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 @@ -40,7 +40,7 @@ import chat.simplex.common.views.usersettings.networkAndServers.defaultCondition import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration -import chat.simplex.res.MR +import chat.simplex.res.* import com.russhwolf.settings.Settings import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource 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 140c1951ee..a6ede20e41 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 @@ -6,7 +6,7 @@ import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import java.util.* enum class AppPlatform { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index ad0e9aaa5a..9d1c0b8769 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -8,7 +8,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import java.io.File import java.nio.ByteBuffer diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index cdd4140e3f..6dfbeceff8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.charleskorn.kaml.* import kotlinx.serialization.encodeToString import java.io.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index bf7a4c83c3..ee47403cd3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -7,7 +7,7 @@ import chat.simplex.common.views.chatlist.acceptContactRequest import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay enum class NotificationAction { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 20e0280acd..b6d6a4d771 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.Transient import java.util.UUID diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 843bacecec..e4a187783a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -45,7 +45,7 @@ import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.DeleteImageButton import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch 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..0eda3cd751 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 @@ -15,7 +15,7 @@ import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.NameWithBadge import chat.simplex.common.views.helpers.ProfileImage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt index cb300b183d..28cf4c40b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesHowItWorksView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt index 2783727621..9420f323ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt @@ -39,7 +39,7 @@ import chat.simplex.common.views.helpers.ModalView import chat.simplex.common.views.helpers.ShareButton import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.withBGApi -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.math.abs @Composable 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/BadgesPayView.kt index d7a10f38f7..f8fbd3a416 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/BadgesPayView.kt @@ -23,7 +23,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.* // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgePeriod { 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..62bdc2962e 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 @@ -32,7 +32,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* private const val badgeCodePrefix = "SB" private const val badgeCodeBodyLength = 20 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..a58ab552f8 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 @@ -29,7 +29,7 @@ import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.onboarding.HowItWorks import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesSupportSimplexView(modalManager: ModalManager) { 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..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 @@ -34,7 +34,7 @@ import chat.simplex.common.views.helpers.badgeTypeName import chat.simplex.common.views.helpers.openVerifiedSimplexUri import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.views.usersettings.simplexTeamUri -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { 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/BadgesYourLevelView.kt index 161702704b..44b5de021a 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/BadgesYourLevelView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgeLevel { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt index 47a4d3b152..2a7f1034df 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -20,7 +20,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.BannerDismissButton import chat.simplex.common.views.chatlist.bannerCard import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SupportSimpleXBanner( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 563f4c3b83..70565fedb4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.ProfileImage import chat.simplex.common.views.usersettings.ProfilePreview -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Clock // Spec: spec/services/calls.md#IncomingCallAlertView diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 6cd0ba95ba..b761218fdc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -3,7 +3,7 @@ package chat.simplex.common.views.call import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index b35e828962..9c019ad535 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -43,7 +43,7 @@ import chat.simplex.common.views.chat.group.ChatTTLOption import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.newchat.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 50f5e35462..e4f93a0b8a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.common.views.usersettings.networkAndServers.serverHostname -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.serialization.encodeToString diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index fc57998f1b..a4a4ddfe94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -44,7 +44,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.platform.AudioPlayer import chat.simplex.common.views.newchat.ContactConnectionInfoView import chat.simplex.common.views.newchat.alertProfileImageSize -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt index 26b1fce741..5f7aec3dd6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt @@ -25,7 +25,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.sendCommandMsg import chat.simplex.common.views.helpers.commandMenuAnimSpec -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt index 14edea3ed6..06a8d529a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.model.MsgChatLink import chat.simplex.common.ui.theme.appColors import chat.simplex.common.views.helpers.ProfileImage import dev.icerock.moko.resources.compose.painterResource -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ComposeChatLinkView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt index 106c975226..c10e28ce11 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.views.chatlist.acceptContactRequest import chat.simplex.common.views.chatlist.rejectContactRequest import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt index 21799ff820..015308fe22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index be82a5d2d3..8cda1226fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.views.chat.group.removeMember import chat.simplex.common.views.chat.group.removeMemberDialog import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt index b7be49055e..80940bf631 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.IncognitoOptionImage import chat.simplex.common.views.usersettings.IncognitoView -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt index 26e069c739..c5a21dfa37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt @@ -12,7 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt index 97b6f9afda..c6ce37a9ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt @@ -14,7 +14,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.platform.base64ToBitmap -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.UploadContent diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c81149fcb1..cd733bf275 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -42,7 +42,7 @@ import chat.simplex.common.views.newchat.RelayProgressIndicator import chat.simplex.common.views.newchat.RelayStatusIndicator import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.common.views.newchat.relayDisplayName -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt index b070dce1d1..29c171b00f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.model.durationText import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.AudioPlayer -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.flow.distinctUntilChanged @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 0276727ccc..b7adec1add 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -21,7 +21,7 @@ import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index e681c4fed7..b477f56118 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.views.chat.item.* import chat.simplex.common.model.* import chat.simplex.common.platform.getLoadedFilePath import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 428d4b1b8f..ea0ef11aef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -9,7 +9,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 03d0b99854..3f3278ee58 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -17,7 +17,7 @@ import chat.simplex.common.platform.BackHandler import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.* import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index b116201d87..8205071225 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatItem import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt index d3e3ffbe61..7915aedca5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index db31c05dce..20f5acd3e0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -39,7 +39,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.itemPrefixText import chat.simplex.common.views.chat.item.itemSegmentDisplayText import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index 880c15216e..3610147804 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun VerifyCodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 7223f69f98..731b125955 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt index 95ce003caa..f67efc0202 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.chatRelayDisplayName import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index 128747c4fa..ea841acc86 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.views.chat.ownersContributorsCountStr import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ModalData.ChannelMembersView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt index 60cc19bb1b..951b7b2c35 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -20,7 +20,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt index 18a944f671..7f40458f42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index d14da9dc33..973b684d38 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -49,7 +49,7 @@ import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.TtlOptions import chat.simplex.common.views.newchat.SimpleXLinkQRCode -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index f6f19e9d72..9f02d0fefb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun GroupLinkView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 885d3de4e6..ac1c6915b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -36,7 +36,7 @@ import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.openDirectChat -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.datetime.Clock import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt index 81e7d80da3..6dcc6e9a4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt index 2a5f9df8c1..5f0fed040e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch import kotlin.text.CharCategory.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index f04d63e2d2..bd3af10c37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -21,7 +21,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 6e91ad92d6..c93179edd7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt index 2cc2402c0a..0c272ae98d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -9,7 +9,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt index 544af8ed7e..ef333a8826 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 0da3c46abb..8e0b3321b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index b685d20fb9..b05d425c46 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 927e9940b5..ec8b545647 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* private const val maxByteCount = 1200 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt index d49f8526d5..1d50ba17e4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIBrokenComposableView(alignment: Alignment) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index 744bcf7b66..d3cc76fb7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.SimpleButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CICallItemView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt index 3c3e4baf49..23fce97a6f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.ProfileImage -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt index 1a23dfc49a..27e605df4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIFeaturePreferenceView( 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 fb4a2cdaea..a3c2b63f8f 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 @@ -25,7 +25,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 9b8393f66a..649188502d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable 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 fc1c76074d..d5ea44126b 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 @@ -26,7 +26,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.common.views.chat.chatViewScrollState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlin.math.roundToInt diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt index d297d5ef76..391ee88e2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt @@ -14,7 +14,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.shareText import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIInvalidJSONView(json: String) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt index 2ade49b3fc..bbd7788f29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIMemberCreatedContactView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index 92f89ccad9..e318f406de 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.appPreferences import chat.simplex.common.ui.theme.isInDarkTheme -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Clock @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index d58fd7553f..589b2a6fa6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.appColors import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource 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 8b4756c3ad..21f406db18 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 @@ -15,7 +15,7 @@ import androidx.compose.ui.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* 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 c3e1c92732..3b3c835e0a 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 @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.flow.* import kotlin.math.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index c38bc1fe08..cb3a07b7f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -34,7 +34,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index e2e7f0bc69..fd422b649a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -25,7 +25,7 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.newchat.planAndConnect -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.math.ceil diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index 08b6520dfa..9b378ea9ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.model.RcvMsgError import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 84bc14fee3..c488b4172b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt index f36978cd3c..050843059c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.annotatedStringResource import chat.simplex.common.views.onboarding.ReadableTextWithLink import chat.simplex.common.views.usersettings.MarkdownHelpView import chat.simplex.common.views.usersettings.simplexTeamUri -import chat.simplex.res.MR +import chat.simplex.res.* val bold = SpanStyle(fontWeight = FontWeight.Bold) 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 ca1528a3ce..5f3fa2725c 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 @@ -28,7 +28,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.contacts.onRequestAccepted import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.datetime.Clock 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 5f296655b7..17101c35d1 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 @@ -46,7 +46,7 @@ import chat.simplex.common.views.badges.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 01179f07ee..7e11fdfb97 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.common.views.chat.item.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource // Spec: spec/client/chat-list.md#ChatPreviewView diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt index 349f1f12d4..ddfc4b74ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.model.getTimestampText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactConnectionView(contactConnection: PendingContactConnection) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 96e7fbacd0..fb575498c7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.model.ChatInfo import chat.simplex.common.model.getTimestampText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt index d383366dd4..c740e53a17 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.onboarding.GetStakeView -import chat.simplex.res.MR +import chat.simplex.res.* // Spec: spec/client/chat-list.md#GetStakeBanner @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 1c2f34e88d..31941e1f77 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -50,7 +50,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsPreferenceItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index 07058b5787..b1fe42cdfc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 382d126db7..023aea530f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.themedBackground import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.newchat.ActiveProfilePicker -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ShareListView(chatModel: ChatModel, stopped: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index fe61859937..17e288ff00 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -37,7 +37,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.ReactionIcon import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* 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 81a6b31323..652d072c9c 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 @@ -34,7 +34,7 @@ import chat.simplex.common.views.localauth.VerticalDivider import chat.simplex.common.views.remote.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 371e9072ea..8c77d7560f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.ContactType import chat.simplex.common.views.newchat.chatContactType -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay fun onRequestAccepted(chat: Chat) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 524fbedb1c..a430b521c2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.newchat.ContactType import chat.simplex.common.views.newchat.chatContactType -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactPreviewView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index ebf96a9868..ac64c93c73 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -32,7 +32,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 4aeb929624..4b59dd81c9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.AppVersionText import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index cc782e7b48..6abbf83226 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.* import java.io.* import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 62ba2c10f3..9c64e7ce85 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -24,7 +24,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index cf2ceaf2d6..2c6eed78f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlin.math.absoluteValue diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 91919ff0cf..036bcab68d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -38,7 +38,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.newchat.noShownBadge -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlin.math.max import kotlin.math.roundToInt diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt index c1a76d7bf8..50341761c5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.serialization.SerialName diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt index 3c44cbb4dd..ed10c0f86f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.CustomTimeUnit import chat.simplex.common.model.timeText -import chat.simplex.res.MR +import chat.simplex.res.* @Composable expect fun CustomTimePicker( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index e984ddfb7b..41556236b3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -2,7 +2,7 @@ package chat.simplex.common.views.helpers import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.* import java.io.File import java.security.SecureRandom diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt index b0366cceb3..e5feb49107 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.text.input.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.views.database.PassphraseStrength -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 81fac40a40..1c2ef93a09 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -17,7 +17,7 @@ import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.CenteredRowLayout -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.math.absoluteValue @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index 893ff5a467..2867f6d8a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import dev.icerock.moko.resources.ImageResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index 8aea99eb4e..258fede5b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID import chat.simplex.common.views.chat.item.imageViewFullWidth -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt index 1f2b5485f2..5e040042dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt @@ -8,7 +8,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.BackHandler import chat.simplex.common.views.localauth.LocalAuthView import chat.simplex.common.views.usersettings.LAMode -import chat.simplex.res.MR +import chat.simplex.res.* sealed class LAResult { object Success: LAResult() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt index 3d21cfaf0f..27064b21d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt @@ -5,7 +5,7 @@ import chat.simplex.common.platform.Log import chat.simplex.common.platform.TAG import chat.simplex.common.platform.ntfManager import chat.simplex.common.views.database.restartChatOrApp -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Job import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index cc6b1c40a4..790174eaf6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.text.input.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 5196a144b1..32370ce290 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SelectableCard import chat.simplex.common.views.usersettings.SettingsActionItemWithContent -import chat.simplex.res.MR +import chat.simplex.res.* val SectionCardShape = RoundedCornerShape(16.dp) val CARD_PADDING = 18.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt index 1c4879dedd..d59fc1fe13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import chat.simplex.common.platform.appPlatform import chat.simplex.common.ui.theme.SimpleXTheme -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SimpleButton(text: String, icon: Painter, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt index e0e61b598e..7f8b902b94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt @@ -5,7 +5,7 @@ import androidx.compose.material.Icon import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt index c8c24d918a..98ea245886 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.AppearanceScope.WallpaperPresetSelector import chat.simplex.common.views.usersettings.AppearanceScope.editColor -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.serialization.encodeToString 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 2d6e70eae9..dfe33e3595 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 @@ -13,7 +13,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.ThemeModeOverrides import chat.simplex.common.ui.theme.ThemeOverrides import chat.simplex.common.views.chatlist.connectIfOpenedViaUri -import chat.simplex.res.MR +import chat.simplex.res.* import com.charleskorn.kaml.decodeFromStream import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index f86edb0388..04c1239d84 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -11,7 +11,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.platform.* import chat.simplex.common.views.database.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt index 669847c3a9..957eb099d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.chat.group.ProgressIndicator import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun PasscodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt index d16f0a2a38..9305e21cc4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun PasscodeEntry( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt index 1d620c915f..a42949493b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt @@ -7,7 +7,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword import chat.simplex.common.views.helpers.DatabaseUtils.ksSelfDestructPassword import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SetAppPasscodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 87784f009e..05c22155b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.LinkTextView import chat.simplex.common.views.newchat.SimpleXLinkQRCode import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 54b6758976..150be7ea6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -29,7 +29,7 @@ import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index 639a5cc78e..7ad3076640 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -32,7 +32,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView import chat.simplex.common.views.chat.group.hostFromRelayLink -import chat.simplex.res.MR +import chat.simplex.res.* import java.net.URI import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt index 077abd1b98..9198ec819a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt @@ -11,7 +11,7 @@ import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.KeyChangeEffect import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun AddContactLearnMore(close: () -> Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 1d8da1690c..e2c89940ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -29,7 +29,7 @@ import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.usersettings.* import androidx.compose.ui.layout.ContentScale import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 5e5769891a..61cb73ca0b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -14,7 +14,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* enum class ConnectionLinkType { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index f1bd732d87..f6109778dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 4d97e3a054..bdad9d19c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.contacts.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index f3006d221b..29819f21cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -42,7 +42,7 @@ import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* enum class NewChatOption { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index 661e752751..cf708c72d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -38,7 +38,7 @@ import chat.simplex.common.views.chatlist.openGetStake import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.UserAddressView -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch import kotlin.math.cos import kotlin.math.sin diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 74c9a55ecf..7171c7475c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index d11e396388..30be129aa8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.usersettings.networkAndServers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 9d82395740..a205a11145 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt index 97dfcd34b9..5aca7a20c9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt @@ -17,7 +17,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.remote.AddingMobileDevice import chat.simplex.common.views.remote.DeviceNameField import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt index 684bfb0053..c5361452ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt @@ -26,7 +26,7 @@ import chat.simplex.common.views.helpers.mixWith import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index adcfb8b194..4dfd6445ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -18,7 +18,7 @@ import chat.simplex.common.model.NotificationsMode import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 9ef72a7f12..0d00c20591 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.database.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index d97d709953..41229037b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -35,7 +35,7 @@ import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.migration.MigrateToDeviceView import chat.simplex.common.views.migration.MigrationToState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlin.math.ceil import kotlin.math.floor diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 39a6514302..81ea810acb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -59,7 +59,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.common.views.usersettings.showAddShortLinkAlert -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlin.math.absoluteValue diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt index b20cfe3096..45f7927c8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.usersettings.changeNotificationsMode -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 2d2de2881f..01b054fb28 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -35,7 +35,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 8caf038481..57700aeba3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -36,7 +36,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.validPort -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index 93c5eb7756..a661568a0d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -40,7 +40,7 @@ import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.ui.theme.ThemeManager.toReadableHex import chat.simplex.common.views.chat.item.PreviewChatItemView import chat.simplex.common.views.chat.item.msgTailWidthDp -import chat.simplex.res.MR +import chat.simplex.res.* import com.godaddy.android.colorpicker.ClassicColorPicker import com.godaddy.android.colorpicker.HsvColor import dev.icerock.moko.resources.StringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt index 43863d5794..ca9c66df27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CallSettingsView(m: ChatModel, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index 2c729149d0..cfdf003bc6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -15,7 +15,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> Unit diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt index aaaef31583..74afa93f06 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.chatlist.ChatHelpView import chat.simplex.common.views.helpers.AppBarTitle -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun HelpView(userDisplayName: String) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 4a3806ab89..a14dab3f1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.UserProfileRow import chat.simplex.common.views.database.PassphraseField import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun HiddenProfileView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt index 6098f66941..7f1081ff3d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun IncognitoView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt index ab2df2014c..2810cb02ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.Format import chat.simplex.common.model.FormatColor import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.ui.theme.SimpleXTheme -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun MarkdownHelpView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 91324bb39a..083e5a07ae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.text.style.TextOverflow import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.collections.ArrayList @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 63f3491d80..6a06432c6d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 314537f579..1696e5f1fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt index 0c31b062dd..57c77df844 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt @@ -23,7 +23,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.parseRTCIceServers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun RTCServersView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index ef4acdeac6..003300a033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt index f2c7b02b42..fc120fb7ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.chat.item.openBrowserAlert import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* 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 32f1b6384a..bd929bc2f7 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 @@ -35,7 +35,7 @@ import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt index ea0cd4fe28..8167bae62a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt @@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 87660c7e65..5b22d5f363 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.* import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index f2a4cc7dac..cc1149e44d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.platform.* import chat.simplex.common.views.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index ac21fb6b23..dd1e208349 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 5070c3c0aa..e297ef62a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.AppBarTitle -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun VersionInfoView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 42746006a3..3a6fc0fb5c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -30,7 +30,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsPreferenceItem -import chat.simplex.res.MR +import chat.simplex.res.* import java.text.DecimalFormat @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt index 9a2d7f8e61..f68dd0c0dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 8241422d0a..92714b5910 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -43,7 +43,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index a3a843d034..6e8d919afc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -8,7 +8,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable 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 53277c9ccd..1e46091ea3 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 @@ -31,7 +31,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.mikepenz.markdown.compose.Markdown import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.elements.MarkdownHeader diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index 3c8cc9e0ce..c1c423e981 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -27,7 +27,7 @@ import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 63365bd080..6e1fad133a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt index d280773976..68242a22b1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt @@ -7,7 +7,7 @@ import chat.simplex.common.model.UserServer import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner -import chat.simplex.res.MR +import chat.simplex.res.* @Composable expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/az/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/az/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/bn/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/bn/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/da/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/da/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Bold.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Bold.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Bold.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Bold.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Italic.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Italic.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Italic.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Italic.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Light.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Light.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Light.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Light.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Medium.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Medium.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Medium.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Medium.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Regular.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Regular.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Regular.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Regular.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Semibold.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Semibold.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Semibold.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Semibold.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hi/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hi/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_investor.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_investor.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_legend.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_legend.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_supporter.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_supporter.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_symbol@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_symbol@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_box.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_box.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_box.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_box.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_group.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_group.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_link.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_link.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_link.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_link.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_photo.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_photo.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_photo.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_photo.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_all_inclusive.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_all_inclusive.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_all_inclusive.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_all_inclusive.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_alternate_email.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_alternate_email.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_back_ios_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_back_ios_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_back_ios_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_back_ios_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_circle_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_circle_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_circle_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_circle_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_downward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_downward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_downward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_downward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward_ios.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward_ios.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward_ios.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward_ios.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_outward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_outward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_upward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_upward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_upward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_upward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrows_left_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrows_left_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_article.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_article.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_article.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_article.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_at.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_at.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_avg_pace.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_avg_pace.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_avg_pace.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_avg_pace.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_back_hand.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_back_hand.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backspace.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backspace.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backspace.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backspace.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backup.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backup.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backup.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backup.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_2_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_2_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_2_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_2_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_3_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_3_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_3_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_3_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bedtime_moon.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bedtime_moon.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bluetooth.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bluetooth.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_blur_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_blur_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_brand_awareness_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_brand_awareness_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_breaking_news.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_breaking_news.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_breaking_news.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_breaking_news.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cable.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cable.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cable.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cable.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_calendar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_calendar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_calendar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_calendar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_camera_enhance.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_camera_enhance.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_camera_enhance.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_camera_enhance.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cancel_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cancel_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cancel_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cancel_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_bubble.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_bubble.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_person.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_person.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_person.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_person.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_checklist.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_checklist.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right_2.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right_2.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right_2.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right_2.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_close.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_close.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_close.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_close.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_code.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_code.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_code.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_code.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_collapse_all.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_collapse_all.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_contact_support.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_contact_support.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_contact_support.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_contact_support.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_copy.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_copy.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_copy.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_copy.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_paste.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_paste.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_paste.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_paste.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cube.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cube.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_database.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_database.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_database.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_database.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_desktop.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_desktop.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_desktop.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_desktop.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_dns.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_dns.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_dns.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_dns.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_disturb_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_disturb_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_disturb_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_disturb_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_touch.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_touch.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_done_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_done_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_done_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_done_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_double_check.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_double_check.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_download.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_download.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_download.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_download.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drag_handle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drag_handle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drive_folder_upload.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drive_folder_upload.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drive_folder_upload.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drive_folder_upload.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_note.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_note.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_note.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_note.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_electrical_services.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_electrical_services.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_electrical_services.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_electrical_services.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_engineering.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_engineering.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_expand_all.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_expand_all.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_filter_list.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_filter_list.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_filter_list.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_filter_list.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flip_camera_android_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flip_camera_android_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flip_camera_android_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flip_camera_android_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_open.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_open.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_open.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_open.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_pen.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_pen.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_pen.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_pen.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forum.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forum.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forum.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forum.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_github.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_github.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_github.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_github.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_headphones.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_headphones.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_heart@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_heart@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_history.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_history.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_history.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_history.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_id_card.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_id_card.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_id_card.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_id_card.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_info.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_info.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_info.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_info.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_inventory_2.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_inventory_2.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_inventory_2.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_inventory_2.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ios_share.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ios_share.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ios_share.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ios_share.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_arrow_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_arrow_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_arrow_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_arrow_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_light_mode.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_light_mode.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_light_mode.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_light_mode.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lightbulb.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lightbulb.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lightbulb.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lightbulb.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_link.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_link.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_link.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_link.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_open_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_open_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_login.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_login.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_login.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_login.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_logout.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_logout.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_logout.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_logout.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_manage_accounts.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_manage_accounts.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_manage_accounts.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_manage_accounts.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_maps_ugc.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_maps_ugc.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_maps_ugc.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_maps_ugc.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mark_chat_unread.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mark_chat_unread.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mark_chat_unread.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mark_chat_unread.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_match_case.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_match_case.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_match_case.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_match_case.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_menu.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_menu.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_menu.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_menu.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_3.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_3.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_4.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_4.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_horiz.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_horiz.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_horiz.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_horiz.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_vert.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_vert.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_vert.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_vert.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_music_note.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_music_note.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_music_note.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_music_note.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_note_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_note_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_note_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_note_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_open_in_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_open_in_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_open_in_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_open_in_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outbound.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outbound.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outline_terminal.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outline_terminal.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outline_terminal.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outline_terminal.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_palette.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_palette.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_palette.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_palette.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pause_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pause_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pause_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pause_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_edit.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_edit.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_bluetooth_speaker.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_bluetooth_speaker.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_in_talk_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_in_talk_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_in_talk_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_in_talk_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_camera.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_camera.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_camera.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_camera.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_play_arrow_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_play_arrow_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_play_arrow_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_play_arrow_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_power_settings_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_power_settings_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_power_settings_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_power_settings_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_priority_high.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_priority_high.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_priority_high.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_priority_high.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code_scanner.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code_scanner.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_question_mark.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_question_mark.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radio_button_unchecked.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radio_button_unchecked.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_1_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_1_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_1_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_1_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_2_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_2_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_2_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_2_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_3_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_3_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_3_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_3_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_4_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_4_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_4_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_4_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_redeem.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_redeem.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_redeem.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_redeem.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_refresh.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_refresh.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_repeat_one.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_repeat_one.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_repeat_one.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_repeat_one.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_replay.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_replay.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_replay.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_replay.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_reply.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_reply.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_reply.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_reply.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_restart_alt.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_restart_alt.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_restart_alt.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_restart_alt.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ring_volume.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ring_volume.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ring_volume.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ring_volume.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_safety_divider.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_safety_divider.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_safety_divider.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_safety_divider.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_security.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_security.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_security.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_security.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_backup_restore.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_backup_restore.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_backup_restore.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_backup_restore.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_ethernet.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_ethernet.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_ethernet.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_ethernet.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_phone.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_phone.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_phone.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_phone.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_shield.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_shield.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_shield.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_shield.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_dark@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_dark@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_dark@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_dark@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot_light.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot_light.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_light.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_light.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smart_display.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smart_display.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smart_display.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smart_display.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone_300.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone_300.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_start.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_start.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_start.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_start.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_stop_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_stop_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_stop_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_stop_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_supervised_user_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_supervised_user_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_supervised_user_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_supervised_user_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_sync_problem.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_sync_problem.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_tag.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_tag.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_tag.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_tag.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_task.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_task.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_task.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_task.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toast.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toast.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toast.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toast.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toggle_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toggle_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toggle_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toggle_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_translate.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_translate.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_translate.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_translate.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_travel_explore.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_travel_explore.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_travel_explore.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_travel_explore.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upgrade.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upgrade.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upgrade.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upgrade.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upload_file.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upload_file.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upload_file.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upload_file.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_usb.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_usb.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_missing.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_missing.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_user.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_user.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_user.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_user.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_waving_hand.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_waving_hand.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_waving_hand.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_waving_hand.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_tethering.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_tethering.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_tethering.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_tethering.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled_padded.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled_padded.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled_padded.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled_padded.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/icon_foreground_common@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/icon_foreground_common@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/icon_foreground_common@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/icon_foreground_common@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/logo@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/logo@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/logo@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/logo@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/logo_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/logo_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/logo_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/logo_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_cats@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_cats@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_flowers@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_flowers@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_hearts@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_hearts@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_kids@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_kids@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_school@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_school@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_travel@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_travel@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ml/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ml/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/nb-rNO/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/nb-rNO/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/night/themes.xml b/apps/multiplatform/common/src/commonMain/moko-resources/night/themes.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/night/themes.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/night/themes.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/pt/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pt/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/sv/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/sv/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/th/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/th/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml 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 41e81a6f60..3e48621d03 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 @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt index 9f75e481f4..657b1d98f2 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt @@ -17,7 +17,7 @@ import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.isInDarkTheme import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.awt.AWTException diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt index 19cb7aea91..4f023bd04f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt @@ -3,6 +3,7 @@ package chat.simplex.common import chat.simplex.common.platform.Log import chat.simplex.common.platform.TAG import chat.simplex.common.platform.dataDir +import chat.simplex.res.* import java.io.IOException import java.nio.channels.FileChannel import java.nio.channels.FileLock diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt index ee415ae82b..0a61864294 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.simplexWindowState import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.sshtools.twoslices.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index d425d81875..167c3f898d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* import java.util.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.io.File actual val appPlatform = AppPlatform.DESKTOP diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 58260181cf..b779954efc 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.* import chat.simplex.common.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import java.awt.Desktop import java.io.* import java.net.URI 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 0f830e7b60..9c6139b493 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,7 +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.res.MR +import chat.simplex.res.* import org.jetbrains.skia.Image import java.awt.RenderingHints import java.awt.geom.AffineTransform diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 557dabd2e4..3e048a93d5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay import java.awt.Image diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index d59677b726..f22ed52357 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -3,7 +3,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.player.base.MediaPlayer diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt index 4e7594f998..062f26cfd7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.simplexWindowState import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.jthemedetecor.OsThemeDetector import com.russhwolf.settings.* import dev.icerock.moko.resources.ImageResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index a10f675085..435a8891b7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -8,7 +8,7 @@ import chat.simplex.common.views.helpers.* import java.io.File import java.net.URI import java.net.URLEncoder -import chat.simplex.res.MR +import chat.simplex.res.* import java.awt.Desktop actual fun UriHandler.sendEmail(subject: String, body: CharSequence) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index d9375f5d79..52f11de64d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface import uk.co.caprica.vlcj.media.Media diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt index 082036558c..7639c567d4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt @@ -3,26 +3,26 @@ package chat.simplex.common.ui.theme import androidx.compose.ui.text.font.* import androidx.compose.ui.text.platform.Font import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* actual val Inter: FontFamily = FontFamily( - Font(MR.fonts.Inter.regular.file), - Font(MR.fonts.Inter.italic.file, style = FontStyle.Italic), - Font(MR.fonts.Inter.bold.file, FontWeight.Bold), - Font(MR.fonts.Inter.semibold.file, FontWeight.SemiBold), - Font(MR.fonts.Inter.medium.file, FontWeight.Medium), - Font(MR.fonts.Inter.light.file, FontWeight.Light) + Font(MR.fonts.inter_regular.file), + Font(MR.fonts.inter_italic.file, style = FontStyle.Italic), + Font(MR.fonts.inter_bold.file, FontWeight.Bold), + Font(MR.fonts.inter_semibold.file, FontWeight.SemiBold), + Font(MR.fonts.inter_medium.file, FontWeight.Medium), + Font(MR.fonts.inter_light.file, FontWeight.Light) ) actual val EmojiFont: FontFamily = if (desktopPlatform.isMac()) { FontFamily.Default } else { FontFamily( - Font(MR.fonts.NotoColorEmoji.regular.file), - Font(MR.fonts.NotoColorEmoji.regular.file, style = FontStyle.Italic), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Bold), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.SemiBold), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Medium), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Light) + Font(MRdesktopMain.fonts.notocoloremoji_regular.file), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, style = FontStyle.Italic), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Bold), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.SemiBold), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Medium), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Light) ) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index bd8e0d771a..881b9ee168 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.platform.LocalUriHandler import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index e6f782d816..997100246c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.io.File diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt index 05ed7002e5..64dc8d61a6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import chat.simplex.common.model.MREmojiChar import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index 9fb4afbf7d..c1d4a40dae 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.getBitmapFromByteArray -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index 9fd65ec995..250d7ed3d9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt index 75a76014a9..dba5ddb9e6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt @@ -18,7 +18,7 @@ import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.views.chat.item.isHeartEmoji import chat.simplex.common.views.chat.item.isShortEmoji import chat.simplex.common.views.helpers.toDp -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt index 0010d63e15..2d5f58a545 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index e857ef91b1..306562e6ad 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.json import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.onboarding.ReadMoreButton -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt index d125f94e55..4ff8c552c4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index 79aaf5c727..4f26f1d5fe 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.DialogParams import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.awt.FileDialog diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt index 59865dd66f..d4d9415501 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt @@ -9,7 +9,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.platform.rememberFileChooserLauncher -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.views.newchat.ActionButton import java.net.URI diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt index 1b9582b7d2..30e1020d48 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt @@ -9,7 +9,7 @@ import chat.simplex.common.model.SharedPreference import chat.simplex.common.model.User import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt index 6b36a3b1b2..7a48f37271 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -26,7 +26,7 @@ import chat.simplex.common.trayIsAvailable import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay import java.util.Locale diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt index a51fd20c5b..325a475218 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt @@ -3,7 +3,7 @@ package chat.simplex.common.views.usersettings import SectionView import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt index 174ad63c7a..8ea4a3c5e7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf b/apps/multiplatform/common/src/desktopMain/moko-resources/fonts/NotoColorEmoji-Regular.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf rename to apps/multiplatform/common/src/desktopMain/moko-resources/fonts/NotoColorEmoji-Regular.ttf 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 19/94] 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 0c1ffa65bc46340af0a75c8aa0c76cac0d26d36c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 8 Oct 2026 15:06:18 +0100 Subject: [PATCH 20/94] core: group fixes/improvements (#7660) * core: group fixes/improvements * Large messages dropped by relays and hosts (the same 15602 limit was applied, without accounting for forwarding header) * Members below v18 did not receive forwarded messages or history in p2p groups. * Join confirmations near the size limit could fail. * SMP servers are limited to 2 host names each. * simplify * remove legacy forwarding and history support * only restrict p2p groups --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/AppAPITypes.swift | 5 ++ .../chat/simplex/common/model/SimpleXAPI.kt | 4 ++ .../moko-resources/base/strings.xml | 1 + src/Simplex/Chat/Library/Internal.hs | 17 +++--- src/Simplex/Chat/Library/Subscriber.hs | 10 ++-- src/Simplex/Chat/Operators.hs | 6 +- src/Simplex/Chat/Protocol.hs | 12 +++- src/Simplex/Chat/Store/Delivery.hs | 2 +- tests/ChatTests/Direct.hs | 7 +++ tests/ChatTests/Groups.hs | 59 ++++++++++++++++++- tests/MessageBatching.hs | 7 +++ 11 files changed, 112 insertions(+), 18 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index eadca9f71e..26438bc60e 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1931,10 +1931,13 @@ enum UserServersError: Decodable { case storageMissing(protocol: ServerProtocol, user: UserRef?) case proxyMissing(protocol: ServerProtocol, user: UserRef?) case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + case tooManyHosts(protocol: ServerProtocol, tooManyHostsServer: String) case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String) var globalError: String? { switch self { + case .tooManyHosts: + return globalSMPError case let .noServers(`protocol`, _): switch `protocol` { case .smp: return globalSMPError @@ -1977,6 +1980,8 @@ enum UserServersError: Decodable { } else { return text } + case let .tooManyHosts(.smp, server): + return String.localizedStringWithFormat(NSLocalizedString("Server %@ has more than two host names.", comment: "servers error"), serverHostname(server)) default: return nil } 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 706ee713fc..d879df1d27 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 @@ -4886,6 +4886,7 @@ sealed class UserServersError { @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + @Serializable @SerialName("tooManyHosts") data class TooManyHosts(val protocol: ServerProtocol, val tooManyHostsServer: String): UserServersError() @Serializable @SerialName("duplicateChatRelayAddress") data class DuplicateChatRelayAddress(val duplicateChatRelay: String, val duplicateAddress: String): UserServersError() val globalError: String? @@ -4901,6 +4902,7 @@ sealed class UserServersError { is StorageMissing -> this.protocol is ProxyMissing -> this.protocol is DuplicateServer -> this.protocol + is TooManyHosts -> this.protocol is DuplicateChatRelayAddress -> null } @@ -4916,6 +4918,8 @@ sealed class UserServersError { is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" } ?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing) + is TooManyHosts -> String.format(generalGetString(MR.strings.server_has_too_many_hosts), ServerAddress.parseServerAddress(this.tooManyHostsServer)?.hostnames?.firstOrNull() ?: this.tooManyHostsServer) + else -> null } } else { 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 8a8aad0cfa..2d0db3e038 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -140,6 +140,7 @@ No message servers. No servers to receive messages. No servers for private message routing. + Server %1$s has more than two host names. No media & file servers. No servers to send files. No servers to receive files. diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 1d4cf969a8..effa943cc7 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1414,7 +1414,7 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c -- (regular groups only; never channels) is an authored element -- all batch together in order. let fwdEls = map (uncurry encodeFwdElement) (concat fwdMsgsByItem) welcomeEl <- welcomeElement - let (batches, dropped) = batchElements maxEncodedMsgLength (fwdEls <> maybe [] (: []) welcomeEl) + let (batches, dropped) = batchElements maxForwardBatchLength (fwdEls <> maybe [] (: []) welcomeEl) when (dropped > 0) $ toView $ CEvtChatErrors [ChatError $ CEInternalError ("sendHistory: dropped " <> show dropped <> " oversized history messages")] forM_ batches $ \body -> void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)] @@ -1552,9 +1552,7 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c pure $ map ((,) fwd) (contentVM : fileDescrVMs) memberShortenedName :: GroupMember -> ContactName -memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}} - | T.length displayName <= 16 = displayName - | otherwise = T.take 16 displayName `T.snoc` '…' +memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}} = fwdMemberName displayName -- the description proof travels on the last part, so that part leaves room for it badgeDescrPartSize :: Int @@ -2457,9 +2455,10 @@ compressToLimit maxLen s s' = compressedBatchMsgBody_ s compressConnInfo :: PQSupport -> MsgBody -> CM MsgBody -compressConnInfo pqSup = compressToLimit $ case pqSup of - PQSupportOn -> maxEncodedInfoLengthPQ - PQSupportOff -> maxEncodedInfoLength +compressConnInfo pqSup = compressToLimit $ e2eEncConnInfoLength pqSup - 2 - maxReplyQueueFraming + +maxReplyQueueFraming :: Int +maxReplyQueueFraming = 256 encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString encodeConnInfo = encodeConnInfoPQ PQSupportOff @@ -2468,7 +2467,7 @@ encodeConnInfoPQ :: MsgEncodingI e => PQSupport -> ChatMsgEvent e -> CM ByteStri encodeConnInfoPQ pqSup chatMsgEvent = do cxt <- chatStoreCxt let info = ChatMessage {chatVRange = vr cxt, msgId = Nothing, chatMsgEvent} - case encodeChatMessage maxEncodedInfoLength info of + case encodeChatMessage maxEncodedProfileMsgLength info of ECMEncoded connInfo -> compressConnInfo pqSup connInfo ECMLarge -> throwChatError $ CEException "large info" @@ -2477,7 +2476,7 @@ encodeSignedConnInfo :: MsgEncodingI e => PQSupport -> MsgSigning -> ChatMsgEven encodeSignedConnInfo pqSup signing chatMsgEvent = do vr <- chatVersionRange let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent} - case encodeChatMessage maxEncodedInfoLength info of + case encodeChatMessage maxEncodedProfileMsgLength info of ECMEncoded body -> compressConnInfo pqSup $ encodeBatchElement (Just $ signChatMsgBody signing body) body ECMLarge -> throwChatError $ CEException "large signed info" diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 3f0e53fd8a..304aa15500 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3937,7 +3937,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing xGrpMsgForward :: GroupInfoKeys -> Maybe GroupChatScopeInfo -> GroupMember -> GrpMsgForward -> ParsedMsg 'Json -> UTCTime -> CM () - xGrpMsgForward g@(GIK gInfo _) scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do + xGrpMsgForward g@(GIK gInfo@GroupInfo {membership} _) scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName) case fwdSender of FwdMember memberId memberName -> do @@ -3945,6 +3945,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag let allowCreate = toCMEventTag chatMsgEvent /= XGrpLeave_ withStore (\db -> getCreateUnknownGMByMemberId db cxt user gInfo memberId memberName unknownRole allowCreate) >>= \case Just (author, unknown) + | not (useRelays' gInfo) && groupMemberId' author == groupMemberId' membership -> + messageError $ "x.grp.msg.forward: content attributed to own membership, forwarder " <> tshow (groupMemberId' m) <> ", event " <> tshow (toCMEventTag chatMsgEvent) | memberRemoved author -> logInfo $ "x.grp.msg.forward: ignoring content from removed member, group " <> tshow (groupId' gInfo) <> ", member " <> safeDecodeUtf8 (strEncode memberId) <> ", event " <> tshow (toCMEventTag chatMsgEvent) | not (useRelays' gInfo) && not (expectedForwarder author) -> @@ -4160,7 +4162,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks + let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxForwardBatchLength nextTasks senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks withStore' $ \db -> do forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body @@ -4290,8 +4292,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do else do -- all members' profiles disseminate; privileged key/role come from the roster, not here let (encoderErrs, validLabeled) = partitionEithers [(\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s | (s, _) <- senders] - (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled - (overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled + (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxForwardBatchLength body validLabeled + (overflowBatches', large2) = batchProfiles maxForwardBatchLength overflowLabeled packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2] allErrs = encoderErrs <> packerErrs unless (null allErrs) $ do diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index a216bbb1fc..9ec5d0f48d 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -525,6 +525,7 @@ data UserServersError | USEStorageMissing {protocol :: AProtocolType, user :: Maybe User} | USEProxyMissing {protocol :: AProtocolType, user :: Maybe User} | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost} + | USETooManyHosts {protocol :: AProtocolType, tooManyHostsServer :: Text} | USEDuplicateChatRelayAddress {duplicateChatRelay :: Text, duplicateAddress :: ShortLinkContact} deriving (Show) @@ -547,13 +548,16 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError] - serverErrs p uss = mapMaybe duplicateErr_ srvs + serverErrs p uss = mapMaybe duplicateErr_ srvs <> hostsErrs where p' = AProtocolType p srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) $ userServers p uss duplicateErr_ (AUS _ srv@UserServer {server}) = USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server) <$> find (`S.member` duplicateHosts) (srvHost srv) + hostsErrs = case p of + SPSMP -> [USETooManyHosts p' (safeDecodeUtf8 $ strEncode server) | AUS _ srv@UserServer {server} <- srvs, L.length (srvHost srv) > 2] + _ -> [] duplicateHosts = snd $ foldl' addDuplicate (S.empty, S.empty) allHosts allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 723f78d6db..01cb868e83 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -924,6 +924,14 @@ $(JQ.deriveJSON defaultJSON ''MsgContainer) maxEncodedMsgLength :: Int maxEncodedMsgLength = 15602 +maxForwardBatchLength :: Int +maxForwardBatchLength = maxEncodedMsgLength + 161 + +fwdMemberName :: ContactName -> ContactName +fwdMemberName displayName + | T.length displayName <= 16 = displayName + | otherwise = T.take 16 displayName `T.snoc` '…' + -- maxEncodedMsgLength - 2222, see e2eEncUserMsgLength in agent maxEncodedMsgLengthPQ :: Int maxEncodedMsgLengthPQ = 13380 @@ -965,8 +973,8 @@ rosterBlobP = do maxEncodedInfoLength :: Int maxEncodedInfoLength = 14694 -maxEncodedInfoLengthPQ :: Int -maxEncodedInfoLengthPQ = 10968 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent +maxEncodedProfileMsgLength :: Int +maxEncodedProfileMsgLength = 16384 data EncodedChatMessage = ECMEncoded ByteString | ECMLarge diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index add8c64e7f..c80264da7a 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -156,7 +156,7 @@ getMsgDeliveryTask_ db taskId = toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, Binary msgBody, chatBinding_, sigs_, BI showGroupAsSender)) = case (toJobScope_ jobScopeRow, J.eitherDecodeStrict' msgBody) of (Just jobScope, Right chatMsg) -> - let fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId senderMemberName + let fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId (fwdMemberName senderMemberName) -- Re-parsed from msg_body: validates stored content against current code. -- Signed: original bytes preserved (re-encoding would invalidate signature). -- Unsigned: re-encoded from parsed ChatMessage on forward (sanitizes content). diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index b95d738589..930a0fa22d 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1158,6 +1158,13 @@ testGetSetSMPServers = alice <## " SMP servers" alice <## " smp://2345-w==@smp2.example.im" alice <## " smp://3456-w==@smp3.example.im:5224" + alice #$> ("/smp smp://2345-w==@smp2.example.im,smp4.example.im", id, "ok") + alice ##> "/smp smp://2345-w==@smp2.example.im,smp4.example.im,smp5.example.im" + alice <##. "bad chat command: user servers validation error(s): [USETooManyHosts" + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://2345-w==@smp2.example.im,smp4.example.im" testTestSMPServerConnection :: HasCallStack => TestParams -> IO () testTestSMPServerConnection = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 39e50dc16b..3608e5f7e9 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -38,7 +38,7 @@ 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, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -119,6 +119,7 @@ chatGroupTests = do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do it "create group link, join via group link" testGroupLink + it "join via group link with group profile near the size limit" testGroupLinkLargeGroupProfile it "invitees were previously connected as contacts" testGroupLinkInviteesWereConnected it "all members were previously connected as contacts" testGroupLinkAllMembersWereConnected it "delete group, re-join via same link" testGroupLinkDeleteGroupRejoin @@ -176,6 +177,7 @@ chatGroupTests = do it "manually accept contact with group member incognito" testMemberContactAcceptIncognito describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForwardMessage + it "reject forwarded content attributed to own membership" testGroupMsgForwardOwnMembershipRejected 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 @@ -3042,6 +3044,39 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" +testGroupLinkLargeGroupProfile :: HasCallStack => TestParams -> IO () +testGroupLinkLargeGroupProfile = + testChatOpts2 testOptsNoFullLinks aliceProfile bobProfile $ + \alice bob -> do + threadDelay 100000 + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/set history #team off" + alice <## "updated group preferences:" + alice <## "Recent history: off" + withCCTransaction alice $ \db -> + DB.execute db "UPDATE group_profiles SET description = ? WHERE display_name = ?" (T.pack welcome, "team" :: T.Text) + alice ##> "/create link #team" + gLink <- getGroupLink_ alice "team" GRMember True + alice ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + alice #> "#team hello" + let waitHello = do + l <- getTermLine bob + if "alice> hello" `isSuffixOf` l then pure () else waitHello + waitHello + where + welcome = unwords $ replicate 1775 "welcome" + testGroupLink :: HasCallStack => TestParams -> IO () testGroupLink = testChatOpts3 testOptsNoFullLinks aliceProfile bobProfile cathProfile $ @@ -5399,6 +5434,28 @@ testGroupMsgForwardMessage = cath <# "#team bob> hi there [>>]" cath <# "#team hey team" +testGroupMsgForwardOwnMembershipRejected :: HasCallStack => TestParams -> IO () +testGroupMsgForwardOwnMembershipRejected = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + threadDelay 1000000 + [Only bobMemId] <- withCCTransaction bob $ \db -> + DB.query db "SELECT member_id FROM group_members WHERE member_category = ?" (Only ("user" :: T.Text)) :: IO [Only ByteString] + connId <- relayConnIdToMember alice "bob" + ts <- getCurrentTime + let ChatController {smpAgent = aliceAgent} = chatController alice + forgedProfile = (bobProfile :: Profile) {displayName = "mallory", fullName = "Mallory"} + chatMsg = ChatMessage chatInitialVRange (Just $ SharedMsgId "forged_info1") (XInfo forgedProfile Nothing) + fwd = GrpMsgForward (FwdMember (MemberId bobMemId) "bob") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages aliceAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + bob <##. "error: x.grp.msg.forward: content attributed to own membership" + bob ##> "/p" + bob <## "user profile: bob (Bob)" + bob <## "use /p [] to change it" + testGroupMsgForwardBatched :: HasCallStack => TestParams -> IO () testGroupMsgForwardBatched = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 01515fb6a3..71cf2d5f49 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -33,6 +33,7 @@ import Simplex.Chat.Protocol GrpMsgForward (GrpMsgForward), MsgContent (MCText), VerifiedMsg (VMUnsigned), + fwdMemberName, maxBatchElementCount, maxEncodedMsgLength, mcSimple, @@ -49,6 +50,7 @@ batchingTests = describe "message batching tests" $ do it "splits a batch that exceeds the element count limit" testBatchElementCountLimit it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow + it "shortens forwarded member names" testFwdMemberName instance IsString SndMessage where fromString s = SndMessage {msgId, sharedMsgId = SharedMsgId "", msgBody = s', signedMsg_ = Nothing} @@ -190,6 +192,11 @@ testRelayBatchSingletonOverflow = do map deliveryTaskId accepted `shouldBe` [] map deliveryTaskId large `shouldBe` [1] +testFwdMemberName :: IO () +testFwdMemberName = do + fwdMemberName "sixteen_chars_ab" `shouldBe` "sixteen_chars_ab" + fwdMemberName "seventeen_chars_a" `shouldBe` "seventeen_chars_…" + runBatcherTest :: BatchMode -> Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec runBatcherTest mode maxLen msgs expectedErrors expectedBatches = it 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 21/94] 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 22/94] 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 96970373b03766dd7406afe9c1292c975526f4d4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 8 Oct 2026 16:31:29 +0100 Subject: [PATCH 23/94] core: add status when switching queue (#7664) * core: add status when switching queue * fix --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/SimpleXChat/APITypes.swift | 1 + .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 3 ++- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Messages.hs | 4 ++-- src/Simplex/Chat/View.hs | 1 + 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 75d66ebb6f..ecf389563e 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -598,6 +598,7 @@ public enum RcvSwitchStatus: String, Decodable, Hashable { case sendingQADD = "sending_qadd" case sendingQUSE = "sending_quse" case receivedMessage = "received_message" + case receivedQEND = "received_qend" } public struct SndQueueInfo: Decodable, Hashable { 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 d879df1d27..2672d0e996 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 @@ -7573,7 +7573,8 @@ enum class RcvSwitchStatus { @SerialName("switch_started") SwitchStarted, @SerialName("sending_qadd") SendingQADD, @SerialName("sending_quse") SendingQUSE, - @SerialName("received_message") ReceivedMessage + @SerialName("received_message") ReceivedMessage, + @SerialName("received_qend") ReceivedQEND } @Serializable diff --git a/cabal.project b/cabal.project index ed446d492c..e5f2c65eda 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: c0a1b7bd2d79c4af5163d9a863cb0780f556d7a9 + tag: e24d537eaaa32230eec1fefcd957d474d7b8d4d5 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1795f6e865..d6c932df39 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c0a1b7bd2d79c4af5163d9a863cb0780f556d7a9" = "0l10gc46l1i1wwalapg4wa0y1x3j4l4465242701k9lvvgywjh9z"; + "https://github.com/simplex-chat/simplexmq.git"."e24d537eaaa32230eec1fefcd957d474d7b8d4d5" = "1ximwqi1jaiq1ml05y0mcsrgvabhp5is1ry2h91w5jjarjb1a5fj"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 3c6dca2ac8..50035b55be 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -55,7 +55,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer, unMsgId) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection @@ -1228,7 +1228,7 @@ msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId { integrity = (decodeLatin1 . strEncode) integrity, rcvId, rcvTs, - serverId = (decodeLatin1 . B64.encode) serverId, + serverId = (decodeLatin1 . B64.encode . unMsgId) serverId, serverTs, sndId } diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1c62bc267b..aa509d49bf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1961,6 +1961,7 @@ viewRcvQueuesInfo = plain . T.intercalate ", " . map showQueueInfo RSSendingQADD -> "switch started" RSSendingQUSE -> "switch confirmed" RSReceivedMessage -> "switch secured" + RSReceivedQEND -> "switch completed" viewSndQueuesInfo :: [SndQueueInfo] -> StyledString viewSndQueuesInfo = plain . T.intercalate ", " . map showQueueInfo 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 24/94] 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 64d68ff7109d3f633ed8d1ebdd1833fcd3df736d Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 8 Oct 2026 17:11:46 +0100 Subject: [PATCH 25/94] core: fix test (#7666) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- tests/ChatClient.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index fa6d0f37d3..4a052d7da5 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -641,7 +641,7 @@ smpServerCfg p = maxJournalMsgCount = 24, maxJournalStateLines = 4, queueIdBytes = 24, - msgIdBytes = 6, + msgIdBytes = 24, serverStoreCfg = SSCMemory Nothing, -- $ Just StorePaths {storeLogFile = "tmp/smp-server-store.log", storeMsgsFile = Just "tmp/smp-server-messages.log"}, storeNtfsFile = Nothing, allowNewQueues = True, From a24a3b19116a91e51d7ba3f376b67d3dcb93f789 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 8 Oct 2026 18:10:37 +0100 Subject: [PATCH 26/94] website: redirect page (#7667) * website: redirect page * script --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- website/src/wefunder.html | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 website/src/wefunder.html diff --git a/website/src/wefunder.html b/website/src/wefunder.html new file mode 100644 index 0000000000..7fc7623d69 --- /dev/null +++ b/website/src/wefunder.html @@ -0,0 +1,23 @@ +--- +templateEngineOverride: njk +--- + + + + + + + + Invest in SimpleX Chat + + + + + + + + + + + + From 9c9c86f9c07620dab852fcac4b095846e30d3003 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 8 Oct 2026 22:53:59 +0100 Subject: [PATCH 27/94] core, ui: fix profiles, add error alerts (#7663) * core, ui: fix profiles, add error alerts * simplify * query plans --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 2 + apps/ios/Shared/Model/SimpleXAPI.swift | 8 +- .../Views/UserSettings/UserAddressView.swift | 22 +++- .../Views/UserSettings/UserProfile.swift | 9 +- apps/ios/SimpleXChat/ChatTypes.swift | 3 +- .../chat/simplex/common/model/ChatModel.kt | 2 +- .../chat/simplex/common/model/SimpleXAPI.kt | 11 +- .../views/usersettings/UserAddressView.kt | 1 + .../moko-resources/base/strings.xml | 1 + src/Simplex/Chat/Core.hs | 6 +- src/Simplex/Chat/Library/Commands.hs | 59 +++++---- src/Simplex/Chat/Library/Internal.hs | 4 + src/Simplex/Chat/Store/Profiles.hs | 34 ++--- .../SQLite/Migrations/agent_query_plans.txt | 4 + .../SQLite/Migrations/chat_query_plans.txt | 34 +++-- src/Simplex/Chat/Store/Shared.hs | 9 +- tests/ChatTests/Groups.hs | 4 +- tests/ChatTests/Names.hs | 120 ++++++++++++++++++ tests/ChatTests/Profiles.hs | 115 +++++++++++++++++ tests/ValidNames.hs | 7 + 20 files changed, 382 insertions(+), 73 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 6a5b1de353..7c1e8e65ab 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -935,6 +935,8 @@ final class ChatModel: ObservableObject { func updateCurrentUser(_ newProfile: Profile, _ preferences: FullPreferences? = nil) { if let current = currentUser { currentUser?.profile = toLocalProfile(current.profile.profileId, newProfile, "") + currentUser?.profile.localBadge = current.profile.localBadge + currentUser?.profile.contactDomainVerified = current.profile.contactDomainVerified if let preferences = preferences { currentUser?.fullPreferences = preferences } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index be2cde7cd6..21335cee64 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1359,8 +1359,12 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? { switch r { case .result(.userProfileNoChange): return (profile, []) case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts) - case .error(.errorStore(.duplicateName)): return nil; - default: throw r.unexpected + case .error(.errorStore(.duplicateName)), .error(.error(.userExists)): return nil; + default: + await MainActor.run { + showErrorAlert(r.unexpected, NSLocalizedString("Error saving profile", comment: "alert title")) + } + throw r.unexpected } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 9711f3d6f8..61c808dda1 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -139,7 +139,7 @@ struct UserAddressView: View { title: Text("Share address with SimpleX contacts?"), message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."), primaryButton: .default(Text("Share")) { - setProfileAddress($progressIndicator, true) + setProfileAddress($progressIndicator, true) { shareViaProfile = false } shareViaProfile = true }, secondaryButton: .cancel() ) @@ -318,7 +318,7 @@ struct UserAddressView: View { alert = .shareOnCreate progressIndicator = false } else { - setProfileAddress($progressIndicator, true) + setProfileAddress($progressIndicator, true) { shareViaProfile = false } shareViaProfile = true } } else { @@ -601,7 +601,7 @@ struct AddressSettingsState: Equatable { } } -private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool) { +private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool, revert: @escaping () -> Void) { progressIndicator.wrappedValue = true Task { do { @@ -613,7 +613,11 @@ private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool) { await MainActor.run { progressIndicator.wrappedValue = false } } catch let error { logger.error("apiSetProfileAddress: \(responseError(error))") - await MainActor.run { progressIndicator.wrappedValue = false } + await MainActor.run { + progressIndicator.wrappedValue = false + revert() + showErrorAlert(error, NSLocalizedString("Error saving profile", comment: "alert title")) + } } } } @@ -709,7 +713,10 @@ struct UserAddressSettingsView: View { title: NSLocalizedString("Share", comment: "alert action"), style: .default, handler: { _ in - setProfileAddress($progressIndicator, on) + setProfileAddress($progressIndicator, on) { + ignoreShareViaProfileChange = true + shareViaProfile = !on + } } ) ]} @@ -731,7 +738,10 @@ struct UserAddressSettingsView: View { title: NSLocalizedString("Stop sharing", comment: "alert action"), style: .default, handler: { _ in - setProfileAddress($progressIndicator, on) + setProfileAddress($progressIndicator, on) { + ignoreShareViaProfileChange = true + shareViaProfile = !on + } } ) ]} diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index a2a62b557c..8fe8a1c45a 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -36,7 +36,7 @@ struct UserProfile: View { HStack { TextField("Enter your name…", text: $profile.displayName) .focused($focusDisplayName) - if !validDisplayName(profile.displayName) { + if !validNewProfileName { Button { alert = .invalidNameError(validName: mkValidName(profile.displayName)) } label: { @@ -147,10 +147,15 @@ struct UserProfile: View { (chatModel.currentUser?.profile.description ?? "") != description.trimmingCharacters(in: .whitespacesAndNewlines) ) && profile.displayName.trimmingCharacters(in: .whitespaces) != "" && - validDisplayName(profile.displayName) && + validNewProfileName && bioFitsLimit() } + private var validNewProfileName: Bool { + profile.displayName == chatModel.currentUser?.profile.displayName + || validDisplayName(profile.displayName) + } + private func saveProfile() { focusDisplayName = false Task { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9dbbffd4ce..4a95ed23eb 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -583,7 +583,8 @@ public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias contactLink: profile.contactLink, preferences: profile.preferences, peerType: profile.peerType, - localAlias: localAlias + localAlias: localAlias, + contactDomain: profile.contactDomain ) } 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 c10471317e..c33a077ae9 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 @@ -1115,7 +1115,7 @@ object ChatModel { fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return val updated = current.copy( - profile = newProfile.toLocalProfile(current.profile.profileId), + profile = newProfile.toLocalProfile(current.profile.profileId).copy(localBadge = current.profile.localBadge, contactDomainVerified = current.profile.contactDomainVerified), fullPreferences = preferences ?: current.fullPreferences ) val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId } 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 2672d0e996..5f1603423a 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 @@ -1927,8 +1927,10 @@ object ChatController { val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile)) if (r is API.Result && r.res is CR.UserProfileNoChange) return profile to emptyList() if (r is API.Result && r.res is CR.UserProfileUpdated) return r.res.toProfile to r.res.updateSummary.changedContacts - if (r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName) { + if (r is API.Error && (r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName || r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.UserExists)) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) + } else if (!(networkErrorAlert(r))) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_profile), "${r.responseType}: ${r.details}") } Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") return null @@ -1940,7 +1942,12 @@ object ChatController { return when { r is API.Result && r.res is CR.UserProfileNoChange -> null r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh) - else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}") + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiSetProfileAddress", generalGetString(MR.strings.error_saving_profile), r) + } + throw Exception("failed to set profile address: ${r.responseType} ${r.details}") + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 5b22d5f363..826648ddb8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -63,6 +63,7 @@ fun UserAddressView( chatModel.updateUser(u) } } catch (e: Exception) { + shareViaProfile.value = !on Log.e(TAG, "UserAddressView apiSetProfileAddress: ${e.stackTraceToString()}") } finally { progressIndicator.value = false 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 2d0db3e038..71831e1c89 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -255,6 +255,7 @@ Error opening chat Error opening group Error changing profile + Error saving profile Instant notifications diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 942b07dea0..3b8ffb34bb 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -75,10 +75,10 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha noMaintenance img_ <- mapM loadImageFile userImageFile createActiveUser cc coreOptions createBot userDisplayName img_ - Just u@User {localDisplayName} -> do + Just u@User {profile = LocalProfile {displayName}} -> do forM_ userDisplayName $ \name -> - when (localDisplayName /= name) $ do - putStrLn $ "Active user display name " <> show localDisplayName <> " does not match --user-display-name " <> show name + when (displayName /= name) $ do + putStrLn $ "Active user display name " <> show displayName <> " does not match --user-display-name " <> show name exitFailure -- --user-image-file only applies when the profile is created; ignore it for an existing user forM_ userImageFile $ \_ -> diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index bf9326871b..4ffd27f428 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -174,7 +174,9 @@ checkProfileImageSize = mapM_ $ \(ImageData t) -> in when (size > maxProfileImageSize) $ throwCmdError $ "Profile image is too large " <> show size checkProfileSize :: Profile -> CM () -checkProfileSize p = checkInfoSize "Profile" (XInfo p Nothing) +checkProfileSize p@Profile {contactDomain} = checkInfoSize "Profile" (XInfo p' Nothing) + where + p' = (p :: Profile) {badge = Nothing, contactDomain = (\d -> d {proof = Nothing} :: SimplexDomainClaim) <$> contactDomain} checkGroupProfileSize :: GroupProfile -> CM () checkGroupProfileSize p = checkInfoSize "Group profile" (XGrpInfo p) @@ -454,7 +456,7 @@ processChatCommand cxt nm = \case p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser users <- withFastStore' getUsers - forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do + forM_ users $ \User {profile = LocalProfile {displayName = n}, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} when (isTrue userChatRelay && isTrue userChatRelay') $ throwChatError CEChatRelayExists @@ -1596,8 +1598,8 @@ processChatCommand cxt nm = \case APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call - APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) - APISetUserDomain userId strDomain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do + APIUpdateProfile userId profile -> withUserProfileLock "updateProfile" $ withUserId userId (`updateProfile` profile) + APISetUserDomain userId strDomain_ -> withUserProfileLock "setUserDomain" $ withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do let domain_ = unStrJSON <$> strDomain_ if (claimDomain <$> contactDomain) == domain_ then pure $ CRUserProfileNoChange user @@ -1637,7 +1639,7 @@ processChatCommand cxt nm = \case APISetUserUIThemes uId uiThemes -> withUser $ \user@User {userId} -> do user'@User {userId = uId'} <- withFastStore $ \db -> do user' <- getUser db uId - liftIO $ setUserUIThemes db user uiThemes + liftIO $ setUserUIThemes db user' uiThemes pure user' when (userId == uId') $ chatWriteVar currentUser $ Just (user :: User) {uiThemes} ok user' @@ -2480,12 +2482,12 @@ processChatCommand cxt nm = \case pure $ CRUserContactLinkCreated user ccLink'' CreateMyAddress ratchetKeys_ -> withUser $ \User {userId} -> processChatCommand cxt nm $ APICreateMyAddress userId Nothing ratchetKeys_ - APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do + APIDeleteMyAddress userId -> withUserProfileLock "deleteMyAddress" $ withUserId userId $ \user@User {profile = p} -> do conn <- withFastStore $ \db -> getUserAddressConnection db cxt user withChatLock "deleteMyAddress" $ do deleteAgentConnectionAsync $ aConnId conn withFastStore' (`deleteUserAddress` user) - let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} + let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing, contactDomain = Nothing} r <- updateProfile_ user p' False $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing let user' = case r of CRUserProfileUpdated u' _ _ _ -> u' @@ -2503,10 +2505,10 @@ processChatCommand cxt nm = \case CRUserContactLink user <$> (withFastStore (`getUserAddress` user) >>= setMyAddressData False pqInitKeys user) APIRotateAddressRatchetKeys userId -> withUserId' userId $ \user -> CRUserContactLink user <$> (withFastStore (`getUserAddress` user) >>= setMyAddressData True (Just IKUsePQ) user) - APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do - let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} + APISetProfileAddress userId False -> withUserProfileLock "setProfileAddress" $ withUserId userId $ \user@User {profile = p} -> do + let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing, contactDomain = Nothing} updateProfile_ user p' True $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing - APISetProfileAddress userId True -> withUserId userId $ \user@User {profile = p} -> do + APISetProfileAddress userId True -> withUserProfileLock "setProfileAddress" $ withUserId userId $ \user@User {profile = p} -> do ucl <- withFastStore (`getUserAddress` user) -- TODO [short links] replace with short links let p' = (fromLocalProfile p :: Profile) {contactLink = Just $ profileContactLink ucl} @@ -3582,23 +3584,23 @@ processChatCommand cxt nm = \case -- 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 - SetBotCommands commands -> withUser $ \user@User {profile} -> do + SetBotCommands commands -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} p = (fromLocalProfile profile :: Profile) {preferences = prefs, peerType = Just CPTBot} updateProfile user p - UpdateProfile displayName shortDescr -> withUser $ \user@User {profile} -> do + UpdateProfile displayName shortDescr -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {displayName, shortDescr, fullName = ""} updateProfile user p - UpdateProfileImage image -> withUser $ \user@User {profile} -> do + UpdateProfileImage image -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {image} updateProfile user p - UpdateProfileImageFromFile path -> withUser $ \user@User {profile} -> do + UpdateProfileImageFromFile path -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do img <- readProfileImageFile path let p = (fromLocalProfile profile :: Profile) {image = Just img} updateProfile user p ShowProfileImage -> withUser $ \user@User {profile} -> pure $ CRUserProfileImage user $ fromLocalProfile profile - SetUserFeature (ACF f) allowed -> withUser $ \user@User {profile} -> do + SetUserFeature (ACF f) allowed -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference f (Just allowed) $ preferences' user} updateProfile user p SetContactFeature (ACF f) cName allowed_ -> withUser $ \user -> do @@ -3616,7 +3618,7 @@ processChatCommand cxt nm = \case case memberAdmission of Nothing -> p {memberAdmission = Just (emptyGroupMemberAdmission :: GroupMemberAdmission) {review = reviewAdmissionApplication}} Just ma -> p {memberAdmission = Just (ma :: GroupMemberAdmission) {review = reviewAdmissionApplication}} - SetUserTimedMessages onOff -> withUser $ \user@User {profile} -> do + SetUserTimedMessages onOff -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let allowed = if onOff then FAYes else FANo pref = TimedMessagesPreference allowed Nothing p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference' SCFTimedMessages (Just pref) $ preferences' user} @@ -3685,6 +3687,7 @@ processChatCommand cxt nm = \case CLContactRequest crId -> "ContactRequest " <> tshow crId CLFile fId -> "File " <> tshow fId CLBadgeUser uId -> "BadgeUser " <> tshow uId + CLUserProfile -> "UserProfile" DebugEvent event -> toView event >> ok_ GetAgentSubsTotal userId -> withUserId userId $ \user -> do users <- withStore' $ \db -> getUsers db @@ -4009,20 +4012,29 @@ processChatCommand cxt nm = \case when (fileSize > maxSndXFTPFileSize lims now sndBadge) $ throwChatError $ CEFileSize f pure fileSize updateProfile :: User -> Profile -> CM ChatResponse - updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' + updateProfile user@User {profile = LocalProfile {contactDomain}} p = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' + where + p' = (p :: Profile) {contactDomain} updateProfile_ :: User -> Profile -> Bool -> CM User -> CM ChatResponse updateProfile_ user@User {profile = p@LocalProfile {displayName = n}} p'@Profile {displayName = n', image = img'} shouldUpdateAddressData updateUser | p' == fromLocalProfile p = pure $ CRUserProfileNoChange user | otherwise = do - when (n /= n') $ checkValidName n' + when (n /= n') $ do + checkValidName n' + users <- withFastStore' getUsers + forM_ users $ \User {profile = LocalProfile {displayName}, activeUser, viewPwdHash} -> + when (displayName == n') . throwChatError $ + if activeUser || isNothing viewPwdHash then CEUserExists n' else CEInvalidDisplayName {displayName = n', validName = ""} checkProfileImageSize img' checkProfileSize p' + when shouldUpdateAddressData $ do + ts <- liftIO getCurrentTime + setMyAddressData' (user :: User) {profile = updatedUserProfile user p' ts} -- read contacts before user update to correctly merge preferences contacts <- withFastStore' $ \db -> getUserContacts db cxt user user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') withChatLock "updateProfile" $ do - when shouldUpdateAddressData $ setMyAddressData' user' summary <- sendUpdateToContacts user' contacts pure $ CRUserProfileUpdated user' (fromLocalProfile p) p' summary where @@ -5630,7 +5642,7 @@ presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now | otherwise = do cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId) forM_ cred_ $ \cred@(BadgeCredential _ _ _ info) -> - when (presentDue cred) $ do + when (presentDue cred) $ withUserProfileLock "presentIssuedBadge" $ do user' <- withStore $ \db -> setUserBadge db user (Just $ OwnBadge cred (mkBadgeStatus now (Just True) info)) presentUserBadgeToContacts user' where @@ -5643,7 +5655,7 @@ presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now retireExpiredBadge :: User -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM Bool retireExpiredBadge user UserBadgePurchase {badgePurchaseId, shown} now balance | not (shown && L.paidThrough balance <= now) = pure False - | otherwise = do + | otherwise = withUserProfileLock "retireExpiredBadge" $ do user' <- withStore $ \db -> do liftIO $ clearShownBadge db user badgePurchaseId setUserBadge db user Nothing @@ -6678,8 +6690,11 @@ displayNameP_ = quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',') refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' mkValidName :: String -> String -mkValidName = dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) +mkValidName = noLdnSuffix . dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where + noLdnSuffix s = case break (== '_') (reverse s) of + (ds@(_ : _), '_' : rest@(_ : _)) | all isDigit ds -> reverse rest <> ('-' : reverse ds) + _ -> s fst3 (x, _, _) = x addChar (r, prev, punct) c' = if validChar then (c : r, c, punct') else (r, prev, punct) where diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index effa943cc7..4008bc2727 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -160,6 +160,10 @@ withFileLock :: Text -> Int64 -> CM a -> CM a withFileLock name = withEntityLock name . CLFile {-# INLINE withFileLock #-} +withUserProfileLock :: Text -> CM a -> CM a +withUserProfileLock name = withEntityLock name CLUserProfile +{-# INLINE withUserProfileLock #-} + useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p) useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains = fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c3d9f2c0bd..3b52c46fef 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -44,6 +44,7 @@ module Simplex.Chat.Store.Profiles updateUserAutoAcceptMemberContacts, updateUserAutoAcceptGroupInvitations, updateUserProfile, + updatedUserProfile, setUserBadge, setUserProfileContactLink, getUserContactProfiles, @@ -169,7 +170,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing) + pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order, displayName) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing) -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -344,21 +345,15 @@ updateUserProfile db user p' currentTs <- getCurrentTime updateUserProfileFields_' db userId profileId p' currentTs userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs - pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {profile = updatedUserProfile user p' currentTs, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} | otherwise = - checkConstraint SEDuplicateName . liftIO $ do + checkConstraint SEDuplicateName . ExceptT . withLocalDisplayName db userId newName $ \newLDN -> do currentTs <- getCurrentTime - -- Insert first: checkConstraint returns the violation as a value, so the - -- transaction commits, keeping whatever ran before the failing insert. - DB.execute - db - "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (newName, newName, userId, currentTs, currentTs) - DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) + DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newLDN, currentTs, userId) userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs updateUserProfileFields_' db userId profileId p' currentTs - updateContactLDN_ db user userContactId localDisplayName newName currentTs - pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + updateContactLDN_ db user userContactId localDisplayName newLDN currentTs + pure $ Right user {localDisplayName = newLDN, profile = updatedUserProfile user p' currentTs, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs | userMemberProfileChanged = do @@ -366,10 +361,14 @@ updateUserProfile db user p' pure $ Just currentTs | otherwise = pure userMemberProfileUpdatedAt userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || desc' /= description || img' /= image - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, localBadge, localAlias}, userMemberProfileUpdatedAt} = user + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image}, userMemberProfileUpdatedAt} = user Profile {displayName = newName, fullName = fn', shortDescr = d', description = desc', image = img', preferences} = p' fullPreferences = fullPreferences' preferences +updatedUserProfile :: User -> Profile -> UTCTime -> LocalProfile +updatedUserProfile User {profile = LocalProfile {profileId, localBadge, localAlias}} p' ts = + (toLocalProfile profileId p' localAlias ts (Just False) Nothing) {localBadge} + -- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge) updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType} updatedAt = @@ -411,19 +410,20 @@ setUserSimplexDomain db user@User {userId, profile = p@LocalProfile {profileId}} pure (user :: User) {profile = p {contactDomain = mkDomainClaim <$> domain_}} setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User -setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do +setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId, contactDomain}} ucl_ = do ts <- getCurrentTime DB.execute db [sql| UPDATE contact_profiles - SET contact_link = ?, updated_at = ? + SET contact_link = ?, contact_domain = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? |] - (contactLink, ts, userId, profileId) - pure (user :: User) {profile = p {contactLink}} + (contactLink, claimDomain <$> contactDomain', ts, userId, profileId) + pure (user :: User) {profile = p {contactLink, contactDomain = contactDomain'}} where contactLink = profileContactLink <$> ucl_ + contactDomain' = if isJust contactLink then contactDomain else Nothing -- only used in tests getUserContactProfiles :: DB.Connection -> User -> IO [Profile] 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 7c2e56c2b7..00886165b5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1391,6 +1391,10 @@ Query: SELECT conn_id FROM connections WHERE user_id = ? Plan: SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) +Query: SELECT conn_id FROM rcv_queues WHERE switch_status = ? AND deleted = 0 +Plan: +SCAN rcv_queues + Query: SELECT conn_id, rc_verify_code_ad, rc_verify_code_pq, CASE WHEN rc_verify_code_ad IS NULL THEN ratchet_state END 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 c169b95d9a..7b76c06527 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5344,7 +5344,7 @@ SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET contact_link = ?, updated_at = ? + SET contact_link = ?, contact_domain = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -6365,7 +6365,7 @@ Plan: SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6378,7 +6378,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6392,7 +6392,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6406,7 +6406,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6421,7 +6421,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6435,7 +6435,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6449,7 +6449,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6463,7 +6463,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6477,7 +6477,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -6490,7 +6490,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -7632,6 +7632,10 @@ Query: SELECT member_id FROM group_members WHERE local_display_name = ? Plan: SCAN group_members +Query: SELECT member_id FROM group_members WHERE member_category = ? +Plan: +SCAN group_members + Query: SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1 Plan: SCAN group_members @@ -7908,6 +7912,10 @@ Query: UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user Plan: SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE contact_profiles SET contact_domain_verified = NULL WHERE user_id = ? AND contact_profile_id = ? +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET image = ? WHERE display_name = ? Plan: SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) @@ -8100,6 +8108,10 @@ Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE g Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_profiles SET description = ? WHERE display_name = ? +Plan: +SCAN group_profiles + Query: UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ? Plan: SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c3d42de3d1..908d5a823b 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -74,6 +74,7 @@ data ChatLockEntity | CLContactRequest Int64 | CLFile Int64 | CLBadgeUser Int64 -- one signed badge request per profile in flight + | CLUserProfile deriving (Eq, Ord) -- These error type constructors must be added to mobile apps @@ -558,7 +559,7 @@ toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, co userQuery :: Query userQuery = [sql| - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -566,9 +567,9 @@ userQuery = JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User -toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInv, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} +toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User +toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, localDisplayName) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInv, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 3608e5f7e9..e349a71f0a 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -8036,8 +8036,8 @@ testForwardQuoteMention = bob <# "alice> -> forwarded" bob <## " hello @alice @cath" -- member renamed to duplicate name - cath ##> "/p alice_1" - cath <## "user profile is changed to alice_1 (your 1 contacts are notified)" + cath ##> "/p alice" + cath <## "user profile is changed to alice (your 1 contacts are notified)" alice <## "contact cath changed to alice_1" alice <## "use @alice_1 to send messages" -- mention changed in quoted mentions diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index 6421459124..29a8c00c4f 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -16,6 +16,9 @@ import Test.Hspec hiding (it) chatNamesTests :: SpecWith TestParams chatNamesTests = do it "connect by resolved name" testConnectByName + it "profile update without the name keeps the name" testUpdateProfileKeepsName + it "stopping address sharing removes the name" testAddressSharingOffRemovesName + it "deleting the address removes the name" testAddressDeleteRemovesName it "connect by name not claimed in link profile is rejected" testConnectByNameNotClaimed it "connect by name to a known contact not claimed in profile is rejected" testConnectByNameKnownContactNotClaimed it "connect by unregistered name fails to resolve" testConnectByNameNotFound @@ -62,6 +65,123 @@ testConnectByName ps = withSmpServerAndNames ps $ \reg -> _ <- getTermLine bob pure () +testUpdateProfileKeepsName :: HasCallStack => TestParams -> IO () +testUpdateProfileKeepsName ps = withSmpServerAndNames ps $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + mapM_ enableNamesRole [alice, bob] + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + bob ##> "/c @alice.simplex" + bob <## "alice: connection started" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice ##> ("/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"\", \"shortDescr\": \"new bio\", \"contactLink\": \"" <> shortLink <> "\"}") + alice <## "user bio changed to new bio (your 1 contacts are notified)" + bob <## "contact alice updated bio: new bio" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + _ <- getTermLine bob + bob <## "SimpleX name: @alice.simplex (verified)" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + _ <- getTermLine bob + pure () + +testAddressSharingOffRemovesName :: HasCallStack => TestParams -> IO () +testAddressSharingOffRemovesName ps = withSmpServerAndNames ps $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + mapM_ enableNamesRole [alice, bob] + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + bob ##> "/c @alice.simplex" + bob <## "alice: connection started" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice ##> "/pa off" + alice <## "contact address removed" + bob <## "alice removed contact address" + checkNoName bob + alice ##> "/pa on" + alice <## "new contact address set" + bob <## "alice set new contact address, use /info alice to view" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + _ <- getTermLine bob + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + _ <- getTermLine bob + pure () + +testAddressDeleteRemovesName :: HasCallStack => TestParams -> IO () +testAddressDeleteRemovesName ps = withSmpServerAndNames ps $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + mapM_ enableNamesRole [alice, bob] + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + bob ##> "/c @alice.simplex" + bob <## "alice: connection started" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice ##> "/da" + alice <## "Your chat address is deleted - accepted contacts will remain connected." + alice <## "To create a new chat address use /ad" + bob <## "alice removed contact address" + checkNoName bob + +checkNoName :: HasCallStack => TestCC -> IO () +checkNoName bob = do + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + _ <- getTermLine bob + pure () + testConnectByNameNotClaimed :: HasCallStack => TestParams -> IO () testConnectByNameNotClaimed ps = withSmpServerAndNames ps $ \reg -> testChat2 aliceProfile bobProfile (test reg) ps diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 31ca1bae73..06b85fdb72 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -46,6 +46,10 @@ chatProfileTests :: SpecWith TestParams chatProfileTests = do describe "user profiles" $ do it "update user profile and notify contacts" testUpdateProfile + it "profile is not saved when its address data upload fails" testUpdateProfileAddressDataError + it "profile name used by a contact gets a local name with suffix" testUpdateProfileNameUsedByContact + it "profile name used by another user is rejected, names with _N suffix are invalid" testUpdateProfileNameUsedByUser + it "profile name used by a hidden user is rejected" testUpdateProfileNameUsedByHiddenUser it "profile description round-trips and shows in contact info" testProfileDescriptionShown it "member profile description is redacted for members without a direct contact" testMemberDescriptionRedacted it "update user profile with image" testUpdateProfileImage @@ -210,6 +214,117 @@ testUpdateProfile = bob <## "use @cat to send messages" ] +testUpdateProfileAddressDataError :: HasCallStack => TestParams -> IO () +testUpdateProfileAddressDataError ps = do + let cfg' = testCfg {agentConfig = testAgentCfg {persistErrorInterval = 0}} + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do + withSmpServer' serverCfg' $ do + connectUsers alice bob + alice ##> "/ad" + _ <- getContactLinks alice True + pure () + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" + alice ##> "/p alisa" + alice <##. "smp agent error: BROKER" + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + withSmpServer' serverCfg' $ do + alice <## "subscribed 2 connections on server localhost" + bob <## "subscribed 1 connections on server localhost" + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 1 contacts are notified)" + bob <## "contact alice changed to alisa" + bob <## "use @alisa to send messages" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" + where + serverCfg' = + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], + serverStoreCfg = persistentServerStoreCfg (tmpPath ps) + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + +testUpdateProfileNameUsedByContact :: HasCallStack => TestParams -> IO () +testUpdateProfileNameUsedByContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + alice ##> "/p bob" + alice <## "user profile is changed to bob (your 1 contacts are notified)" + bob <## "contact alice changed to bob_1" + bob <## "use @bob_1 to send messages" + alice ##> "/p" + alice <## "user profile: bob" + alice <## "use /p [] to change it" + alice ##> "/users" + alice <## "bob_1 (active)" + alice #> "@bob hi" + bob <# "bob_1> hi" + cath ##> ("/_connect plan 1 " <> shortLink) + cath <## "contact address: ok to connect" + sLinkData <- getTermLine cath + sLinkData `shouldContain` "\"displayName\":\"bob\"" + alice ##> "/p alice" + alice <## "user profile is changed to alice (your 1 contacts are notified)" + bob <## "contact bob_1 changed to alice" + bob <## "use @alice to send messages" + alice ##> "/users" + alice <## "alice (active)" + +testUpdateProfileNameUsedByHiddenUser :: HasCallStack => TestParams -> IO () +testUpdateProfileNameUsedByHiddenUser = + testChat aliceProfile $ + \alice -> do + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/hide user my_password" + alice <## "current user alisa:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice ##> "/p alisa" + alice <## "invalid display name: alisa" + +testUpdateProfileNameUsedByUser :: HasCallStack => TestParams -> IO () +testUpdateProfileNameUsedByUser = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice ##> "/p alisa" + alice <## "user with the name alisa already exists" + alice ##> "/p alisa_1" + alice <## "invalid display name: alisa_1" + alice <## "you could use this one: alisa-1" + alice ##> "/p alisa-1" + alice <## "user profile is changed to alisa-1 (your 1 contacts are notified)" + bob <## "contact alice changed to alisa-1" + bob <## "use @alisa-1 to send messages" + alice ##> "/create user alisa-1" + alice <## "user with the name alisa-1 already exists" + alice ##> "/create user bob_2" + alice <## "invalid display name: bob_2" + alice <## "you could use this one: bob-2" + alice ##> "/users" + alice <## "alisa" + alice <## "alisa-1 (active)" + -- Profile.description survives the connect-time round-trip and is shown in the contact /i view. testProfileDescriptionShown :: HasCallStack => TestParams -> IO () testProfileDescriptionShown = diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index dd8433d231..a69f897516 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -57,3 +57,10 @@ testMkValidName = do mkValidName "123 alice" `shouldBe` "123 alice" mkValidName "01234567890123456789012345678901234567890123456789extra" `shouldBe` "01234567890123456789012345678901234567890123456789" mkValidName "0123456789012345678901234567890123456789012345678 extra" `shouldBe` "0123456789012345678901234567890123456789012345678" + mkValidName "alice_1" `shouldBe` "alice-1" + mkValidName "alice_bob_12" `shouldBe` "alice_bob-12" + mkValidName "alice _1" `shouldBe` "alice -1" + mkValidName "alice_" `shouldBe` "alice_" + mkValidName "alice_bob" `shouldBe` "alice_bob" + mkValidName "alice_1b" `shouldBe` "alice_1b" + mkValidName (mkValidName "alice_1") `shouldBe` "alice-1" From f2b3fe385cecddaa1a9ede2e6ed8dfdb63cee51f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 9 Oct 2026 07:00:27 +0100 Subject: [PATCH 28/94] core: remove legacy field to support contact connections in groups (#7671) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/SimpleXChat/ChatTypes.swift | 1 + docs/protocol/simplex-chat.schema.json | 3 --- src/Simplex/Chat/Library/Subscriber.hs | 20 ++++++--------- src/Simplex/Chat/Store/Groups.hs | 34 ++------------------------ src/Simplex/Chat/Types.hs | 3 +-- src/Simplex/Chat/protocol.md | 6 ++--- tests/ProtocolTests.hs | 12 +++------ 7 files changed, 17 insertions(+), 62 deletions(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4a95ed23eb..a8c5de3672 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -133,6 +133,7 @@ public struct Profile: Codable, NamedChat, Hashable { self.image = image self.contactLink = contactLink self.preferences = preferences + self.peerType = peerType self.contactDomain = contactDomain } diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 8a71e715db..a2400e9947 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -325,9 +325,6 @@ "introInvitation": { "properties": { "groupConnReq": {"ref": "connReqUri"} - }, - "optionalProperties": { - "directConnReq": {"ref": "connReqUri"} } }, "groupMemberRole": { diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 304aa15500..4ff32429b9 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -774,7 +774,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag hostConnId <- withStore $ \db -> do liftIO $ setConnConnReqInv db user connId cReq getHostConnId db user groupId - sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} + sendXGrpMemInv hostConnId XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _pqSupport _ connInfo -> do @@ -3304,10 +3304,10 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId (chatHasNtfs chatSettings) SCMInvitation CR.IKPQOff True subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" - sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> CM () - sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do + sendXGrpMemInv :: Int64 -> XGrpMemIntroCont -> CM () + sendXGrpMemInv hostConnId XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do hostConn <- withStore $ \db -> getConnectionById db cxt user hostConnId - let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} + let msg = XGrpMemInv memberId IntroInvitation {groupConnReq} void $ sendDirectMemberMessage hostConn msg groupId withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited @@ -3321,7 +3321,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _ _) IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _ _) IntroInvitation {groupConnReq} = do let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- withStore $ \db -> do @@ -3341,20 +3341,16 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag liftIO $ updateGroupMemberStatus db userId toMember newMemberStatus pure toMember subMode <- chatReadVar subscriptionMode - -- [incognito] send membership incognito profile, create direct connection as incognito + -- [incognito] send membership incognito profile membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability let enableNtfsGrp = chatHasNtfs chatSettings groupConnIds@(gCmdId, gAcId) <- prepareAgentJoin user Nothing enableNtfsGrp groupConnReq - directConnIds <- mapM (prepareAgentJoin user Nothing True) directConnReq - let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - mcvr = maybe chatInitialVRange fromChatVRange memChatVRange + let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange chatV = vr cxt `peerConnChatVersion` mcvr - withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode + withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds subMode joinAgentConnectionAsync gCmdId False gAcId enableNtfsGrp groupConnReq dm subMode - forM_ ((,) <$> directConnIds <*> directConnReq) $ \((dCmdId, dAcId), dcr) -> - joinAgentConnectionAsync dCmdId False dAcId True dcr dm subMode -- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted -- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 66a5dc7783..a376ee7ca5 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -2645,42 +2645,12 @@ createIntroReMemberConn liftIO $ setCommandConnId db user groupCmdId groupConnId pure (reMember :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () -createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> SubscriptionMode -> IO () +createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId} chatV mcvr (groupCmdId, groupAgentConnId) subMode = do let cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- getCurrentTime Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId chatV mcvr viaContactId cLevel currentTs subMode setCommandConnId db user groupCmdId groupConnId - forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId ConnNew chatV mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff - setCommandConnId db user directCmdId directConnId - contactId <- createMemberContact_ directConnId currentTs - updateMember_ contactId currentTs - where - createMemberContact_ :: Int64 -> UTCTime -> IO Int64 - createMemberContact_ connId ts = do - DB.execute - db - [sql| - INSERT INTO contacts (contact_profile_id local_display_name, user_id, created_at, updated_at, chat_ts) - SELECT contact_profile_id, ?, ?, ?, ?, ? - FROM group_members - WHERE group_member_id = ? - |] - (localDisplayName, userId, ts, ts, ts, groupMemberId) - contactId <- insertedRowId db - DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, ts, connId) - pure contactId - updateMember_ :: Int64 -> UTCTime -> IO () - updateMember_ contactId ts = - DB.execute - db - [sql| - UPDATE group_members - SET contact_id = ?, updated_at = ? - WHERE group_member_id = ? - |] - (contactId, ts, groupMemberId) createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index ce5879538b..cea169a4a4 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1085,8 +1085,7 @@ data MemberIdRole = MemberIdRole deriving (Eq, Show) data IntroInvitation = IntroInvitation - { groupConnReq :: ConnReqInvitation, - directConnReq :: Maybe ConnReqInvitation + { groupConnReq :: ConnReqInvitation } deriving (Eq, Show) diff --git a/src/Simplex/Chat/protocol.md b/src/Simplex/Chat/protocol.md index 33161a45a7..06b34d92eb 100644 --- a/src/Simplex/Chat/protocol.md +++ b/src/Simplex/Chat/protocol.md @@ -197,8 +197,7 @@ Events: { "memberId": "", "memberIntro": { - "groupConnReq": "", - "directConnReq": "" + "groupConnReq": "" } } @@ -214,8 +213,7 @@ Events: }, }, "memberIntro": { - "groupConnReq": "", - "directConnReq": "" + "groupConnReq": "" } } diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index e341a210a7..2d9842e9ee 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -369,17 +369,11 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" - #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} - it "x.grp.mem.inv w/t directConnReq" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" - #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} - it "x.grp.mem.fwd" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} - it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ + #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq} + it "x.grp.mem.fwd with member chat version range" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq} it "x.grp.mem.info" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile From f924d233e45623028e2aaabc8eb5855bd338b0ae Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 9 Oct 2026 07:48:04 +0100 Subject: [PATCH 29/94] core: fix encoding for name proof (#7672) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Badges.hs | 8 +++++++- tests/BadgeTests.hs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs index 62683788d5..f34ba8ef93 100644 --- a/src/Simplex/Chat/Badges.hs +++ b/src/Simplex/Chat/Badges.hs @@ -295,7 +295,13 @@ data ProofPresHeader | PHFileDescr {chatBinding :: ByteString, fileSize :: Int64, descrHash :: ByteString, fileExpires :: Maybe UTCTime} | PHUnknown Char ByteString deriving (Eq, Show) - deriving (ToJSON, FromJSON) via (StrJSON "ProofPresHeader" ProofPresHeader) + +instance ToJSON ProofPresHeader where + toJSON = toJSON . BBSPresHeader . strEncode + toEncoding = toEncoding . BBSPresHeader . strEncode + +instance FromJSON ProofPresHeader where + parseJSON v = parseJSON v >>= \(BBSPresHeader ph) -> either fail pure $ strDecode ph instance StrEncoding ProofPresHeader where strEncode = \case diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index bd8f62d3fd..ced51a5bbf 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -51,6 +51,7 @@ badgeTests = do it "should accept unknown badge types" testUnknownBadgeType it "credential serializes to a paste-able token and back" testCredentialSerialization it "presentation headers encode and decode" testPresHeaderEncoding + it "presentation headers with binary bytes round-trip through JSON" testPresHeaderJSON it "should reject a proof presented under another chat binding" testOtherChatBinding describe "redemption codes" $ do it "a generated code reads back" testCodeRoundTrip @@ -209,6 +210,21 @@ testPresHeaderEncoding = PHUnknown 'Z' "payload" ] +testPresHeaderJSON :: IO () +testPresHeaderJSON = + mapM_ + ( \ph -> do + J.eitherDecode (J.encode ph) `shouldBe` Right ph + J.toJSON ph `shouldBe` J.toJSON (BBSPresHeader $ strEncode ph) + ) + [ PHChat binaryBytes, + PHFileInv {chatBinding = binaryBytes, fileSize = 139737}, + PHFileDescr {chatBinding = binaryBytes, fileSize = 139737, descrHash = binaryBytes, fileExpires = Just futureTime}, + PHUnknown 'Z' binaryBytes + ] + where + binaryBytes = "\0\1\31\34\92\127\128\195\255" + testOtherChatBinding :: IO () testOtherChatBinding = do let ph = PHFileInv {chatBinding = aliceBinding, fileSize = 139737} From 10166454fdc624b71acc5f76cf752905d340c212 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 9 Oct 2026 07:53:40 +0100 Subject: [PATCH 30/94] website: add sitemap, robots, llms.txt and markdown pages (#7670) * website: markdown pages for llms * website: add sitemap, robots, llms.txt and markdown pages * faster site generation * more faster * faster * update * optimize build --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- README.md | 13 + docs/WHY.md | 2 + website/.eleventy.js | 278 +++++++++++------- website/copy_sources.js | 81 +++++ website/customize_docs_frontmatter.js | 131 ++++----- website/langs/cs.json | 2 +- website/langs/en.json | 1 + website/markdown_pages.js | 272 +++++++++++++++++ website/package.json | 4 +- website/plans/2026-10-08-llms.md | 99 +++++++ website/run.sh | 2 + website/src/_data/llms_facts.json | 219 ++++++++++++++ website/src/_includes/contact_page.html | 12 +- website/src/_includes/layouts/article.html | 1 + website/src/_includes/layouts/channel.html | 13 + website/src/_includes/layouts/doc.html | 1 + website/src/_includes/layouts/jobs.html | 1 + website/src/_includes/layouts/main.html | 1 + website/src/_includes/layouts/page.html | 1 + website/src/_includes/layouts/privacy.html | 1 + website/src/_includes/layouts/token.html | 1 + website/src/_includes/llms/agents.md | 157 ++++++++++ website/src/_includes/llms/answers.md | 73 +++++ website/src/_includes/llms/facts.md | 12 + website/src/_includes/llms/timeline.md | 27 ++ website/src/_includes/llms/wefunder.md | 194 ++++++++++++ website/src/_includes/markdown-alternate.html | 3 + .../_includes/sections/simplex_unique.html | 2 +- website/src/crowdfunding-news.html | 18 +- website/src/crowdfunding.html | 4 +- website/src/directory.html | 1 + website/src/index.html | 11 +- website/src/links.html | 6 +- website/src/livestream.html | 1 + website/src/llms/agents.njk | 5 + website/src/llms/answers.njk | 5 + website/src/llms/docs.njk | 11 + website/src/llms/facts-json.njk | 5 + website/src/llms/facts.njk | 5 + website/src/llms/llms-full.njk | 63 ++++ website/src/llms/llms-txt.njk | 88 ++++++ website/src/llms/robots.njk | 8 + website/src/llms/sitemap.njk | 12 + website/src/llms/timeline.njk | 5 + website/src/llms/wefunder.njk | 5 + website/src/messaging.html | 2 +- website/src/news.html | 19 +- website/src/wefunder.html | 1 + website/src/why.html | 4 + website/web.sh | 17 +- 50 files changed, 1664 insertions(+), 236 deletions(-) create mode 100644 website/copy_sources.js create mode 100644 website/markdown_pages.js create mode 100644 website/plans/2026-10-08-llms.md create mode 100644 website/src/_data/llms_facts.json create mode 100644 website/src/_includes/layouts/channel.html create mode 100644 website/src/_includes/llms/agents.md create mode 100644 website/src/_includes/llms/answers.md create mode 100644 website/src/_includes/llms/facts.md create mode 100644 website/src/_includes/llms/timeline.md create mode 100644 website/src/_includes/llms/wefunder.md create mode 100644 website/src/_includes/markdown-alternate.html create mode 100644 website/src/llms/agents.njk create mode 100644 website/src/llms/answers.njk create mode 100644 website/src/llms/docs.njk create mode 100644 website/src/llms/facts-json.njk create mode 100644 website/src/llms/facts.njk create mode 100644 website/src/llms/llms-full.njk create mode 100644 website/src/llms/llms-txt.njk create mode 100644 website/src/llms/robots.njk create mode 100644 website/src/llms/sitemap.njk create mode 100644 website/src/llms/timeline.njk create mode 100644 website/src/llms/wefunder.njk diff --git a/README.md b/README.md index fa4247caf8..cb792e84f5 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ SimpleX Chat founder - [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations) - [For developers](#for-developers) - [Develop a chat bot](#develop-a-chat-bot) +- [Agents](#agents) - [Roadmap](#roadmap) - [Disclaimers, Security contact, License](#disclaimers) @@ -331,6 +332,18 @@ See [our new bot API reference](./bots/README.md). Most of it is automatically g Also see [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js). +## Agents + +- For AI assistants: [simplex.chat/llms.txt](https://simplex.chat/llms.txt), an index of the documentation in Markdown, and [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md). +- Every page of simplex.chat is also available as Markdown: replace `.html` with `.md` ([simplex.chat/docs/simplex.md](https://simplex.chat/docs/simplex.md)), or add `.md` to the path ([simplex.chat/why.md](https://simplex.chat/why.md)). +- Libraries: [Node.js](./packages/simplex-chat-nodejs/) ([npm](https://www.npmjs.com/package/simplex-chat)) and [Python](./packages/simplex-chat-python/) ([PyPI](https://pypi.org/project/simplex-chat/)). +- [Bot API guide](./bots/README.md) and [API reference](./bots/api/README.md): commands, events and types. +- Example bots: [support bot](./apps/simplex-support-bot/) and [calculator](./apps/simplex-calculator-bot/) in TypeScript, [support bot light](./apps/simplex-support-bot-light/) in Python, [directory service](./apps/simplex-directory-service/) and [broadcast bot](./apps/simplex-broadcast-bot/) in Haskell. +- [Terminal client](./docs/CLI.md): installation, building from source and usage. +- Servers: [SMP](./docs/SERVER.md), [XFTP](./docs/XFTP-SERVER.md) and [chat relay](./docs/CHAT-RELAY.md). +- Protocols: [SimpleX network](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md), [chat](./docs/protocol/simplex-chat.md), [channels](./docs/protocol/channels-overview.md) and [public names](./docs/protocol/names-overview.md). +- For coding agents working in this repository: [project structure](./docs/contributing/PROJECT.md) and [coding and building](./docs/contributing/CODE.md). + ## Roadmap - ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies. diff --git a/docs/WHY.md b/docs/WHY.md index e1582984df..b36df4a154 100644 --- a/docs/WHY.md +++ b/docs/WHY.md @@ -17,3 +17,5 @@ The oldest human freedom — to speak to another person without being watched Because we destroyed the power to know who you are. So that your power can never be taken. Be free in your network. + +[Watch the film](https://www.youtube.com/watch?v=k-GhLNZm8G0) diff --git a/website/.eleventy.js b/website/.eleventy.js index a96614e4ba..dcd0f67916 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -4,22 +4,44 @@ const markdownItReplaceLink = require('markdown-it-replace-link') const markdownItFootnote = require('markdown-it-footnote') const slugify = require("slugify") const uri = require('fast-uri') -const i18n = require('eleventy-plugin-i18n') const fs = require("fs") const path = require("path") const matter = require('gray-matter') const pluginRss = require('@11ty/eleventy-plugin-rss') const { JSDOM } = require('jsdom') +const parse5 = require('parse5') +const markdownPages = require('./markdown_pages') // Links page data const parseLinks = require('./parse_links') const linksFilePath = path.resolve(__dirname, '../docs/LINKS.md') -const linksData = fs.existsSync(linksFilePath) ? parseLinks(linksFilePath) : [] const linkImagesDir = path.resolve(__dirname, 'src/link-images') -linksData.forEach(entry => { - entry.imageExists = entry.image && fs.existsSync(path.join(linkImagesDir, entry.image)) -}) + +function readLinksPage() { + const entries = fs.existsSync(linksFilePath) ? parseLinks(linksFilePath) : [] + entries.forEach(entry => { + entry.imageExists = entry.image && fs.existsSync(path.join(linkImagesDir, entry.image)) + }) + + const catCounts = {} + entries.forEach(e => { if (e.category) { const c = e.category.toLowerCase(); catCounts[c] = (catCounts[c] || 0) + 1 } }) + const mediaPills = ["Video", "Audio"].filter(p => entries.some(e => e.mediaType === p.toLowerCase())) + const catPills = Object.keys(catCounts).sort() + + return { + entries, + languages: [...new Set(entries.map(e => e.language).filter(Boolean))].sort(), + pills: mediaPills.concat(catPills), + } +} + +const permalinks = new Map() + +function filePermalink(file) { + if (!permalinks.has(file)) permalinks.set(file, matter(fs.readFileSync(file, 'utf8')).data?.permalink) + return permalinks.get(file) +} // Rewrites relative markdown links to website permalinks or GitHub URLs. // Shared by the main markdown renderer (markdownLib) and the glossary renderer @@ -39,8 +61,7 @@ function replaceLink(link, _env) { if (fs.existsSync(linkFile) && fs.statSync(linkFile).isFile()) { // this condition works if the link is a valid website file - const fileContent = fs.readFileSync(linkFile, 'utf8') - parsed.path = (matter(fileContent).data?.permalink || parsed.path).replace(/\.md$/, ".html").toLowerCase() + parsed.path = (filePermalink(linkFile) || parsed.path).replace(/\.md$/, ".html").toLowerCase() } else if (!fs.existsSync(linkFile)) { linkFile = linkFile.replace('/website/src', '') if (fs.existsSync(linkFile)) { @@ -94,8 +115,41 @@ glossary.forEach(item => { item.tooltip = firstParagraph item.hasMultipleParagraphs = paragraphCount > 1 } + + const definitionLinks = new JSDOM(item.definition).window.document.querySelectorAll('a[href*="#"]') + item.linkedHashes = Array.from(definitionLinks, a => a.href.substring(a.href.indexOf("#") + 1)) + item.id = item.term.toLowerCase().replace(/\s/g, '-') + item.pattern = new RegExp(`(? [item.id, item])) +const glossaryTermPattern = new RegExp(`(? item.term).join('|')})\\b`, 'i') +const glossaryContentTags = new Set(['p', 'td', 'a', 'h1', 'h2', 'h3', 'h4']) +const htmlParseOptions = { scriptingEnabled: false } +const closeOverlayIcon = '' + +function glossaryTooltipHtml(term) { + const readMoreButton = term.hasMultipleParagraphs + ? `` + : '' + return `

` +} + +function glossaryOverlayHtml(term) { + return `` +} + +function descendantElements(node, tags) { + return (node.childNodes || []).flatMap((child) => + tags.has(child.tagName) ? [child, ...descendantElements(child, tags)] : descendantElements(child, tags)) +} + +function parseChildren(node, html) { + const children = parse5.parseFragment(node, html, htmlParseOptions).childNodes + children.forEach((child) => child.parentNode = node) + return children +} + const globalConfig = { onionLocation: "http://isdb4l77sjqoy2qq7ipum6x3at6hyn3jmxfx4zdhc72ufbmuq4ilwkqd.onion", @@ -118,7 +172,11 @@ fs.readdir(translationsDirectoryPath, (err, files) => { const translations = require("./translations.json") +const outputDir = '_site' + module.exports = function (ty) { + ty.on("eleventy.before", () => permalinks.clear()) + // Add this after your markdownLib definition ty.addShortcode("mdInclude", function (filepath) { const fullPath = path.join(__dirname, 'src/_includes', filepath); @@ -126,14 +184,8 @@ module.exports = function (ty) { return markdownLib.render(content); }); - ty.addGlobalData("links", linksData) - ty.addGlobalData("linkLanguages", [...new Set(linksData.map(e => e.language).filter(Boolean))].sort()) - - const catCounts = {} - linksData.forEach(e => { if (e.category) { const c = e.category.toLowerCase(); catCounts[c] = (catCounts[c] || 0) + 1 } }) - const mediaPills = ["Video", "Audio"].filter(p => linksData.some(e => e.mediaType === p.toLowerCase())) - const catPills = Object.keys(catCounts).sort() - ty.addGlobalData("linkPills", mediaPills.concat(catPills)) + ty.addGlobalData("linksPage", readLinksPage) + ty.addWatchTarget(linksFilePath) ty.addShortcode("cfg", (name) => globalConfig[name]) @@ -160,100 +212,52 @@ module.exports = function (ty) { }) ty.addFilter('applyGlossary', function (content) { - const dom = new JSDOM(content) - const { document } = dom.window - const body = document.querySelector('body') - const allContentNodes = document.querySelectorAll('p, td, a, h1, h2, h3, h4') + if (!glossaryTermPattern.test(content)) return content + const document = parse5.parse(content, htmlParseOptions) + const body = document.childNodes.find((node) => node.tagName === 'html').childNodes.find((node) => node.tagName === 'body') + const allContentNodes = descendantElements(document, glossaryContentTags) + const contentHtml = allContentNodes.map((node) => parse5.serialize(node)) const overlayIds = [] + const matchedIds = new Set() - glossary.forEach((term, index) => { - let changeNoted = false - const id = term.term.toLowerCase().replace(/\s/g, '-') - - allContentNodes.forEach((node) => { - const regex = new RegExp(`(? { + glossary.forEach((term) => { + const id = term.id + allContentNodes.forEach((node, nodeIndex) => { + const beforeContent = contentHtml[nodeIndex] + const afterContent = beforeContent.replace(term.pattern, (match) => { return `${match}` }) - if (beforeContent !== node.innerHTML && !changeNoted) { - changeNoted = true + if (afterContent !== beforeContent) { + node.childNodes = parseChildren(node, afterContent) + contentHtml[nodeIndex] = parse5.serialize(node) + matchedIds.add(id) } }) + }) - if (changeNoted) { - const definitionTooltipDiv = document.createElement('div') - definitionTooltipDiv.id = `tooltip-${id}` - definitionTooltipDiv.className = "glossary-tooltip" - const titleH4 = document.createElement('h4') - titleH4.innerHTML = term.term - titleH4.className = "tooltip-title" - const p = document.createElement('p') - p.innerHTML = term.tooltip - const innerDiv = document.createElement('div') - innerDiv.appendChild(titleH4) - innerDiv.appendChild(p) - if (term.hasMultipleParagraphs) { - const readMoreBtn = document.createElement('button') - readMoreBtn.innerHTML = "Read more" - readMoreBtn.className = "read-more-btn open-overlay-btn" - readMoreBtn.setAttribute('data-show-overlay', id) - innerDiv.appendChild(readMoreBtn) - } - innerDiv.className = "tooltip-content" - definitionTooltipDiv.appendChild(innerDiv) - body.appendChild(definitionTooltipDiv) + const neededIds = new Set(matchedIds) + neededIds.forEach((id) => glossaryById.get(id).linkedHashes.forEach((hash) => { + if (glossaryById.has(hash)) neededIds.add(hash) + })) + + glossary.forEach((term) => { + const id = term.id + + if (matchedIds.has(id)) { + body.childNodes.push(...parseChildren(body, glossaryTooltipHtml(term))) } - let tooltipDom = new JSDOM(term.definition) - let tooltipDocument = tooltipDom.window.document - const hashList = [term.term.toLowerCase().replace(/\s/g, '-')] - tooltipDocument.querySelectorAll('a[href*="#"]').forEach(a => { - let hashIndex = a.href.indexOf("#") - if (hashIndex !== -1) { - let hash = a.href.substring(hashIndex + 1) - hashList.push(hash) - } - }) + const hashList = [id, ...term.linkedHashes] hashList.forEach(hash => { - if (!overlayIds.includes(hash)) { - let termFromHash = glossary.find(term => term.term.toLowerCase().replace(/\s/g, '-') === hash) - if (!termFromHash) return - - const overlayDiv = document.createElement('div') - overlayDiv.id = hash - overlayDiv.className = "overlay glossary-overlay hidden" - const overlayCardDiv = document.createElement('div') - overlayCardDiv.className = "overlay-card" - const overlayTitleH1 = document.createElement('h1') - overlayTitleH1.className = "overlay-title" - overlayTitleH1.innerHTML = termFromHash.term - const overlayContent = document.createElement('div') - overlayContent.className = "overlay-content" - overlayContent.innerHTML = termFromHash.definition - const crossSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg") - crossSVG.setAttribute('class', 'close-overlay-btn') - crossSVG.setAttribute('id', 'cross') - crossSVG.setAttribute('width', '16') - crossSVG.setAttribute('height', '16') - crossSVG.setAttribute('viewBox', '0 0 13 13') - crossSVG.setAttribute('xmlns', 'http://www.w3.org/2000/svg') - const crossPath = document.createElementNS("http://www.w3.org/2000/svg", "path") - crossPath.setAttribute('d', 'M12.7973 11.5525L7.59762 6.49833L12.7947 1.44675C13.055 1.19371 13.0658 0.771991 12.8188 0.505331C12.5718 0.238674 12.1602 0.227644 11.8999 0.480681L6.65343 5.58028L1.09979 0.182228C0.805 0.002228 0.430001 0.002228 0.135211 0.182228C-0.159579 0.362228 -0.159579 0.697228 0.135211 0.877228L5.68885 6.27528L0.4918 11.3295C0.231501 11.5825 0.220703 12.0042 0.467664 12.2709C0.714625 12.5376 1.12625 12.5486 1.38655 12.2956L6.63302 7.196L12.1867 12.5941C12.4815 12.7741 12.8565 12.7741 13.1513 12.5941C13.4461 12.4141 13.4461 12.0791 13.1513 11.8991L12.7973 11.5525Z') - crossSVG.appendChild(crossPath) - - overlayCardDiv.appendChild(overlayTitleH1) - overlayCardDiv.appendChild(overlayContent) - overlayCardDiv.appendChild(crossSVG) - overlayDiv.appendChild(overlayCardDiv) - body.appendChild(overlayDiv) + if (neededIds.has(hash) && !overlayIds.includes(hash)) { + body.childNodes.push(...parseChildren(body, glossaryOverlayHtml(glossaryById.get(hash)))) overlayIds.push(hash) } }) }) - return dom.serialize() + return parse5.serialize(document) }) ty.addFilter('wrapH3s', function (content, page) { @@ -334,14 +338,81 @@ module.exports = function (ty) { } }) + ty.addFilter("markdownUrl", (url) => markdownPages.markdownUrl(url, supportedLangs)) + + ty.addFilter("markdownAlternate", (page) => + markdownPages.markdownKind(page.inputPath, supportedLangs) ? markdownPages.markdownUrl(page.url, supportedLangs) : "") + + ty.addFilter("markdownSource", (inputPath) => markdownPages.markdownFromSource(inputPath, replaceLink)) + + ty.addFilter("markdownTitle", markdownPages.markdownTitle) + + ty.addFilter("plainText", markdownPages.plainText) + + const outputSections = ["pages", "blog pages", "doc pages", "language pages", "other files", "Markdown files"] + const outputCounts = new Map() + + function countOutput(section) { + outputCounts.set(section, (outputCounts.get(section) || 0) + 1) + } + + function outputSection(outputPath) { + const [folder] = path.relative(outputDir, outputPath).split(path.sep) + if (supportedLangs.includes(folder)) return "language pages" + if (folder === "blog") return "blog pages" + if (folder === "docs") return "doc pages" + return outputPath.endsWith(".html") ? "pages" : "other files" + } + + ty.on("eleventy.before", () => outputCounts.clear()) + + ty.on("eleventy.after", () => { + const counts = outputSections.filter((section) => outputCounts.has(section)).map((section) => `${outputCounts.get(section)} ${section}`) + console.log(`[11ty] Wrote ${counts.join(", ")}`) + }) + + ty.addTransform("countOutputs", function (content) { + if (this.outputPath) countOutput(outputSection(this.outputPath)) + return content + }) + + ty.addTransform("markdownPages", function (content) { + const kind = markdownPages.markdownKind(this.inputPath, supportedLangs) + if (kind && this.outputPath) { + const pageUrl = "/" + path.relative(outputDir, this.outputPath).replace(/index\.html$/, "") + const markdown = kind === "source" + ? markdownPages.markdownFromSource(this.inputPath, replaceLink) + : markdownPages.markdownFromHtml(content, pageUrl) + const markdownPath = path.join(outputDir, markdownPages.markdownUrl(pageUrl, supportedLangs)) + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }) + fs.writeFileSync(markdownPath, markdown) + countOutput("Markdown files") + } + return content + }) + ty.addPlugin(pluginRss) - ty.addPlugin(i18n, { - translations, - fallbackLocales: { - '*': 'en' - }, - defaultLocale: 'en', + const unknownStrings = new Set() + + ty.addFilter("i18n", function (key, _data, lang) { + const locale = lang || (this.page || this.ctx.page)?.url?.split("/")[1] + const strings = translations[key] || {} + if (strings.en === undefined) unknownStrings.add(key) + return strings[locale] ?? strings.en ?? key + }) + + ty.on("eleventy.before", () => unknownStrings.clear()) + + ty.on("eleventy.after", () => { + const allStrings = Object.values(translations) + const untranslated = supportedLangs.filter((lang) => lang !== "en").sort() + .map((lang) => [lang, allStrings.filter((strings) => strings[lang] === undefined).length]) + .filter(([, count]) => count > 0) + if (untranslated.length > 0) { + console.warn(`[i18n] Untranslated of ${allStrings.length} strings, English used: ${untranslated.map(([lang, count]) => `${lang} ${count}`).join(", ")}`) + } + if (unknownStrings.size > 0) console.warn(`[i18n] Missing in en.json: ${Array.from(unknownStrings).join(", ")}`) }) // Keeps the same directory structure. @@ -453,6 +524,13 @@ module.exports = function (ty) { return newDocs }) + ty.addCollection("markdownDocs", (collection) => + collection.getFilteredByGlob("src/docs/**/*.md") + .filter((doc) => markdownPages.markdownKind(doc.inputPath, supportedLangs) === "source") + .sort((a, b) => a.url.localeCompare(b.url))) + + ty.setQuietMode(true) + ty.setWatchThrottleWaitTime(100) ty.addWatchTarget("src/css") ty.addWatchTarget("markdown/") ty.addWatchTarget("components/Card.js") @@ -509,7 +587,7 @@ module.exports = function (ty) { dir: { input: 'src', includes: '_includes', - output: '_site', + output: outputDir, }, templateFormats: ['md', 'njk', 'html'], markdownTemplateEngine: 'njk', diff --git a/website/copy_sources.js b/website/copy_sources.js new file mode 100644 index 0000000000..124179ac3c --- /dev/null +++ b/website/copy_sources.js @@ -0,0 +1,81 @@ +const fs = require("fs") +const path = require("path") +const customizeDocs = require("./customize_docs_frontmatter") + +const repoDir = path.resolve(__dirname, "..") + +const docs = { + from: "docs", + to: "src/docs", + exclude: [/^contributing\//, /^rfcs\//, /^dependencies\//, /^LINKS\.md$/, /^lang\/[^/]+\/README\.md$/], +} + +const sources = [ + docs, + { from: "docs/links/images", to: "src/link-images", exclude: [] }, + { from: "blog", to: "src/blog", exclude: [/^README\.md$/, /^new\//] }, + { from: "images", to: "src/images", exclude: [] }, + { from: "PRIVACY.md", to: "src/privacy.md", exclude: [] }, +] + +function sourcePath(source, file = "") { + return path.join(repoDir, source.from, file) +} + +function targetPath(source, file = "") { + return path.join(__dirname, source.to, file) +} + +function relativeFiles(root, dir = "") { + if (!fs.statSync(path.join(root, dir)).isDirectory()) return [dir] + return fs.readdirSync(path.join(root, dir)).flatMap((name) => relativeFiles(root, path.join(dir, name))) +} + +function isExcluded(source, file) { + return source.exclude.some((pattern) => pattern.test(file)) +} + +function isDocsMarkdown(source, file) { + return source === docs && file.endsWith(".md") +} + +function sourceFiles(source) { + return relativeFiles(sourcePath(source)).filter((file) => !isExcluded(source, file)) +} + +function copyFile(source, file) { + fs.mkdirSync(path.dirname(targetPath(source, file)), { recursive: true }) + fs.copyFileSync(sourcePath(source, file), targetPath(source, file)) +} + +function writeDocsMarkdown() { + const markdownFiles = sourceFiles(docs).filter((file) => isDocsMarkdown(docs, file)) + customizeDocs(sourcePath(docs), targetPath(docs), markdownFiles) +} + +function copySources() { + sources.filter((source) => fs.existsSync(sourcePath(source))).forEach((source) => { + fs.rmSync(targetPath(source), { recursive: true, force: true }) + sourceFiles(source).filter((file) => !isDocsMarkdown(source, file)).forEach((file) => copyFile(source, file)) + }) + writeDocsMarkdown() +} + +function syncFile(source, event, file) { + if (event === "unlink" || event === "unlinkDir") fs.rmSync(targetPath(source, file), { recursive: true, force: true }) + if (isDocsMarkdown(source, file) || (source === docs && event === "unlinkDir")) writeDocsMarkdown() + else if (event === "add" || event === "change") copyFile(source, file) +} + +function watchSources() { + const chokidar = require("chokidar") + sources.forEach((source) => { + chokidar.watch(sourcePath(source), { ignoreInitial: true }).on("all", (event, changedPath) => { + const file = path.relative(sourcePath(source), changedPath) + if (!isExcluded(source, file)) syncFile(source, event, file) + }) + }) +} + +if (process.argv.includes("--watch")) watchSources() +else copySources() diff --git a/website/customize_docs_frontmatter.js b/website/customize_docs_frontmatter.js index 10031b5436..e2056c380e 100644 --- a/website/customize_docs_frontmatter.js +++ b/website/customize_docs_frontmatter.js @@ -2,93 +2,62 @@ const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); -const directoryPath = path.resolve(__dirname, 'src/docs'); -const langFolder = 'lang'; -const enFiles = {}; - -function traverseDirectory(directory, currentLanguage = 'en', result = {}, callback) { - const filesAndDirectories = fs.readdirSync(directory); - - filesAndDirectories.forEach((fileOrDirectoryName) => { - const fullPath = path.join(directory, fileOrDirectoryName); - - if (fs.statSync(fullPath).isDirectory()) { - // If the subdirectory is inside the 'lang' folder, update the current language - if (directory.endsWith('/lang')) { - currentLanguage = fileOrDirectoryName; - } - - // Recursively traverse the subdirectories - traverseDirectory(fullPath, currentLanguage, result, callback); - } else { - // Process the file only if it has the '.md' extension - if (path.extname(fullPath) === '.md') { - // Add the language to the file's language array or create a new array if it doesn't exist - const fileName = path.basename(fullPath, '.md'); - if (!result[fileName]) { - result[fileName] = []; - } - result[fileName].push(currentLanguage); - } - if (callback) { - callback(fullPath, currentLanguage); - } - } - }); - - return result; +function docLanguage(relativePath) { + const [folder, language] = relativePath.split(path.sep); + return folder === 'lang' ? language : 'en'; } -const fileLanguageMapping = traverseDirectory(directoryPath); +function customizeDocs(sourceDir, destDir, relativePaths) { + const docs = relativePaths.map((relativePath) => { + const parsedMatter = matter(fs.readFileSync(path.join(sourceDir, relativePath), 'utf-8')); + return { + relativePath, + fileName: path.basename(relativePath, '.md'), + language: docLanguage(relativePath), + content: parsedMatter.content, + data: { ...parsedMatter.data }, + }; + }); -// Update the frontmatter of each Markdown file -Object.entries(fileLanguageMapping).forEach(([fileName, languages]) => { - // Find and update the frontmatter of each Markdown file - traverseDirectory(directoryPath, null, {}, (fullPath, currentLanguage) => { - if (path.basename(fullPath) === `${fileName}.md`) { - // Read the existing frontmatter - const fileContent = fs.readFileSync(fullPath, 'utf-8'); - const parsedMatter = matter(fileContent); - const relativePath = path.relative(directoryPath, fullPath); + const languagesByFileName = new Map(); + docs.forEach(({ fileName, language }) => { + if (!languagesByFileName.has(fileName)) languagesByFileName.set(fileName, []); + languagesByFileName.get(fileName).push(language); + }); - // Calculate the permalink based on the file's location - const linkPath = path.relative(directoryPath, fullPath).replace(/\.md$/, '.html'); - const permalink = `/docs/${linkPath}`.toLowerCase(); + const enRevisions = new Map(docs + .filter((doc) => doc.language === 'en') + .map((doc) => [doc.relativePath, doc.data.revision])); - if (fileName === 'JOIN_TEAM') { - parsedMatter.data.active_jobs = true; + docs.forEach(({ relativePath, fileName, language, content, data }) => { + const permalink = `/docs/${relativePath.replace(/\.md$/, '.html')}`.toLowerCase(); + + if (fileName === 'JOIN_TEAM') { + data.active_jobs = true; + } + if (!data.permalink) data.permalink = permalink; + + data.supportedLangsForDoc = languagesByFileName.get(fileName); + + if (!data.layout) data.layout = 'layouts/doc.html'; + + if (language === 'en') { + data.version = 'new'; + } else { + const enRelativePath = path.join(...relativePath.split(path.sep).slice(2)); + if (enRevisions.has(enRelativePath)) { + const isOld = new Date(data.revision) < new Date(enRevisions.get(enRelativePath)); + data.version = isOld ? 'old' : 'new'; } - if (!parsedMatter.data.permalink) parsedMatter.data.permalink = permalink; + } - // Update the frontmatter with the new languages list - parsedMatter.data.supportedLangsForDoc = languages; - - // Add the layout value - if (!parsedMatter.data.layout) parsedMatter.data.layout = 'layouts/doc.html'; - - if (fullPath.startsWith(path.join(directoryPath, langFolder))) { - // Non-English files - const [language, ...rest] = relativePath.split(path.sep).slice(1); - const enFilePath = path.join(directoryPath, ...rest); - - if (enFiles[enFilePath]) { - const enRevision = new Date(enFiles[enFilePath].revision); - const currentRevision = new Date(parsedMatter.data.revision); - - const isOld = currentRevision < enRevision; - // Add the version value - parsedMatter.data.version = isOld ? 'old' : 'new'; - } - } else { - // English files - enFiles[fullPath] = { revision: parsedMatter.data.revision }; - // Add the version value - parsedMatter.data.version = 'new'; - } - - // Save the updated frontmatter and content back to the file - const updatedFileContent = matter.stringify(parsedMatter.content, parsedMatter.data); - fs.writeFileSync(fullPath, updatedFileContent, 'utf-8'); + const destPath = path.join(destDir, relativePath); + const updatedFileContent = matter.stringify(content, data); + if (!fs.existsSync(destPath) || fs.readFileSync(destPath, 'utf-8') !== updatedFileContent) { + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.writeFileSync(destPath, updatedFileContent, 'utf-8'); } }); -}); +} + +module.exports = customizeDocs; diff --git a/website/langs/cs.json b/website/langs/cs.json index ef06c53676..90bad228aa 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -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í.", diff --git a/website/langs/en.json b/website/langs/en.json index 8e4fba8470..24dde29b39 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -331,6 +331,7 @@ "why-p7": "The oldest human freedom — to speak to another person without being watched — built on infrastructure that cannot betray it.", "why-p8": "Because we destroyed the power to know who you are. So that your power can never be taken.", "why-tagline": "Be free in your network.", + "why-film": "Watch the film", "why-footer-link": "Why we are building it", "send-file": "Send file", "file-desc": "Send files securely with end-to-end encryption — no accounts, no tracking.", diff --git a/website/markdown_pages.js b/website/markdown_pages.js new file mode 100644 index 0000000000..2671de3690 --- /dev/null +++ b/website/markdown_pages.js @@ -0,0 +1,272 @@ +const fs = require("fs") +const path = require("path") +const matter = require("gray-matter") +const parse5 = require("parse5") + +const siteLocation = "https://simplex.chat" + +const htmlPages = new Set([ + "index", + "why", + "messaging", + "fdroid", + "contact", + "invitation", + "file", + "crowdfunding", + "crowdfunding-news", + "news", + "livestream", + "blog", + "links", + "directory", +]) + +const removedTags = new Set([ + "script", + "style", + "noscript", + "svg", + "button", + "select", + "input", + "textarea", +]) + +const removedIds = new Set([ + "navbar", + "mobile-header", +]) + +const removedClasses = new Set([ + "footer", + "glossary-tooltip", + "glossary-overlay", +]) + +const blockTags = new Set([ + "address", + "article", + "aside", + "dd", + "details", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "form", + "header", + "main", + "nav", + "section", + "summary", +]) + +const codeFencePattern = /(^```[\s\S]*?^```[^\n]*$)/gm +const linkTargetPattern = /\]\((<[^>]*>|[^)\s]+)/g +const referenceTargetPattern = /^( {0,3}\[[^\]]+\]:[ \t]*)(<[^>]*>|\S+)/gm +const textEntityPattern = /&(?!lt;|gt;|amp;)(#\d+|#x[0-9a-f]+|[a-z]+\d*);/gi +const headingPattern = /^#\s+(.+)$/m +const invisibleCharacters = /[​⁠]/g + +function markdownKind(inputPath, langs) { + const relativePath = path.relative("src", inputPath) + if (/^docs\/(?!lang\/).+\.md$/.test(relativePath)) return "source" + if (/^blog\/[^/]+\.md$/.test(relativePath)) return "source" + if (relativePath === "privacy.md" || relativePath === "token.md") return "source" + const parts = relativePath.split("/") + const fileName = parts.length === 1 ? parts[0] : parts.length === 2 && langs.includes(parts[0]) ? parts[1] : "" + return fileName.endsWith(".html") && htmlPages.has(fileName.slice(0, -5)) ? "html" : null +} + +function markdownUrl(url, langs) { + if (url.endsWith(".html")) return url.slice(0, -5) + ".md" + const isRoot = url === "/" || langs.some((lang) => url === `/${lang}/`) + return isRoot ? url + "index.md" : url.slice(0, -1) + ".md" +} + +function isMarkdownPagePath(pathname) { + const isDoc = pathname.startsWith("/docs/") && !pathname.startsWith("/docs/lang/") + return pathname.endsWith(".html") && (isDoc || pathname.startsWith("/blog/")) +} + +function absoluteMarkdownLink(link, inputPath, resolveLink) { + if (link.startsWith("<")) return `<${absoluteMarkdownLink(link.slice(1, -1), inputPath, resolveLink)}>` + if (link.startsWith("#")) return link + let resolved + try { + resolved = resolveLink(link, { page: { inputPath } }) + } catch { + return link + } + if (!resolved.startsWith("/")) return resolved + const [pathname, fragment] = resolved.split("#") + const target = isMarkdownPagePath(pathname) ? pathname.slice(0, -5) + ".md" : pathname + return siteLocation + target + (fragment === undefined ? "" : "#" + fragment) +} + +function markdownFromSource(inputPath, resolveLink) { + const { content } = matter(fs.readFileSync(inputPath, "utf8")) + const rewrite = (link) => absoluteMarkdownLink(link, inputPath, resolveLink) + const markdown = content + .split(codeFencePattern) + .map((part, index) => index % 2 === 1 ? part : part + .replace(linkTargetPattern, (_, link) => "](" + rewrite(link)) + .replace(referenceTargetPattern, (_, prefix, link) => prefix + rewrite(link)) + .replace(textEntityPattern, plainText)) + .join("") + return markdown.trim() + "\n" +} + +function markdownTitle(inputPath) { + const { data, content } = matter(fs.readFileSync(inputPath, "utf8")) + return plainText(data.title || content.match(headingPattern)?.[1] || "") +} + +function attribute(node, name) { + return node.attrs.find((attr) => attr.name === name)?.value +} + +function isRemoved(element) { + const classes = (attribute(element, "class") || "").split(/\s+/) + return removedTags.has(element.tagName) + || (element.tagName === "template" && attribute(element, "data-markdown") === undefined) + || attribute(element, "data-markdown-skip") !== undefined + || removedIds.has(attribute(element, "id")) + || classes.some((name) => removedClasses.has(name)) +} + +function childElements(node) { + return node.childNodes.filter((child) => child.tagName && !isRemoved(child)) +} + +function findElements(node, predicate) { + return childElements(node).flatMap((child) => predicate(child) ? [child] : findElements(child, predicate)) +} + +function findElement(node, predicate) { + return findElements(node, predicate)[0] +} + +function textContent(node) { + return node.nodeName === "#text" ? node.value : (node.childNodes || []).map(textContent).join("") +} + +function singleLine(text) { + return text.trim().replace(/\s*\n\s*/g, " ") +} + +function emphasis(marker, content) { + return content.replace(/^(\s*)([\s\S]*?)(\s*)$/, (_, leading, text, trailing) => + text ? leading + marker + text + marker + trailing : leading + trailing) +} + +function linkMarkdown(element, content, pageUrl) { + const href = attribute(element, "href") + if (href && href.startsWith("javascript:")) return "" + const image = findElement(element, (child) => child.tagName === "img" && attribute(child, "alt") !== undefined) + const label = attribute(element, "aria-label") || singleLine(content) || attribute(element, "title") || (image && attribute(image, "alt")) || "" + if (!href) return label + return label ? `[${label}](${new URL(href, siteLocation + pageUrl).href})` : "" +} + +function listMarkdown(element, ordered, pageUrl) { + const items = childElements(element).filter((child) => child.tagName === "li") + const lines = items.map((item, index) => (ordered ? `${index + 1}. ` : "- ") + singleLine(childrenMarkdown(item, pageUrl))) + return `\n\n${lines.join("\n")}\n\n` +} + +function tableMarkdown(element, pageUrl) { + const rows = findElements(element, (child) => child.tagName === "tr").map((row) => + childElements(row).map((cell) => singleLine(childrenMarkdown(cell, pageUrl)).replace(/\|/g, "\\|"))) + if (rows.length === 0) return "" + const width = Math.max(...rows.map((row) => row.length)) + const line = (cells) => "| " + Array.from({ length: width }, (_, index) => cells[index] || "").join(" | ") + " |" + const [header, ...body] = rows + return `\n\n${[line(header), line(Array(width).fill("---")), ...body.map(line)].join("\n")}\n\n` +} + +function nodeMarkdown(node, pageUrl) { + if (node.nodeName === "#text") return node.value.replace(invisibleCharacters, "").replace(/\s+/g, " ") + if (!node.tagName || isRemoved(node)) return "" + const tag = node.tagName + const content = () => childrenMarkdown(node, pageUrl) + switch (tag) { + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + return `\n\n${"#".repeat(Number(tag[1]))} ${singleLine(content())}\n\n` + case "p": + return `\n\n${content().trim()}\n\n` + case "br": + return "\n" + case "hr": + return "\n\n---\n\n" + case "a": + return linkMarkdown(node, content(), pageUrl) + case "strong": + case "b": + return emphasis("**", content()) + case "em": + case "i": + return emphasis("*", content()) + case "code": + return "`" + textContent(node) + "`" + case "pre": + return "\n\n```\n" + textContent(node).trim() + "\n```\n\n" + case "sup": + return `[${content().trim()}]` + case "img": + return "" + case "ul": + case "ol": + return listMarkdown(node, tag === "ol", pageUrl) + case "table": + return tableMarkdown(node, pageUrl) + case "blockquote": + return "\n\n" + content().trim().split("\n").map((line) => "> " + line).join("\n") + "\n\n" + case "template": + return childrenMarkdown(node.content, pageUrl) + default: + return blockTags.has(tag) ? `\n\n${content()}\n\n` : content() + } +} + +function childrenMarkdown(node, pageUrl) { + return node.childNodes.map((child) => nodeMarkdown(child, pageUrl)).join("") +} + +function normalizedMarkdown(markdown) { + let inFence = false + const lines = markdown.split("\n").map((line) => { + if (line.startsWith("```")) inFence = !inFence + return inFence || line.startsWith("```") ? line : line.trim() + }) + return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n" +} + +function markdownFromHtml(html, pageUrl) { + const document = parse5.parse(html) + const body = findElement(document, (node) => node.tagName === "body") + const title = findElement(document, (node) => node.tagName === "title") + const heading = findElement(body, (node) => node.tagName === "h1") ? "" : `# ${singleLine(title ? textContent(title) : "")}\n\n` + return normalizedMarkdown(heading + childrenMarkdown(body, pageUrl)) +} + +function plainText(html) { + return textContent(parse5.parseFragment(html)) +} + +module.exports = { + markdownKind, + markdownUrl, + markdownFromSource, + markdownTitle, + markdownFromHtml, + plainText, +} diff --git a/website/package.json b/website/package.json index 5e4e947bcf..7aa8a1399e 100644 --- a/website/package.json +++ b/website/package.json @@ -31,12 +31,12 @@ "dependencies": { "@plausible-analytics/tracker": "0.4.6", "@simplex-chat/xftp-web": "^0.3.0", - "eleventy-plugin-i18n": "^0.1.3", "fs": "^0.0.1-security", "gray-matter": "^4.0.3", "jsdom": "^22.1.0", "lottie-web": "5.12.2", "markdown-it": "^13.0.1", - "markdown-it-footnote": "^4.0.0" + "markdown-it-footnote": "^4.0.0", + "parse5": "^7.1.2" } } diff --git a/website/plans/2026-10-08-llms.md b/website/plans/2026-10-08-llms.md new file mode 100644 index 0000000000..8d604a79e0 --- /dev/null +++ b/website/plans/2026-10-08-llms.md @@ -0,0 +1,99 @@ +# LLM content for simplex.chat + +## Outputs + +### Index files + +| URL | Content | +|---|---| +| `/llms.txt` | site name, summary, sections of links to Markdown pages | +| `/llms-full.txt` | core pages in one file | +| `/docs.md` | every English documentation page | + +### Markdown versions of site pages + +URL rule: + +| Page URL | Markdown URL | +|---|---| +| `/` | `/index.md` | +| `/xx/` (language root) | `/xx/index.md` | +| `/x/` | `/x.md` | +| `/docs/x.html` | `/docs/x.md` | +| `/blog/x.html` | `/blog/x.md` | + +Sources: + +| Pages | Markdown made from | +|---|---| +| English `docs/**/*.md`, `blog/*.md`, `privacy.md`, `token.md` | source text: front matter removed, links made absolute, HTML entities decoded | +| `index`, `why`, `messaging`, `fdroid`, `contact`, `invitation`, `file` (every language) | rendered HTML | +| `crowdfunding`, `crowdfunding-news`, `news`, `livestream`, `blog`, `links`, `directory` | rendered HTML | + +Translated docs and blog posts: no Markdown version. + +HTML to Markdown markup: + +| Markup | Effect | +|---|---| +| `data-markdown-skip` | element left out | +| `