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/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 0a401f9bf3..8cc6d816b5 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -123,6 +123,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { private func prepareForLaunch() { try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) + excludeAppDataFromBackup() } static func keepScreenOn(_ on: Bool) { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index dc7cfb059e..bcb592ee1a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2391,6 +2391,10 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws m.chatRunning = true chatLastStartGroupDefault.set(Date.now) Task { await BadgeStore.shared.presentUnfinished() } + if shouldDeleteDatabaseBackupsDefault.get() { + deleteDatabaseBackups() + shouldDeleteDatabaseBackupsDefault.set(false) + } } func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? { @@ -2952,23 +2956,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 @@ -3108,6 +3108,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/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 2ce04e4b80..c7d53d3c69 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -117,7 +117,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg encryptor.delegate = self frameEncryptor = encryptor - let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes)) + let decryptor = RTCFrameDecryptor.init(sizeChange: 0) decryptor.delegate = self frameDecryptor = decryptor } @@ -508,11 +508,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? { guard encrypted.count > 0 else { return nil } + let isKeyFrame = encrypted[0] & 1 == 0 + let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 + guard encrypted.count >= clearTextBytesSize + WebRTCClient.ivTagBytes else { return nil } if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8), let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) { memcpy(pointer, (encrypted as NSData).bytes, encrypted.count) - let isKeyFrame = encrypted[0] & 1 == 0 - let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 logCrypto("decrypt", chat_decrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(encrypted.count - clearTextBytesSize))) return Data(bytes: pointer, count: encrypted.count - WebRTCClient.ivTagBytes) } else { @@ -522,11 +523,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? { guard unencrypted.count > 0 else { return nil } + let isKeyFrame = unencrypted[0] & 1 == 0 + let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 + guard unencrypted.count >= clearTextBytesSize else { return nil } if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8), let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) { memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count) - let isKeyFrame = unencrypted[0] & 1 == 0 - let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize))) return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes) } else { diff --git a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift index c9924223b5..0ee07ea828 100644 --- a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift +++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift @@ -22,11 +22,11 @@ struct GetStakeBanner: View { Button(action: onTap) { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 4) { - Text("Get a stake in SimpleX Chat!") + Text("Invest in SimpleX Chat!") .font(.headline) .foregroundColor(theme.colors.primary) .lineLimit(2) - Text("Invest on Wefunder from $100") + Text("Crowdfunding on Wefunder") .font(.subheadline) .foregroundColor(theme.colors.onBackground) .lineLimit(2) 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/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/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/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/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 6cafb78043..79023a5821 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -890,7 +890,7 @@ struct GetStakeView: View { var body: some View { ZoomablePageView { VStack(alignment: .leading, spacing: 18) { - Text(verbatim: "Get a stake in\nSimpleX Chat") + Text(verbatim: "Invest in\nSimpleX Chat") .font(.largeTitle) .bold() .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index c1d1e5b111..6cf9b5abe6 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -13,6 +13,8 @@ import SimpleXChat let simplexTeamURL = URL(string: "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im")! +let simplexNewsURL = URL(string: "simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im")! + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String @@ -47,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 @@ -205,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) @@ -462,6 +467,15 @@ struct SettingsView: View { } Section(header: Text("Contact").foregroundColor(theme.colors.secondary)) { + settingsRow("antenna.radiowaves.left.and.right", color: theme.colors.secondary) { + Button("Follow SimpleX Network News") { + dismiss() + DispatchQueue.main.async { + ChatModel.shared.appOpenUrl = simplexNewsURL + } + } + } + .disabled(chatModel.chatRunning != true) settingsRow("number", color: theme.colors.secondary) { Button("Send questions and ideas") { dismiss() 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/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 05a28d21ce..37b24162e4 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -195,8 +195,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.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.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 */; }; @@ -590,8 +590,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.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.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 = ""; }; @@ -764,8 +764,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.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; @@ -852,8 +852,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.7-KLlSPSHmTF28mDmnoCMp3f.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 = ""; @@ -2147,7 +2147,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2197,7 +2197,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2239,7 +2239,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2259,7 +2259,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2284,7 +2284,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2321,7 +2321,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2358,7 +2358,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2409,7 +2409,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2463,7 +2463,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2497,7 +2497,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; 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/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9e6ff84b96..df72fdcafa 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3904,6 +3904,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .memberCreatedContact: return false case .memberProfileUpdated: return false case .newMemberPendingReview: return true + case .msgBadSignature: return false } case .sndGroupEvent: return false case .rcvConnEvent: return false @@ -6075,6 +6076,7 @@ public enum RcvGroupEvent: Decodable, Hashable { case memberCreatedContact case memberProfileUpdated(fromProfile: Profile, toProfile: Profile) case newMemberPendingReview + case msgBadSignature var text: String { text(isChannel: false) } @@ -6110,6 +6112,7 @@ public enum RcvGroupEvent: Decodable, Hashable { case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item") case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) case .newMemberPendingReview: return NSLocalizedString("New member wants to join the group.", comment: "rcv group event chat item") + case .msgBadSignature: return NSLocalizedString("message rejected: bad signature", comment: "rcv group event chat item") } } diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index a4d4e4ae0c..1641541ef7 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -50,6 +50,10 @@ private let CHAT_DB_BAK: String = "_chat.db.bak" private let AGENT_DB_BAK: String = "_agent.db.bak" +private let CHAT_DB_EXPORTED: String = "_chat.db.exported" + +private let AGENT_DB_EXPORTED: String = "_agent.db.exported" + // Spec: spec/database.md#getDocumentsDirectory public func getDocumentsDirectory() -> URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! @@ -66,6 +70,18 @@ func getAppDirectory() -> URL { : getDocumentsDirectory() } +public func excludeAppDataFromBackup() { + var values = URLResourceValues() + values.isExcludedFromBackup = true + for var dir in [getGroupContainerDirectory(), getDocumentsDirectory()] { + do { + try dir.setResourceValues(values) + } catch { + logger.error("FileUtils.excludeAppDataFromBackup error: \(error.localizedDescription)") + } + } +} + // Spec: spec/database.md#DB_FILE_PREFIX let DB_FILE_PREFIX = "simplex_v1" @@ -101,6 +117,8 @@ public func deleteAppDatabaseAndFiles() { } try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK) try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK) + try? fm.removeItem(atPath: dbPath + CHAT_DB_EXPORTED) + try? fm.removeItem(atPath: dbPath + AGENT_DB_EXPORTED) try? fm.removeItem(at: getTempFilesDirectory()) try? fm.removeItem(at: getMigrationTempFilesDirectory()) try? fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true) @@ -176,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()) } @@ -198,6 +223,8 @@ public func removeLegacyDatabaseAndFiles() -> Bool { let r2 = nil != (try? fm.removeItem(atPath: dbPath.path + CHAT_DB)) try? fm.removeItem(atPath: dbPath.path + AGENT_DB_BAK) try? fm.removeItem(atPath: dbPath.path + CHAT_DB_BAK) + try? fm.removeItem(atPath: dbPath.path + AGENT_DB_EXPORTED) + try? fm.removeItem(atPath: dbPath.path + CHAT_DB_EXPORTED) try? fm.removeItem(at: appFiles) return r1 && r2 } 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 9e5adfcb64..14fee1cb1c 100644 --- a/apps/ios/spec/database.md +++ b/apps/ios/spec/database.md @@ -74,6 +74,10 @@ See [`getDocumentsDirectory()`](../SimpleXChat/FileUtils.swift#L47) and [`getGro The container choice is stored in `dbContainerGroupDefault` (`GroupDefaults`). +### Backup Exclusion + +`isExcludedFromBackup` is set on both containers by [`excludeAppDataFromBackup()`](../SimpleXChat/FileUtils.swift#L69-L79), which is called from [`prepareForLaunch()`](../Shared/AppDelegate.swift#L124-L127) on each app launch. Files that the app, NSE and SE create or replace in these directories later are excluded as well. + --- ## 3. Haskell Store Modules @@ -130,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 --- 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 c2c789f998..1cce1126a9 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"> @@ -109,12 +113,18 @@ - - - - - - + + + + + + + + + + + + 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 5d8371708c..5ce69aeec2 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 @@ -178,7 +178,6 @@ object NtfManager { val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) NotificationCompat.Builder(context, CallChannel) .setFullScreenIntent(fullScreenPendingIntent, true) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) } else { val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/ring_once") val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) 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 995b584fce..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) @@ -376,8 +380,12 @@ fun IncomingCallLockScreenAlertLayout( IncomingCallInfo(invitation, chatModel) Spacer(Modifier.fillMaxHeight().weight(1f)) if (callOnLockScreen == CallOnLockScreen.ACCEPT) { - ProfileImage(size = 192.dp, image = invitation.contact.profile.image) - Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2) + if (chatModel.controller.appPrefs.notificationPreviewMode.get() == NotificationPreviewMode.HIDDEN.name) { + ProfileImage(size = 192.dp) + } else { + ProfileImage(size = 192.dp, image = invitation.contact.profile.image) + Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2) + } Spacer(Modifier.fillMaxHeight().weight(1f)) Row { LockScreenCallButton(stringResource(MR.strings.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 413667c968..ca19cdea3a 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -143,6 +143,7 @@ kotlin { } // For jSystemThemeDetector only implementation("net.java.dev.jna:jna-platform:5.14.0") + implementation("com.github.oshi:oshi-core:6.4.13") implementation("com.sshtools:two-slices:0.9.1") implementation("org.slf4j:slf4j-simple:2.0.12") implementation("uk.co.caprica:vlcj:4.8.3") @@ -157,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/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt new file mode 100644 index 0000000000..bae80e11d0 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt @@ -0,0 +1,6 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.graphics.drawscope.DrawScope + +// Android blurs the bars with a RenderEffect on the layer itself, so this is never called there. +actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) = drawBar() 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/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 011619bab0..9ad787a6b1 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 @@ -132,7 +132,7 @@ actual fun ActiveCallView() { } Box(Modifier.fillMaxSize()) { WebRTCView(chatModel.callCommand) { apiMsg -> - Log.d(TAG, "received from WebRTCView: $apiMsg") + Log.d(TAG, "received from WebRTCView: ${apiMsg.resp.javaClass.simpleName}") val call = chatModel.activeCall.value if (call != null) { val callState = call.androidCallState as ActiveCallState @@ -711,7 +711,7 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM .collect { while (callCommand.isNotEmpty()) { val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") + Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") if (cmd != null) { processCommand(wv, cmd) } @@ -785,7 +785,7 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) { // onResponse(message) onResponse(json.decodeFromString(message)) } catch (e: Exception) { - Log.e(TAG, "failed parsing WebView message: $message") + Log.e(TAG, "failed parsing WebView message") } } } 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/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 32a5ce1ef1..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,6 +269,11 @@ object AppLock { fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000 fun recheckAuthState() { + 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/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 6a345bb612..28856f721a 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 @@ -3561,6 +3561,7 @@ data class ChatItem ( is RcvGroupEvent.MemberCreatedContact -> false is RcvGroupEvent.MemberProfileUpdated -> false is RcvGroupEvent.NewMemberPendingReview -> true + is RcvGroupEvent.MsgBadSignature -> false } is CIContent.SndGroupEventContent -> false is CIContent.RcvConnEventContent -> false @@ -5467,6 +5468,7 @@ sealed class RcvGroupEvent() { @Serializable @SerialName("memberCreatedContact") class MemberCreatedContact(): RcvGroupEvent() @Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent() @Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent() + @Serializable @SerialName("msgBadSignature") class MsgBadSignature(): RcvGroupEvent() val text: String get() = text(isChannel = false) @@ -5491,6 +5493,7 @@ sealed class RcvGroupEvent() { is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact) is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile) is NewMemberPendingReview -> generalGetString(MR.strings.rcv_group_event_new_member_pending_review) + is MsgBadSignature -> generalGetString(MR.strings.rcv_group_event_msg_bad_signature) } private fun profileUpdatedText(from: Profile, to: Profile): String = 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 cf453d7f88..6cb862c247 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 @@ -34,6 +34,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.* @@ -217,6 +218,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) @@ -489,6 +491,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" @@ -717,6 +720,10 @@ object ChatController { apiStartChat() appPrefs.chatStopped.set(false) withLongRunningApi { BadgeStore.presentUnfinished() } + if (appPrefs.shouldDeleteDatabaseBackups.get()) { + deleteDatabaseBackups() + appPrefs.shouldDeleteDatabaseBackups.set(false) + } } catch (e: Throwable) { Log.e(TAG, "failed starting chat $e") throw e @@ -973,7 +980,7 @@ object ChatController { val r = json.decodeFromString(rStr) if (log) { Log.d(TAG, "sendCmd response type ${r.responseType}") - if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) { + if (r is API.Result && ((r.res is CR.Response && !r.res.type.startsWith("call")) || r.res is CR.Invalid)) { Log.d(TAG, "sendCmd response json $rStr") } chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) @@ -990,7 +997,7 @@ object ChatController { } else { val r = json.decodeFromString(rStr) Log.d(TAG, "chatRecvMsg: ${r.responseType}") - if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr") + if (r is API.Result && ((r.res is CR.Response && !r.res.type.startsWith("call")) || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr") r } } @@ -3396,21 +3403,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, sharedKey = r.sharedKey) - val useRelay = appPrefs.webrtcPolicyRelay.get() - val iceServers = getIceServers() - Log.d(TAG, ".callOffer iceServers $iceServers") - chatModel.callCommand.add(WCallCommand.Offer( - offer = r.offer.rtcSession, - iceCandidates = r.offer.rtcIceCandidates, - media = r.callType.media, - aesKey = r.sharedKey, - iceServers = iceServers, - relay = useRelay - )) + chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, hasSharedKey = r.sharedKey != null) + if (r.askConfirmation) { + showUnencryptedCallAlert(call) { processCallOffer(r) } + } else { + processCallOffer(r) + } } } is CR.CallAnswer -> { @@ -3669,6 +3668,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) { @@ -7280,9 +7312,9 @@ sealed class CR { is SndStandaloneFileComplete -> withUser(user, rcvURIs.size.toString()) is SndFileError -> withUser(user, "errorMessage: ${json.encodeToString(errorMessage)}\nchatItem: ${json.encodeToString(chatItem_)}") is SndFileWarning -> withUser(user, "errorMessage: ${json.encodeToString(errorMessage)}\nchatItem: ${json.encodeToString(chatItem_)}") - is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations)}" - is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}" - is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}") + is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations.map { it.copy(sharedKey = null) })}" + is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: ${callInvitation.callType}" + is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}") is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}") is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}") is CallEnded -> withUser(user, "contact: ${contact.id}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt new file mode 100644 index 0000000000..a7bd229d7f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt @@ -0,0 +1,6 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.graphics.drawscope.DrawScope + +// Draws the bars' background blurred. The drawing to blur is given in bar coordinates, whatever size the blur runs at. +expect fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) 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/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index be7022ca80..caaf66811c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -1,18 +1,21 @@ package chat.simplex.common.platform import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.views.helpers.KeyChangeEffect import kotlinx.coroutines.* import kotlinx.coroutines.flow.filter import java.io.File +import kotlin.math.roundToInt @Composable expect fun Modifier.desktopOnExternalDrag( @@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier @Composable -fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState, showMenu: State,): Modifier { +fun Modifier.desktopModifyBlurredState(blurred: MutableState, showMenu: State,): Modifier { val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state } if (appPlatform.isDesktop) { KeyChangeEffect(blurRadius.value) { - blurred.value = enabled && blurRadius.value > 0 + blurred.value = blurRadius.value > 0 } } - return if (appPlatform.isDesktop && enabled && blurRadius.value > 0 && !showMenu.value) { + return if (appPlatform.isDesktop && blurRadius.value > 0 && !showMenu.value) { var job: Job = remember { Job() } LaunchedEffect(Unit) { // The approach here is to allow menu to show up and to not blur the view. When menu is shown and mouse is hovering, @@ -58,27 +61,37 @@ fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState): Boolean = + enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0 + @Composable fun Modifier.privacyBlur( - enabled: Boolean, + fullSize: Boolean, + preview: ImageBitmap, blurred: MutableState = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }, scrollState: State, onLongClick: () -> Unit = {} ): Modifier { val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state } - return if (enabled && blurred.value) { - this then Modifier.blur( - radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp, - radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp, - edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp)) - ) + return if (blurHidesMedia(true, blurred)) { + this then (if (fullSize) { + val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) } + Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) } + } else Modifier.drawWithCache { + val cropScale = maxOf(size.width / preview.width, size.height / preview.height) + val croppedSize = IntSize((preview.width * cropScale).roundToInt(), (preview.height * cropScale).roundToInt()) + val blurredCropped = preview.blurredBy(blurRadius.value, croppedSize.width.toDp().value) + val offset = IntOffset(((size.width - croppedSize.width) / 2).roundToInt(), ((size.height - croppedSize.height) / 2).roundToInt()) + onDrawWithContent { clipRect { drawImage(blurredCropped, dstOffset = offset, dstSize = croppedSize) } } + }) .combinedClickable( onLongClick = onLongClick, onClick = { blurred.value = false } ) - } else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) { + } else if (blurRadius.value > 0 && appPlatform.isAndroid) { LaunchedEffect(Unit) { snapshotFlow { scrollState.value } .filter { it } @@ -90,3 +103,27 @@ fun Modifier.privacyBlur( this } } + +// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger. +private const val BLURRED_MEDIA_WIDTH_DP = 400 +// The width in-chat media is assumed to be drawn at; small views are blurred as much on screen. +private const val CHAT_MEDIA_WIDTH_DP = 360 +// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall. +private const val RESAMPLE_MEDIA_FROM_SIDE = 512 + +private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap { + if (width <= 0 || height <= 0) return this + val w = (BLURRED_MEDIA_WIDTH_DP * drawnWidthDp / CHAT_MEDIA_WIDTH_DP / radius).toInt().coerceIn(1, width) + val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP) + val longest = maxOf(width, height) + var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) { + val step = RESAMPLE_MEDIA_FROM_SIDE.toFloat() / longest + scale((width * step).roundToInt().coerceAtLeast(1), (height * step).roundToInt().coerceAtLeast(1)) + } else this + // One bilinear step from a large image undersamples it. + while (image.width / 2 > w) image = image.scale(image.width / 2, (image.height / 2).coerceAtLeast(1)) + image = image.scale(w, h) + // Stretching w px straight to the screen shows their grid; each doubling smooths it, so the last stretch is small. + while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2) + return image +} 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/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index d6ab57a70d..89865689c6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.call +import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.withBGApi @@ -51,13 +52,12 @@ class CallManager(val chatModel: ChatModel) { callUUID = invitation.callUUID, callState = CallState.InvitationAccepted, initialCallType = invitation.callType.media, - sharedKey = invitation.sharedKey, + hasSharedKey = invitation.sharedKey != null, androidCallState = platform.androidCreateActiveCallState() ) showCallView.value = true val useRelay = controller.appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() - Log.d(TAG, "answerIncomingCall iceServers: $iceServers") callCommand.add(WCallCommand.Start( media = invitation.callType.media, aesKey = invitation.sharedKey, @@ -79,6 +79,7 @@ class CallManager(val chatModel: ChatModel) { // Don't destroy WebView if you plan to accept next call right after this one if (!switchingCall.value) { + AppLock.appWasHidden() showCallView.value = false activeCall.value?.androidCallState?.close() activeCall.value = null 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 6fa99283d8..6cd0ba95ba 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 @@ -21,7 +21,7 @@ data class Call( val localMediaSources: CallMediaSources = CallMediaSources(mic = true, camera = initialCallType == CallMediaType.Video), val localCapabilities: CallCapabilities? = null, val peerMediaSources: CallMediaSources = CallMediaSources(), - val sharedKey: String? = null, + val hasSharedKey: Boolean = false, var localCamera: VideoCamera = VideoCamera.User, val connectionInfo: ConnectionInfo? = null, var connectedAt: Instant? = null, @@ -32,14 +32,14 @@ data class Call( val androidCallState: Closeable ) { - val encrypted: Boolean get() = localEncrypted && sharedKey != null + val encrypted: Boolean get() = localEncrypted && hasSharedKey private val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false val encryptionStatus: String get() = when(callState) { CallState.WaitCapabilities -> "" CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) - CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) - else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) + CallState.InvitationAccepted -> generalGetString(if (!hasSharedKey) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) + else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (!hasSharedKey) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) } val hasVideo: Boolean 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 91b3270dea..b35e828962 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 @@ -1205,12 +1205,13 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) { } @Composable -fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) { +fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit, disabled: Boolean = false) { SettingsActionItem( if (contactVerified) painterResource(MR.images.ic_verified_user) else painterResource(MR.images.ic_shield), stringResource(if (contactVerified) MR.strings.view_security_code else MR.strings.verify_security_code), click = onClick, iconColor = MaterialTheme.colors.secondary, + disabled = disabled, ) } 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 7e43db2e98..fc57998f1b 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 @@ -499,6 +499,15 @@ fun ChatView( showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) groupMembersJob.cancel() + val connStats = mutableStateOf(null) + val connectionCode = mutableStateOf(null) + val connectionLoaded = mutableStateOf(false) + if (chatsCtx.secondaryContextFilter == null) { + ModalManager.end.closeModals() + } + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> + GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, close = close, closeAll = close) + } groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second @@ -513,15 +522,9 @@ fun ChatView( withContext(Dispatchers.Main) { chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember) } - - if (chatsCtx.secondaryContextFilter == null) { - ModalManager.end.closeModals() - } - ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> - remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close) - } - } + connStats.value = stats + connectionCode.value = code + connectionLoaded.value = true } }, loadMessages = { chatId, pagination, visibleItemIndexes -> @@ -2467,6 +2470,7 @@ fun BoxScope.ChatItemsList( LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } + .onCompletion { chatViewScrollState.value = false } .collect { chatViewScrollState.value = it } 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 49e7ac6db7..d14da9dc33 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 @@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView( } }, showMemberInfo = { member, groupRelay -> + val connStats = mutableStateOf(null) + val connectionCode = mutableStateOf(null) + val connectionLoaded = mutableStateOf(false) + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> + GroupMemberInfoView(rhId, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { + closeCurrent() + close() + } + } withBGApi { val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second @@ -132,14 +141,9 @@ fun ModalData.GroupChatInfoView( } else { member to null } - ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> - remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { - closeCurrent() - close() - } - } - } + connStats.value = stats + connectionCode.value = code + connectionLoaded.value = true } }, editGroupProfile = { 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 3e5f64659b..885d3de4e6 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 @@ -45,10 +45,11 @@ import kotlinx.coroutines.* fun GroupMemberInfoView( rhId: Long?, groupInfo: GroupInfo, - member: GroupMember, + groupMember: GroupMember, scrollToItemId: MutableState, - connectionStats: ConnectionStats?, - connectionCode: String?, + connStats: MutableState, + connectionCode: State, + connectionLoaded: State, chatModel: ChatModel, openedFromSupportChat: Boolean, groupRelay: GroupRelay? = null, @@ -60,7 +61,10 @@ fun GroupMemberInfoView( } BackHandler(onBack = close) val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } - val connStats = remember { mutableStateOf(connectionStats) } + // the passed member is shown until the loaded one is added to the model, so that the profile opens without waiting for the core + val member = remember(groupMember.groupMemberId) { + derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember } + }.value val developerTools = chatModel.controller.appPrefs.developerTools.get() var progressIndicator by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -91,7 +95,8 @@ fun GroupMemberInfoView( connStats, newRole, developerTools, - connectionCode, + connectionCode.value, + connectionLoaded.value, groupRelay = groupRelay, getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { contactId -> @@ -101,6 +106,7 @@ fun GroupMemberInfoView( } }, createMemberContact = { + val connectionStats = connStats.value if (member.sendMsgEnabled) { withBGApi { progressIndicator = true @@ -209,7 +215,7 @@ fun GroupMemberInfoView( remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> VerifyCodeView( mem.displayName, - connectionCode, + connectionCode.value, mem.verified, verify = { code -> chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> @@ -368,6 +374,7 @@ fun GroupMemberInfoLayout( newRole: MutableState, developerTools: Boolean, connectionCode: String?, + connectionLoaded: Boolean, groupRelay: GroupRelay? = null, getContactChat: (Long) -> Chat?, openDirectChat: (Long) -> Unit, @@ -538,22 +545,23 @@ fun GroupMemberInfoLayout( SectionDividerSpaced() } + val memberConnected = member.memberActive || (groupInfo.useRelays && member.memberCurrent) val showMemberSupportChat = !openedFromSupportChat && groupInfo.membership.memberRole >= GroupMemberRole.Moderator && member.memberRole != GroupMemberRole.Relay && ((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator) || member.supportChat != null) - val canVerifyCode = connectionCode != null && member.memberRole != GroupMemberRole.Relay + // the same condition decides whether the code is requested, so the row is shown disabled until it arrives + val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded) val canSyncConn = cStats != null && cStats.ratchetSyncAllowed - if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent)) - && (showMemberSupportChat || canVerifyCode || canSyncConn)) { + if (memberConnected && (showMemberSupportChat || canVerifyCode || canSyncConn)) { SectionView { if (showMemberSupportChat) { SupportChatButton() } if (canVerifyCode) { - VerifyCodeButton(member.verified, verifyClicked) + VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null) } if (canSyncConn) { SynchronizeConnectionButton(syncMemberConnection) @@ -655,6 +663,20 @@ fun GroupMemberInfoLayout( SimplexServers(stringResource(MR.strings.sending_via), sndServers) } } + } else if (!connectionLoaded && memberConnected) { + SectionDividerSpaced() + SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { + // the status is set from the same receiving queues as the servers below, so it is reserved with them + SectionItemView { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(MR.strings.network_status)) + Icon(painterResource(MR.images.ic_info), stringResource(MR.strings.network_status), tint = MaterialTheme.colors.secondary) + } + } + SwitchAddressButton(disabled = true, switchAddress = {}) + SimplexServers(stringResource(MR.strings.receiving_via), emptyList()) + SimplexServers(stringResource(MR.strings.sending_via), emptyList()) + } } val connFailedErr = member.activeConn?.connFailedErr @@ -1117,6 +1139,7 @@ fun PreviewGroupMemberInfoLayout() { newRole = remember { mutableStateOf(GroupMemberRole.Member) }, developerTools = false, connectionCode = "123", + connectionLoaded = true, getContactChat = { Chat.sampleData }, openDirectChat = {}, createMemberContact = {}, 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 1ac6d6f048..0da3c46abb 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 @@ -76,6 +76,15 @@ fun MemberSupportChatAppBar( navigationButton = { NavigationButtonBack(onBackClicked) }, title = { MemberSupportChatToolbarTitle(scopeMember_) }, onTitleClick = { + val connStats = mutableStateOf(null) + val connectionCode = mutableStateOf(null) + val connectionLoaded = mutableStateOf(false) + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> + GroupMemberInfoView(rhId, groupInfo, scopeMember_, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = true, close = closeCurrent) { + closeCurrent() + close() + } + } withBGApi { val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId) val stats = r?.second @@ -85,14 +94,9 @@ fun MemberSupportChatAppBar( } else { null } - ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> - remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) { - closeCurrent() - close() - } - } - } + connStats.value = stats + connectionCode.value = code + connectionLoaded.value = true } }, onTop = !oneHandUI.value || !chatBottomBar.value, 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 61b6e450c5..fc1c76074d 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 @@ -114,13 +114,13 @@ fun CIImageView( onClick = onClick ) .onRightClick { showMenu.value = true } - .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), + .privacyBlur(!smallView, imageBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } @Composable - fun ImageView(painter: Painter, image: String, fileSource: CryptoFile?, onClick: () -> Unit) { + fun ImageView(painter: Painter, image: String, onClick: () -> Unit) { // On my Android device Compose fails to display 6000x6000 px WebP image with exception: // IllegalStateException: Recording currently in progress - missing #endRecording() call? // but can display 5000px image. Using even lower value here just to feel safer. @@ -138,7 +138,7 @@ fun CIImageView( onClick = onClick ) .onRightClick { showMenu.value = true } - .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), + .privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } else { @@ -149,14 +149,10 @@ fun CIImageView( onClick = {} ) .onRightClick { showMenu.value = true } - .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), + .privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentAlignment = Alignment.Center ) { - imageView(previewBitmap, onClick = { - if (fileSource != null) { - openFile(fileSource) - } - }) + imageView(previewBitmap, onClick = onClick) Icon( painterResource(MR.images.ic_open_in_new), contentDescription = stringResource(MR.strings.image_descr), @@ -191,29 +187,33 @@ fun CIImageView( } } else Modifier ) - .desktopModifyBlurredState(!smallView, blurred, showMenu), + .desktopModifyBlurredState(blurred, showMenu), contentAlignment = Alignment.TopEnd ) { val res: MutableState?> = remember { mutableStateOf(null) } - if (chatModel.connectedToRemote()) { - LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { - withBGApi { + // Hidden media is not worth reading, decoding at full size and caching. + val revealed = !blurHidesMedia(!smallView, blurred) + if (revealed) { + if (chatModel.connectedToRemote()) { + LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { + withBGApi { + if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { + res.value = imageAndFilePath(file) + } + } + } + } else { + LaunchedEffect(file) { if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { - res.value = imageAndFilePath(file) + res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) } } } } - } else { - LaunchedEffect(file) { - if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { - res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) } - } - } } - val loaded = res.value + val loaded = if (revealed) res.value else null if (loaded != null && file != null) { val (imageBitmap, data, _) = loaded - SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) + SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, onClick) }) } else { imageView(previewBitmap, onClick = { if (file != null) { 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 556b386900..8b4756c3ad 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 @@ -54,7 +54,7 @@ fun CIVideoView( } } else Modifier ) - .desktopModifyBlurredState(!smallView, blurred, showMenu), + .desktopModifyBlurredState(blurred, showMenu), contentAlignment = Alignment.TopEnd ) { val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } @@ -84,11 +84,11 @@ fun CIVideoView( val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) } val decrypted = uriDecrypted.value if (decrypted != null && smallView) { - SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen) + SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, blurred, sizeMultiplier, openFullscreen = openFullscreen) } else if (decrypted != null) { VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen) } else if (smallView) { - SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen) + SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen) } else { VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen) } @@ -184,16 +184,17 @@ private fun SmallVideoViewEncrypted( defaultPreview: ImageBitmap, autoPlay: MutableState, showMenu: MutableState, + blurred: MutableState, sizeMultiplier: Float, openFullscreen: () -> Unit, ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } Box { - VideoPreviewImageView(defaultPreview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick) + VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) - } else if (!file.showStatusIconInSmallView) { + } else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) { PlayButton(false, sizeMultiplier, onLongClick = onLongClick) { decryptionInProgress = true withBGApi { @@ -216,6 +217,7 @@ private fun SmallVideoView( defaultPreview: ImageBitmap, defaultDuration: Long, autoPlay: MutableState, + blurred: MutableState, sizeMultiplier: Float, openFullscreen: () -> Unit ) { @@ -233,8 +235,8 @@ private fun SmallVideoView( onLongClick = {}, {} ) - VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {}) - if (!file.showStatusIconInSmallView) { + VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {}) + if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) { PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) } } @@ -421,7 +423,7 @@ fun VideoPreviewImageView( onClick = onClick ) .onRightClick(onLongClick) - .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick), + .privacyBlur(!smallView, preview, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } 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 68aec09456..e2e7f0bc69 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 @@ -153,6 +153,7 @@ fun FramedItemView( @Composable fun ciQuoteView(qi: CIQuote) { + val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) } val sentColor = MaterialTheme.appColors.sentQuote val receivedColor = MaterialTheme.appColors.receivedQuote Row( @@ -170,7 +171,7 @@ fun FramedItemView( imageBitmap, contentDescription = stringResource(MR.strings.image_descr), contentScale = ContentScale.Crop, - modifier = Modifier.size(68.dp).clipToBounds() + modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) ) } is MsgContent.MCVideo -> { @@ -182,7 +183,7 @@ fun FramedItemView( imageBitmap, contentDescription = stringResource(MR.strings.video_descr), contentScale = ContentScale.Crop, - modifier = Modifier.size(68.dp).clipToBounds() + modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) ) } is MsgContent.MCFile, is MsgContent.MCVoice -> { 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 5fe3b7d5f6..98fd476c90 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 @@ -783,7 +783,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { } else { withBGApi { chatModel.appOpenUrlConnecting.value = true - planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false }) + planAndConnect(rhId, uri, close = { ModalManager.closeAllModalsEverywhere() }, cleanup = { chatModel.appOpenUrlConnecting.value = false }) } } } 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 e6046d6c00..01179f07ee 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 @@ -311,26 +311,31 @@ fun ChatPreviewView( mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} }) } val uriHandler = LocalUriHandler.current + // Media in the chat list has no menu, so a menu opened from it must close at once, or the media stays revealed. + val noMenu = remember { mutableStateOf(false) } + LaunchedEffect(noMenu.value) { noMenu.value = false } when (mc) { is MsgContent.MCLink -> SmallContentPreview { + val image = remember(mc.preview.image) { base64ToBitmap(mc.preview.image) } + val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } IconButton( { openBrowserAlert(mc.preview.uri, uriHandler) }, Modifier.desktopPointerHoverIconHand(), ) { - Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop) + Image(image, null, Modifier.desktopModifyBlurredState(blurred, noMenu).privacyBlur(fullSize = false, image, blurred, chatViewScrollState.collectAsState()), contentScale = ContentScale.Crop) } Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) { Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White) } } is MsgContent.MCImage -> SmallContentPreview { - CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true) { val user = chatModel.currentUser.value ?: return@CIImageView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } } is MsgContent.MCVideo -> SmallContentPreview { - CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true) { val user = chatModel.currentUser.value ?: return@CIVideoView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } @@ -421,7 +426,7 @@ fun ChatPreviewView( val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null if (ci != null && showContentPreview) { - chatItemContentPreview(chat, ci) + key(ci.id) { chatItemContentPreview(chat, ci) } } if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) { Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) { 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..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, ) @@ -434,6 +438,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 { @@ -459,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)) } @@ -539,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/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 241826ac41..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 @@ -556,12 +556,16 @@ suspend fun deleteChatAsync(m: ChatModel) { fun deleteChatDatabaseFilesAndState() { val chat = File(dataDir, chatDatabaseFileName) val chatBak = File(dataDir, "$chatDatabaseFileName.bak") + val chatExported = File(dataDir, "$chatDatabaseFileName.exported") val agent = File(dataDir, agentDatabaseFileName) val agentBak = File(dataDir, "$agentDatabaseFileName.bak") + val agentExported = File(dataDir, "$agentDatabaseFileName.exported") chat.delete() chatBak.delete() + chatExported.delete() agent.delete() agentBak.delete() + agentExported.delete() filesDir.deleteRecursively() filesDir.mkdir() remoteHostsDir.deleteRecursively() @@ -594,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/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt index c7553b6ed0..6b39c80058 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.unit.* import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.drawBarsBlurred import chat.simplex.common.ui.theme.CurrentColors fun Modifier.blurredBackgroundModifier( @@ -32,7 +33,7 @@ fun Modifier.blurredBackgroundModifier( return if (appPlatform.isAndroid) { this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density) } else { - this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density) + this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, handler, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density) } } @@ -100,40 +101,44 @@ private fun Modifier.desktopBlurredModifier( blurRadius: State, keyboardCoversBar: Boolean, onTop: Boolean, + handler: AppBarHandler, graphicsLayer: GraphicsLayer, backgroundGraphicsLayer: GraphicsLayer, backgroundGraphicsLayerSize: State, density: Density ): Modifier = this - .graphicsLayer { - renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null - clip = blurRadius.value > 0 - } .drawBehind { - drawRect(CurrentColors.value.colors.background) - if (onTop) { - clipRect { - if (backgroundGraphicsLayer.size != IntSize.Zero) { - drawLayer(backgroundGraphicsLayer) - } else { - drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat())) + val barWidth = size.width + val barHeight = size.height + // The blur is taken from a copy of the scrolled content rather than drawn from that layer, so unlike a layer's own + // filter it does not follow the content on its own: the bar is redrawn when the container reports the copy moved. + handler.contentVersion.value + drawBarsBlurred(blurRadius.value.dp.toPx(), barWidth, barHeight) { + drawRect(CurrentColors.value.colors.background, size = Size(barWidth, barHeight)) + if (onTop) { + clipRect(0f, 0f, barWidth, barHeight) { + if (backgroundGraphicsLayer.size != IntSize.Zero) { + drawLayer(backgroundGraphicsLayer) + } else { + drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat())) + } + drawLayer(graphicsLayer) } - drawLayer(graphicsLayer) - } - } else { - val bgSize = when { - backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height - backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height - else -> backgroundGraphicsLayerSize.value.height - } - val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0 - translate(top = -bgSize + size.height + keyboardHeightCovered) { - if (backgroundGraphicsLayer.size != IntSize.Zero) { - drawLayer(backgroundGraphicsLayer) - } else { - drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat())) + } else { + val bgSize = when { + backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height + backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height + else -> backgroundGraphicsLayerSize.value.height + } + val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0 + translate(top = -bgSize + barHeight + keyboardHeightCovered) { + if (backgroundGraphicsLayer.size != IntSize.Zero) { + drawLayer(backgroundGraphicsLayer) + } else { + drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat())) + } + drawLayer(graphicsLayer) } - drawLayer(graphicsLayer) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt index ad6611b9d9..3e29649517 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt @@ -78,6 +78,10 @@ class AppBarHandler( val backgroundGraphicsLayerSize: MutableState = mutableStateOf(IntSize.Zero) + // Bars that blur a copy of the scrolled content depend on this to know the copy moved. It is bumped from a collector + // rather than while drawing: a write made during the draw phase invalidates the bars every frame and never settles. + val contentVersion: MutableState = mutableStateOf(0) + companion object { var appBarMaxHeightPx: Int = 0 } 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/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index d2a98ae101..8aea99eb4e 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 @@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel @Composable fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, onLongClick: () -> Unit) { - val image = base64ToBitmap(linkPreview.image) + val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) } Column( Modifier .layoutId(CHAT_IMAGE_LAYOUT_ID) @@ -206,8 +206,8 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, onLongC stringResource(MR.strings.image_descr_link_preview), modifier = Modifier .fillMaxWidth() - .desktopModifyBlurredState(true, blurred, showMenu) - .privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick), + .desktopModifyBlurredState(blurred, showMenu) + .privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick), contentScale = ContentScale.FillWidth, ) Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) { 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/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 0fa53ea6de..39a6514302 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 @@ -1033,7 +1033,7 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { return v != lastVersion } -private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat" +private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=app" private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im" @@ -1197,7 +1197,7 @@ fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, clo ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) { // in the center of the window the page is wide enough for the title to fit on one line - val title = "Get a stake in\nSimpleX Chat" + val title = "Invest in\nSimpleX Chat" AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false) // What's new already shows the image of the first slide, above the link that opens this page if (showFirstImage) { 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 c49caf7cfa..32f1b6384a 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 @@ -74,6 +74,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: ( val simplexTeamUri = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" +val simplexNewsUri = + "simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im" + @Composable fun SettingsLayout( stopped: Boolean, @@ -161,6 +164,7 @@ fun HelpAndSupportView( SectionView(stringResource(MR.strings.settings_section_title_contact)) { if (!chatModel.desktopNoUserNoRemote) { + SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) } SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary) 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 deaf291138..280096d38d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1016,6 +1016,7 @@ Markdown in messages Send questions and ideas Send us email + Follow SimpleX Network News SimpleX Lock Chat console Message servers @@ -1415,6 +1416,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 @@ -1642,6 +1645,9 @@ Save passphrase in Keystore Save passphrase in settings + Secure element + TEE + Software Database encrypted! Error encrypting database Remove passphrase from Keystore? @@ -1658,7 +1664,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.]]> @@ -1784,6 +1790,7 @@ invited via your group link requested connection New member wants to join the group. + message rejected: bad signature you changed role of %s to %s you changed role for yourself to %s you blocked %s @@ -2679,8 +2686,8 @@ Crowdfunding on Wefunder. Crowdfunding on Wefunder Learn more on Wefunder - Get a stake in SimpleX Chat! - Invest on Wefunder from $100 + Invest in SimpleX Chat! + Crowdfunding on Wefunder SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 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/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index ba8901793f..41e81a6f60 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 @@ -182,9 +182,10 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState) { } } var windowFocused by remember { simplexWindowState.windowFocused } - LaunchedEffect(windowFocused) { + val showCallView = ChatModel.showCallView.value + LaunchedEffect(windowFocused, showCallView) { val delay = ChatController.appPrefs.laLockDelay.get() - if (!windowFocused && ChatModel.showAuthScreen.value && delay > 0) { + if (!windowFocused && !showCallView && ChatModel.showAuthScreen.value && delay > 0) { delay(delay * 1000L) // Trigger auth state check when delay ends (and if it ends) AppLock.recheckAuthState() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt index 26748b8425..3fb9bd600c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt @@ -76,12 +76,11 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { // libvlc passes the size the decoder padded the picture to, not the size of the picture (dav1d // pads to a multiple of 128, so 1920x1080 arrives as 1920x1152), and vlc stretches the picture to - // fill whatever size is returned. Ask for the size of the track being played instead. The format - // is negotiated more than once, and vlc has not selected the track yet on the first calls - val player = mediaPlayer - val tracks = player?.media()?.info()?.videoTracks() - val playingTrack = player?.video()?.track() - val track = tracks?.firstOrNull { it.id() == playingTrack } ?: tracks?.singleOrNull() + // fill whatever size is returned. Ask for the size of the video track instead. Not the selected + // track: video().track() takes the input lock, which stop() holds while it waits for this thread + // to finish, so asking here deadlocks both, and every caller of the player after them + val tracks = mediaPlayer?.media()?.info()?.videoTracks() + val track = tracks?.singleOrNull() // Both track sides or neither: one side from the track and the other from the padded size libvlc // passed never described the same picture, and transposing such a pair compounds the mismatch val trackW = track?.width() ?: 0 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt new file mode 100644 index 0000000000..6c4ee704bb --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt @@ -0,0 +1,95 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.* +import kotlin.math.ceil + +// Skia's raster blur costs width x (bar + 6 sigma) pixels every frame, which is what makes the bars lag. The blur cannot be +// made cheaper by scaling the layer it runs on, because a layer's filter is evaluated in device space: any scale applied to +// the layer scales the sigma straight back up, which weakens the blur without saving anything. So the bar is drawn into a +// narrower offscreen surface and blurred there, at its own resolution, and only the finished blur is scaled back out. +// Only the width is reduced: a bar is just AppBarHeight tall, and taking rows away from it visibly weakens the blur. +private const val MAX_BLUR_NARROWING = 8 +private const val MIN_BLUR_WIDTH = 128 + +// How small the blur's own sigma is allowed to get. Below about this the narrowed copy is barely blurred, and stretching it +// back out shows the steps of the narrowing instead of a blur. +private const val MIN_NARROWED_SIGMA = 1.5f + +// The rows are halved as well, with the vertical sigma halved to match, which is only sound because the copy is drawn at +// that size rather than scaled afterwards. A bar has few rows to begin with, so it is not reduced past this. +private const val MIN_BLUR_HEIGHT = 24 + +// The panes of the desktop window have different widths, so bars of several sizes are drawn in the same frame and a single +// pair of surfaces would be reallocated for each of them. Keeping one pair per size, with the least recently drawn size +// released once there are more than the window can show at once, allocates each pair once instead. +private const val MAX_BAR_SURFACES = 4 + +private class BarBlurSurface(width: Int, height: Int) { + val surface: Surface = Surface.makeRaster(ImageInfo.makeN32Premul(width, height)) + + fun close() = surface.close() +} + +private val barBlurSurfaces = ThreadLocal.withInitial { LinkedHashMap() } + +private fun surfaceFor(width: Int, height: Int): BarBlurSurface { + val cached = barBlurSurfaces.get() + val key = (width.toLong() shl 32) or height.toLong() + val surface = cached.remove(key) ?: BarBlurSurface(width, height) + cached[key] = surface + while (cached.size > MAX_BAR_SURFACES) { + cached.remove(cached.keys.first())?.close() + } + return surface +} + +private fun narrowing(sigma: Float, barWidth: Float): Int { + var scale = 1 + while (sigma / (scale * 2) >= MIN_NARROWED_SIGMA && scale < MAX_BLUR_NARROWING && barWidth / (scale * 2) >= MIN_BLUR_WIDTH) scale *= 2 + return scale +} + +private fun shortening(sigma: Float, barHeight: Float): Int = + if (sigma / 2 >= MIN_NARROWED_SIGMA && barHeight / 2 >= MIN_BLUR_HEIGHT) 2 else 1 + +actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) { + val sigma = BlurEffect.convertRadiusToSigma(radiusPx) + val scale = narrowing(sigma, barWidth) + val rows = shortening(sigma, barHeight) + val width = ceil(barWidth / scale).toInt() + val height = ceil(barHeight / rows).toInt() + if (width <= 0 || height <= 0) return + val surface = surfaceFor(width, height).surface + + surface.canvas.clear(Color.TRANSPARENT) + // the blur is the layer's own paint, so it runs at this surface's resolution rather than at the canvas the bar draws to + surface.canvas.saveLayer(Rect.makeWH(width.toFloat(), height.toFloat()), Paint().apply { + imageFilter = ImageFilter.makeBlur(sigma / scale, sigma / rows, FilterTileMode.CLAMP) + }) + CanvasDrawScope().draw(Density(density, fontScale), layoutDirection, surface.canvas.asComposeCanvas(), Size(width.toFloat(), height.toFloat())) { + scale(1f / scale, 1f / rows, Offset.Zero) { drawBar() } + } + surface.canvas.restore() + + // The surface is whole pixels while the bar is not, so only the bar's own area is taken from it. + drawIntoCanvas { + it.nativeCanvas.drawImageRect( + surface.makeImageSnapshot(), + Rect.makeWH(barWidth / scale, barHeight / rows), + Rect.makeWH(barWidth, barHeight), + SamplingMode.LINEAR, + null, + true + ) + } +} 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/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 696e0efde8..2ebec123ad 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -64,6 +64,9 @@ actual fun LazyColumnWithScrollBar( } val state = state ?: handler.listState val connection = handler.connection + LaunchedEffect(state, handler) { + snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }.collect { handler.contentVersion.value++ } + } // When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state // (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row) val scrollBarDraggingState = remember { mutableStateOf(false) } @@ -202,6 +205,9 @@ actual fun ColumnWithScrollBar( } } val state = state ?: handler.scrollState + LaunchedEffect(state, handler) { + snapshotFlow { state.value }.collect { handler.contentVersion.value++ } + } val connection = handler.connection // When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state // (exact scroll position is available but in Int, not Float) 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 75782d75d7..bd8e0d771a 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 @@ -20,6 +20,7 @@ import java.io.IOException import java.net.BindException import java.security.SecureRandom import java.util.Base64 +import java.util.concurrent.atomic.AtomicBoolean private const val SERVER_HOST = "localhost" private const val SERVER_PORT = 50395 @@ -31,7 +32,7 @@ val connections = ArrayList() actual fun ActiveCallView() { val scope = rememberCoroutineScope() WebRTCController(chatModel.callCommand) { apiMsg -> - Log.d(TAG, "received from WebRTCController: $apiMsg") + Log.d(TAG, "received from WebRTCController: ${apiMsg.resp.javaClass.simpleName}") val call = chatModel.activeCall.value if (call != null) { Log.d(TAG, "has active call $call") @@ -202,7 +203,7 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } while (callCommand.isNotEmpty()) { val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd") + Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") if (cmd != null) { processCommand(cmd) } @@ -232,10 +233,16 @@ fun startServer( val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found") + val webSocketAccepted = AtomicBoolean(false) + override fun handle(session: IHTTPSession): Response { return when { session.headers["upgrade"] == "websocket" -> - if (hasValidCallServerToken(session.parameters, token)) { + if ( + session.headers["origin"] == "http://${SERVER_HOST}:${listeningPort}" + && hasValidCallServerToken(session.parameters, token) + && webSocketAccepted.compareAndSet(false, true) + ) { super.handle(session) } else { unauthorizedResponse() @@ -289,7 +296,7 @@ class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTT // onResponse(message.textPayload) onResponse(json.decodeFromString(message.textPayload)) } catch (e: Exception) { - Log.e(TAG, "failed parsing browser message: $message") + Log.e(TAG, "failed parsing browser message") } } 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, ) { diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt index 800c69f617..92146833b8 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt @@ -33,6 +33,23 @@ class CallServerAuthTest { assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token"))) } + @Test + fun testWebSocketUpgradeRejectedFromOtherOrigin() { + assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token", origin = "http://example.com"))) + } + + @Test + fun testSecondWebSocketUpgradeRejected() { + assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token"))) + assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token"))) + } + + @Test + fun testWebSocketUpgradeAcceptedAfterWrongToken() { + assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong"))) + assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token"))) + } + @Test fun testCallPageRejectedWithoutToken() { assertEquals(401, requestStatus(get(path = "/simplex/call/"))) @@ -46,10 +63,11 @@ class CallServerAuthTest { private fun get(path: String): List = listOf("GET $path HTTP/1.1", "Host: localhost:$port") - private fun webSocketUpgrade(path: String): List = + private fun webSocketUpgrade(path: String, origin: String = "http://localhost:$port"): List = listOf( "GET $path HTTP/1.1", "Host: localhost:$port", + "Origin: $origin", "Upgrade: websocket", "Connection: Upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b8975d80f8..069a1a20b8 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,14 +24,14 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.1-beta.4 -android.version_code=379 +android.version_name=7.1-beta.6 +android.version_code=386 -desktop.version_name=7.1-beta.4 -desktop.version_code=162 +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 diff --git a/apps/multiplatform/product/glossary.md b/apps/multiplatform/product/glossary.md index 10203d8a2a..15ed40d3a1 100644 --- a/apps/multiplatform/product/glossary.md +++ b/apps/multiplatform/product/glossary.md @@ -306,7 +306,7 @@ User-defined tags for organizing conversations. CRUD via `ApiCreateChatTag`, `Ap The real-time communication framework used for audio and video calls. The app uses WebRTC for peer-to-peer media streams, with SMP used only for call signaling (offer/answer/ICE candidates). ### Call (data class) -Represents an active call session. Fields: `remoteHostId`, `userProfile`, `contact`, `callUUID`, `callState` (CallState enum), `initialCallType` (Audio/Video), `localMediaSources`, `localCapabilities`, `peerMediaSources`, `sharedKey` (for E2E call encryption), `connectionInfo`, `connectedAt`. +Represents an active call session. Fields: `remoteHostId`, `userProfile`, `contact`, `callUUID`, `callState` (CallState enum), `initialCallType` (Audio/Video), `localMediaSources`, `localCapabilities`, `peerMediaSources`, `hasSharedKey` (whether an E2E call encryption key was agreed), `connectionInfo`, `connectedAt`. *See:* `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt:14` 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 427e31d9d8..9cad3d3901 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -121,8 +121,8 @@ App links use the app's own scheme, `simplexchat:`, for everything that is not a | `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` | @@ -300,7 +300,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. @@ -308,7 +308,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. +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 bea1d37f3a..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) @@ -113,17 +113,19 @@ The `actual` platform implementation of `ActiveCallView()` and supporting compos ## 4. Desktop Implementation -### 4.1 CallView.desktop.kt (263 lines) +### 4.1 CallView.desktop.kt (308 lines) [`CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) Desktop calls run WebRTC in the system browser, not an embedded WebView: -- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`. -- **WebSocket communication** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`. -- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Starts the server, then opens `http://localhost:/simplex/call/` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server. -- **SendStateUpdates** ([line 137](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L137)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display. -- **ActiveCallView** ([line 28](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L28)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance. +- **NanoWSD server** ([line 215](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L215)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/` when the request includes a valid `token` query parameter. All other paths serve resources from `/assets/www/`. +- **Call token** ([line 271](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L271)): `newCallServerToken()` returns 32 random bytes, base64url-encoded. `hasValidCallServerToken()` ([line 277](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L277)) checks the `token` query parameter. +- **WebSocket upgrade** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): Each server instance accepts one WebSocket upgrade. The upgrade request must include `Origin: http://localhost:` and a valid token. Other upgrade requests receive `401 Unauthorized`. +- **WebSocket communication** ([line 283](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L283)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`. +- **WebRTCController** ([line 157](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L157)): Generates the call token, starts the server, then opens `http://localhost:/simplex/call/?token=` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over the WebSocket. On dispose, sends `WCallCommand.End` and stops the server. +- **SendStateUpdates** ([line 141](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L141)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display. +- **ActiveCallView** ([line 32](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L32)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance. --- @@ -166,8 +168,8 @@ 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) | 263 | 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 | +| `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) | 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 6ce4bc9dc1..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 | @@ -101,7 +101,7 @@ Channel creation happens in `createNtfChannelsMaybeShowAlert()` ([line 298](../. [Line 160](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L160): - Returns `false` (no notification) if app is in foreground -- in-app alert is used instead. -- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`, plus `VISIBILITY_PUBLIC`. +- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`. - **Foreground / unlocked**: Uses regular notification with Accept/Reject action buttons and a custom ringtone (`ring_once` raw resource). - Notification flags include `FLAG_INSISTENT` for repeating sound and vibration. - Call notification channel vibration pattern: `[250, 250, 0, 2600]` ms. diff --git a/apps/simplex-badge-service/Main.hs b/apps/simplex-badge-service/Main.hs index cdeb1c0c1e..68369995e2 100644 --- a/apps/simplex-badge-service/Main.hs +++ b/apps/simplex-badge-service/Main.hs @@ -5,13 +5,19 @@ module Main where import BadgeService.Options (BadgeServiceOpts (..)) import BadgeService.Service import Control.Logger.Simple (LogConfig (..), LogLevel (..), setLogLevel, withGlobalLogging) +import GHC.IO.Encoding (setLocaleEncoding) import Simplex.Chat.Terminal (terminalChatConfig) +import System.IO (hSetEncoding, stderr, stdout, utf8) -- | withGlobalLogging installs the SMP agent's log sinks, which the chat core otherwise installs only under --log-agent. main :: IO () -main = withGlobalLogging LogConfig {lc_file = Nothing, lc_stderr = True} $ do - setLogLevel LogWarn - opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts - if runCLI - then badgeServiceCLI opts - else newServiceState >>= badgeService opts terminalChatConfig +main = do + -- Without a UTF-8 locale GHC reads the ini and writes logs as ASCII, and throws on a non-ASCII group name. + setLocaleEncoding utf8 + mapM_ (`hSetEncoding` utf8) [stdout, stderr] + withGlobalLogging LogConfig {lc_file = Nothing, lc_stderr = True} $ do + setLogLevel LogWarn + opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts + if runCLI + then badgeServiceCLI opts + else newServiceState >>= badgeService opts terminalChatConfig diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md index 538d75c7a0..6de3e53d2f 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -21,8 +21,10 @@ At this stage the service: - creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)), - listens for service requests (`CEvtServiceRequest`) on that address, rejects a request whose `purchaseKey` is not the key the agent verified the signature against, and answers `redeemBadgeCode`, -- issues redemption codes, storing only their `SHA-256` and printing each code once, -- does not accept contact requests unless `[dev] chat_redeem` is on: the address is for RPC only, +- issues redemption codes, storing only their `SHA-256` in its code table, +- does not accept contact requests: the address is for RPC only, +- in service mode with `[group]` in the ini, manages one SimpleX group and serves `/issue`, `/bulk` + and `/revoke` in it (see [Issuing codes](#issuing-codes)), - in service mode with `--service-config`, also serves the built web app (`npm run build` in `web/`), `POST /api/invoice` and `GET /api/invoice/:id`, the BTCPay and Stripe webhook routes, and a payment poller, seeding its price/offer catalog on every start, - owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`). @@ -47,8 +49,9 @@ simplex-badge-service --help - default (no `--run-cli`): background service mode, no interactive terminal. - `--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. `[dev] accept_unverified_store_receipts` does. + below: it starts no web listener and no poller, and serves no group commands, whatever + `--service-config` says. It still updates a code's group message when the code is redeemed or + revoked. `[dev] accept_unverified_store_receipts` applies to it. - `--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: @@ -93,7 +96,9 @@ Other options: `badge_service.ini` holds the listener bind address and `static_dir`, an optional `[btcpay]` section (omitting it disables Bitcoin and Monero), an optional `[stripe]` -section (omitting it disables card payments) and the poll cadence. +section (omitting it disables card payments), an optional `[group]` section (omitting it +turns off the group's commands, though a group created earlier still has its code messages updated) +and the poll cadence. `badge_service.ini.example` is the committed template; `badge_service.ini` itself is gitignored, since a real one holds API keys and webhook secrets. @@ -123,6 +128,10 @@ The BTCPay API key needs four permissions, each scoped to the one store: `canmodifyinvoices` so `POST /api/invoice/:id/cancel` can invalidate an invoice at BTCPay rather than only in this store. +Give the service a BTCPay store of its own, and a Stripe account of its own if card payments are +on. The poller lists every invoice and payment intent of the last three days or so, and one it did +not create and cannot read is reported in a warning once an hour until it falls out of that window. + ### Card payments (Stripe) An optional `[stripe]` section enables the card lane; omitting it disables card payments @@ -200,23 +209,6 @@ the exception: each answers 200, 400 or 413 with an empty body, because its prov caller and nothing it could read would change what the route does. A wrong verb on any route, those two included, answers `method_not_allowed`. -### Redeeming over chat, for local testing - -```ini -[dev] -chat_redeem = on -``` - -With this on, the service accepts contact requests and answers `/redeem ` from a contact -with the credential as one-line JSON, ready to paste into a client as `/badge add `. Off by -default, and only `on`/`off` parse, so a typo cannot silently arm it. It applies to the service -mode only; `--run-cli` ignores it. - -Keep it off anywhere real. The service RPC signs over a master key only the client holds; here -there is no client key, so the service generates one and hands it over with the credential, which -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 @@ -237,8 +229,13 @@ they refuse what it signs — never turn it on where the key is one those apps t ## Issuing codes -Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way -to start it — so codes are issued without a second process touching the service's database: +Operators issue codes two ways: from the service's own command line in `--run-cli` mode, and from the +managed group in service mode. Both are commands to a running process, so no second process +touches the service's database. + +### From the command line + +The command is sent to the running service in `--run-cli` mode, not a way to start it: ``` //issue [months] [paid|unpaid|free] @@ -260,8 +257,76 @@ A code that leaked, or that was refunded, is withdrawn the same way: ``` A revoked code answers redemption with `code_invalid`, as if it had never existed, so its holder -learns nothing from trying. Revoking is not repeatable: the second attempt says so. A code that -was already redeemed cannot be revoked: its badge was issued, and the command answers with an error. +learns nothing from trying. A client that redeemed it before the revoke still gets its own badge +back when it asks again. Revoking it again answers "already revoked" and fixes its group +message if the first revoke didn't. A code with no uses left can't be revoked, because its badges +were already given out, and the command answers with an error. A multi-use code with uses left can +be revoked, which stops the uses that remain. Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is why issuing codes lives in the service rather than in core. + +### From the group + +With `[group]` in `badge_service.ini`, the service manages one group and serves three commands in +it: `/issue [months ] [uses ]` and `/bulk [months ] count ` for moderators +and above, `/revoke ` for admins and owners. `months` is 1 to 255, `uses` 1 to 1000 and +`count` 1 to 100; a value outside these gets the usage reply. A member's role is checked as the +service last saw it, so a command sent by a moderator just demoted or removed can still run if it +reaches the service first; revoke any code the service posts for them after the change. `uses` +above 1 makes a multi-use code, tracked by a group message showing its remaining uses and the time +of the last one; when every use is redeemed, the same message says so. Every reply carrying a code is read by every member, +since the group has no private lane, so a code issued there is only as private as its least trusted +member. +Those replies are also kept as plain text in the service's chat database, so a copy of the database +holds every code issued in the group. Keep the group's visible history off: with it on, each new +member receives recent messages, and the codes in them, when they join. A multi-use code's message +carries the code, and every redemption edits it or, after a day, posts it again; either way every +current member receives it, so a member who joined after the code was issued gets the code while it +still has uses left. A message replaced by a new post stays in the group with its old count. Keep +disappearing messages off in the group and set no message TTL for the service's chats: a code's +message that expires is treated as deleted and never posted again, so its counter stops. +Every member can see when each use of a multi-use code was redeemed: the message shows the time of +the last one, and its edit times show the rest. +`/revoke ` names the code in an ordinary group message, so every member holds it before the +service reads the command, and the code stays redeemable until the service acts on it — for the +whole of any downtime. Revoke a code that is not already public in the group, a refunded one above +all, with `//revoke` in `--run-cli` mode. A `/revoke ` with nothing after the code, from a +member below admin, is answered that the code was not revoked and is now visible to the group. A +group command the service received but had not run when it stopped, or received while it ran in +`--run-cli` mode, is dropped with no reply, so resend it, or use `//revoke`. + +The first member to join through the link is promoted to owner, so the operator joins before sharing +it. Keep the service an owner too: below owner it cannot update the group's command menu, and below +author it cannot post codes or replies. A failed promotion is logged at once, and an owner who left +is logged at the next start or join. Then make the member you choose owner with the `/mr` command +that the log line names, in `--run-cli` mode; the service never promotes anyone once the first +promotion was attempted. + +The join link logged when the group is created stays valid: anyone who has it can join later, as a +member, and read every code posted or edited from then on. That includes a removed member, who can +rejoin through it, so removing a member does not stop them seeing new codes. Keep the log that holds +it private. The link is also stored in the `group_link` column of `sx_badge_service_group`, where it +can be read again. + +If an owner deletes the group, or removes the service from it, the service logs an error on start +and stops serving the group. To create a new group, stop the service, delete the row, and start it +again: with SQLite, run `DELETE FROM sx_badge_service_group;` on the `_chat.db` file +(`~/.simplex/simplex_badge_service_chat.db` by default), opened with `sqlcipher` and the database key +if one is set; with PostgreSQL, run +`DELETE FROM _chat_schema.sx_badge_service_group;` (`simplex_v1_chat_schema` by default). +Multi-use codes issued in the old group stay redeemable, but their messages there are no longer +updated, so revoke with `//revoke` any that should not stay live. + +The group is identified by the single `sx_badge_service_group` row. Rolling back past the +`20260918_badge_group_ops` migration drops that table, so a later re-upgrade creates a second group +and orphans the first one with its members and roles; multi-use codes come back single-use with +their claims re-derived, and outstanding trackers come back unanchored. Redeemed credentials are +preserved and no code becomes redeemable again, though while the old version runs, only the holder +whose credential ends last gets it back on a retry, and any other holder of a multi-use code gets +`code_used`; every holder of a revoked code gets `code_invalid`. Rolling back means re-creating and +re-sharing the group; delete the orphaned one with `/d #''` in `--run-cli` mode, as its +join link still works and its messages hold every code posted there. + +The configured `display_name` and `description` apply only to the group the service creates. Editing +them later is logged as not applied and changes nothing. diff --git a/apps/simplex-badge-service/badge_service.ini.example b/apps/simplex-badge-service/badge_service.ini.example index 3d02951fdc..e9ca861806 100644 --- a/apps/simplex-badge-service/badge_service.ini.example +++ b/apps/simplex-badge-service/badge_service.ini.example @@ -50,10 +50,18 @@ 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 = off +; optional: when present the service manages one group, created on its first start without +; --run-cli, and logs its join link once, on the start that creates it. The link is a bearer +; secret that stays valid, so keep that log private. The first member to join is promoted to +; owner, so join right away. +; moderators can /issue and /bulk codes; admins and owners can also /revoke. +; display_name must be a name the chat core accepts unchanged: one it would spell +; differently stops the whole service, payments included, from starting. +;[group] +;display_name = SimpleX Badges +;description = Welcome to the badges group + ; 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 +[dev] accept_unverified_store_receipts = off diff --git a/apps/simplex-badge-service/src/BadgeService/Codes.hs b/apps/simplex-badge-service/src/BadgeService/Codes.hs new file mode 100644 index 0000000000..06d3517359 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Codes.hs @@ -0,0 +1,40 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} + +module BadgeService.Codes + ( issueOneCode, + issueFailedText, + revokeBadgeCode, + singleUse, + ) +where + +import BadgeService.Store (RevokeResult, insertBadgeCode, revokeCode) +import BadgeService.Store.Invoices (truncateToSecond) +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (getCurrentTime) +import Simplex.Chat.Badges (BadgeType) +import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeHash, randomBadgeCode) +import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus) +import Simplex.Chat.Bot.Store (withDB') +import Simplex.Chat.Controller (ChatController (..)) + +singleUse :: Int +singleUse = 1 + +revokeBadgeCode :: ChatController -> BadgeCode -> IO (Either String RevokeResult) +revokeBadgeCode cc code = do + now <- truncateToSecond <$> getCurrentTime + withDB' "revokeBadgeCode" cc $ \db -> revokeCode db (badgeCodeHash code) now + +-- | The group is joined through a bearer link, so this reply names no database error. +issueFailedText :: Text +issueFailedText = "The code could not be issued." + +-- | The code table keeps only the hash, so the caller must deliver the code. +issueOneCode :: ChatController -> BadgeType -> Int -> BadgeCodePaymentStatus -> Int -> IO (Either String (BadgeCode, Int64)) +issueOneCode cc badgeType months paymentStatus redeemLimit = do + code <- randomBadgeCode $ random cc + now <- truncateToSecond <$> getCurrentTime + fmap (code,) <$> withDB' "issueBadgeCode" cc (\db -> insertBadgeCode db (badgeCodeHash code) badgeType months paymentStatus redeemLimit now) diff --git a/apps/simplex-badge-service/src/BadgeService/Config.hs b/apps/simplex-badge-service/src/BadgeService/Config.hs index fd11647a36..8b24956a57 100644 --- a/apps/simplex-badge-service/src/BadgeService/Config.hs +++ b/apps/simplex-badge-service/src/BadgeService/Config.hs @@ -11,6 +11,7 @@ module BadgeService.Config speedPolicyName, PollConfig (..), BadgeIssuerKey (..), + GroupConfig (..), ServiceConfig (..), defaultExpiryMinutes, defaultSessionMinutes, @@ -21,12 +22,15 @@ where import qualified Control.Exception as E import BadgeService.Log (logWarn) +import Control.Monad (mfilter) import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile) import qualified Data.ByteString.Char8 as B import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections) +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as TIO +import Simplex.Chat.Library.Commands (mkValidName) import Simplex.Messaging.Crypto.BBS (BBSSecretKey) import Simplex.Messaging.Encoding.String (strDecode) import System.IO.Error (ioeGetErrorString) @@ -97,14 +101,19 @@ data BadgeIssuerKey = BadgeIssuerKey instance Show BadgeIssuerKey where show BadgeIssuerKey {keyIdx} = "issuer key " <> show keyIdx +data GroupConfig = GroupConfig + { gDisplayName :: Text, + gDescription :: Maybe Text + } + deriving (Eq, Show) + data ServiceConfig = ServiceConfig { listener :: ListenerConfig, btcpay :: Maybe BTCPayConfig, stripe :: Maybe StripeConfig, poll :: PollConfig, issuer :: Maybe BadgeIssuerKey, - -- Local testing only; signs credentials with a master key this service can link. - devChatRedeem :: Bool, + group :: Maybe GroupConfig, -- Local testing only; anyone who can reach the service can mint badges with any well-formed receipt. devAcceptUnverifiedStoreReceipts :: Bool } @@ -157,7 +166,8 @@ 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", "accept_unverified_store_receipts"]), + ("group", ["display_name", "description"]), + ("dev", ["accept_unverified_store_receipts"]), ("issuer", ["index", "private_key"]) ] @@ -181,16 +191,14 @@ parseConfig ini = do p <- num "listener" "port" 8080 if 1 <= p && p <= 65535 then Right p else Left "listener.port must be between 1 and 65535" lServeWebapp <- bool "listener" "serve_webapp" True - let lWebappExportDir = case fmap T.strip (look "listener" "webapp_export_dir") of - Just v | not (T.null v) -> Just (T.unpack v) - _ -> Nothing + let lWebappExportDir = T.unpack <$> present "listener" "webapp_export_dir" lTrustForwardedFor <- bool "listener" "trust_forwarded_for" False btc <- btcpaySection str <- stripeSection iss <- issuerSection + grp <- groupSection 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 @@ -199,18 +207,15 @@ parseConfig ini = do stripe = str, poll = PollConfig {pWaitingSeconds, pIdleSeconds}, issuer = iss, - devChatRedeem = devRedeem, + group = grp, devAcceptUnverifiedStoreReceipts = devUnverifiedReceipts } where hasSection s = s `elem` sections ini look s k = either (const Nothing) Just (lookupValue s k ini) - required s k = case look s k of - Just v | not (T.null (T.strip v)) -> Right (T.strip v) - _ -> Left (T.unpack s <> "." <> T.unpack k <> " is required") - optional s k d = case fmap T.strip (look s k) of - Just v | not (T.null v) -> Right v - _ -> Right d + present s k = mfilter (not . T.null) (T.strip <$> look s k) + required s k = maybe (Left (T.unpack s <> "." <> T.unpack k <> " is required")) Right (present s k) + optional s k d = Right (fromMaybe d (present s k)) -- Integer, because readMaybe at Int wraps silently, reading 2^64+4 as 4. num s k d = case look s k of Nothing -> Right d @@ -272,6 +277,20 @@ parseConfig ini = do Just v -> case readMaybe (T.unpack (T.strip v)) of Just d | d >= 0 && d <= maxTolerance -> Right d _ -> Left ("btcpay.payment_tolerance must be a percentage between 0 and " <> show maxTolerance) + groupSection + | not (hasSection "group") = Right Nothing + | otherwise = do + gDisplayName <- required "group" "display_name" >>= validGroupName + pure (Just GroupConfig {gDisplayName, gDescription = present "group" "description"}) + -- The core refuses a group name that mkValidName would change, so it is rejected here. + validGroupName n = + let valid = T.pack (mkValidName (T.unpack n)) + in if n == valid + then Right n + else Left ("group.display_name \"" <> T.unpack n <> "\" is not a valid group name" <> closest valid) + closest valid + | T.null valid = "" + | otherwise = ", the closest valid name is \"" <> T.unpack valid <> "\"" stripeSection | not (hasSection "stripe") = Right Nothing | otherwise = do diff --git a/apps/simplex-badge-service/src/BadgeService/Group.hs b/apps/simplex-badge-service/src/BadgeService/Group.hs new file mode 100644 index 0000000000..28117655ef --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Group.hs @@ -0,0 +1,433 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module BadgeService.Group + ( ensureManagedGroup, + runGroupLane, + inertGroupConfig, + GroupEvent (..), + GroupAction (..), + groupEvent, + hasTracker, + refreshTracker, + revokeWithTracker, + coalesceTrackerRefreshes, + TrackerAction (..), + trackerDecision, + codeInTracker, + noOwnerHint, + orphanHint, + ) +where + +import BadgeService.Codes (issueFailedText, issueOneCode, revokeBadgeCode, singleUse) +import BadgeService.Config (GroupConfig (..)) +import BadgeService.Group.Command (CmdAction (..), GroupCmd (..), groupCmdAction, groupCommands) +import BadgeService.Log (logError, logInfo, logWarn) +import BadgeService.Store (CodeTracker (..), ManagedGroup (..), RevokeResult (..), clearCodeGroupItems, getCodeTracker, getEditableTrackers, getManagedGroup, insertManagedGroup, markOwnerBootstrapped, setCodeGroupItem) +import BadgeService.Store.Invoices (truncateToSecond) +import Control.Concurrent.STM (TQueue, atomically, flushTQueue, readTQueue, readTVarIO, writeTQueue) +import Control.Monad (forM_, forever, mfilter, replicateM, unless, void) +import Control.Monad.Except (runExceptT) +import Data.Either (partitionEithers) +import Data.Functor (($>), (<&>)) +import Data.Int (Int64) +import Data.List (sortOn) +import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) +import qualified Data.Set as S +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 GHC.Stack (HasCallStack, withFrozenCallStack) +import Simplex.Chat.Badges.Code (BadgeCode, formatBadgeCode, parseBadgeCode) +import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) +import Simplex.Chat.Bot.Store (withDB') +import Simplex.Chat.Controller +import Simplex.Chat.Core (sendChatCmd) +import Simplex.Chat.Markdown (viewName) +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent (CIContent (..), ciContentToText) +import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Store.Messages (getGroupChatItem) +import Simplex.Chat.Store.Shared (StoreError (..)) +import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences (GroupPreferences (..), commands_, emptyGroupPrefs) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Chat.View (simplexChatContact) +import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), UserId) +import Simplex.Messaging.Encoding.String (StrEncoding, strEncode) +import Simplex.Messaging.Util (catchOwn', safeDecodeUtf8, tshow, ($>>=)) +import System.Exit (exitFailure) + +buildGroupProfile :: GroupConfig -> GroupProfile +buildGroupProfile GroupConfig {gDisplayName, gDescription} = + GroupProfile + { displayName = gDisplayName, + fullName = "", + shortDescr = Nothing, + description = gDescription, + image = Nothing, + publicGroup = Nothing, + groupPreferences = Just (emptyGroupPrefs {commands = Just groupCommands} :: GroupPreferences), + memberAdmission = Nothing + } + +ensureManagedGroup :: ChatController -> GroupConfig -> IO (Maybe GroupId) +ensureManagedGroup cc gc = + withDB' "getManagedGroup" cc getManagedGroup >>= \case + -- A read error must not fall through to create, which would orphan a second group. + -- The service stops instead, so a restart retries rather than leaving the group unserved. + Left _ -> logError "badge group lookup failed, stopping" >> exitFailure + -- The join link is a bearer secret, logged only where it is created. + Right (Just ManagedGroup {mgGroupId}) -> + sendChatCmd cc (APIGroupInfo mgGroupId) >>= \case + Right CRGroupInfo {groupInfo = GroupInfo {membership, groupProfile = p}} + | memberCurrent membership -> do + forM_ (inertGroupConfig gc p) logWarn + unless (commandsCurrent p) $ advertiseCommands cc mgGroupId p + logInfo "badge group ready" + pure (Just mgGroupId) + | otherwise -> groupGone + Left (ChatErrorStore SEGroupNotFound {}) -> groupGone + r -> do + logError ("badge group info failed: " <> tshow r) + pure (Just mgGroupId) + Right Nothing -> + readTVarIO (currentUser cc) >>= \case + Nothing -> logError "badge group not created: no current user" >> pure Nothing + Just User {userId} -> createManagedGroup cc gc userId + where + groupGone = do + logError "badge group was deleted or the service was removed from it; delete the sx_badge_service_group row and restart to create a new group" + pure Nothing + +createManagedGroup :: ChatController -> GroupConfig -> UserId -> IO (Maybe GroupId) +createManagedGroup cc gc userId = + sendChatCmd cc (APINewGroup userId False (buildGroupProfile gc)) >>= \case + Right CRGroupCreated {groupInfo = g@GroupInfo {groupId}} -> + sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case + Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact}} -> do + now <- truncateToSecond <$> getCurrentTime + let linkText = groupLinkText connLinkContact + withDB' "insertManagedGroup" cc (\db -> insertManagedGroup db groupId linkText now >> clearCodeGroupItems db) >>= \case + Right () -> do + logInfo $ "badge group created, join link: " <> linkText + pure (Just groupId) + Left _ -> logError ("badge group " <> tshow groupId <> " not recorded - " <> orphanHint (groupName' g)) >> pure Nothing + r -> logError ("badge group " <> tshow groupId <> " link failed: " <> tshow r <> " - " <> orphanHint (groupName' g)) >> pure Nothing + r -> logError ("badge group creation failed: " <> tshow r) >> pure Nothing + +-- The next start creates another group, so a partly created one is left for the operator to delete. +orphanHint :: GroupName -> Text +orphanHint gName = "delete this orphan group with /d #" <> viewName gName <> " in --run-cli mode" + +-- An existing group gets the current commands, so an upgrade that changes them reaches its members. +commandsCurrent :: GroupProfile -> Bool +commandsCurrent groupProfile = (groupPreferences groupProfile >>= commands_) == Just groupCommands + +advertiseCommands :: ChatController -> GroupId -> GroupProfile -> IO () +advertiseCommands cc groupId groupProfile = + sendChatCmd cc (APIUpdateGroupProfile groupId p') >>= \case + Right CRGroupUpdated {} -> logInfo "badge group commands advertised" + r -> logError ("badge group profile update failed: " <> tshow r) + where + prefs = fromMaybe emptyGroupPrefs (groupPreferences groupProfile) + p' = groupProfile {groupPreferences = Just (prefs {commands = Just groupCommands} :: GroupPreferences)} + +-- Config is applied only at creation, since a group rename is broadcast to every member. +inertGroupConfig :: GroupConfig -> GroupProfile -> Maybe Text +inertGroupConfig GroupConfig {gDisplayName, gDescription} GroupProfile {displayName, description} + | null diverged = Nothing + | otherwise = Just $ "badge group config is not applied to an existing group: " <> T.intercalate "; " diverged + where + diverged = + [ field <> " \"" <> configured <> "\", group has \"" <> live <> "\"" + | (field, configured, live) <- + [ ("display_name", gDisplayName, displayName), + ("description", fromMaybe "" gDescription, fromMaybe "" description) + ], + configured /= live + ] + +groupLinkText :: CreatedLinkContact -> Text +groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt (simplexChatContact cReq)) strEncodeTxt sLnk_ + +strEncodeTxt :: StrEncoding a => a -> Text +strEncodeTxt = safeDecodeUtf8 . strEncode + +data GroupEvent + = GEInGroup GroupId GroupAction + | GETracker Int64 BadgeCode + deriving (Eq, Show) + +data GroupAction + = GAJoined + | GACommand GroupMemberRole Text + deriving (Eq, Show) + +-- Support-scope, moderated or blocked, and live items are ignored: the reply would go to the main group, +-- moderation or blocking without Full Delete keeps the content, and a live item holds only partial text. +groupEvent :: ChatEvent -> Maybe GroupEvent +groupEvent = \case + CEvtJoinedGroupMember {groupInfo = GroupInfo {groupId}} -> Just $ GEInGroup groupId GAJoined + CEvtNewChatItems {chatItems = AChatItem _ _ (GroupChat GroupInfo {groupId} scope) ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent (MCText t), meta = CIMeta {itemDeleted, itemLive}} : _} + | isNothing scope && isNothing itemDeleted && itemLive /= Just True -> Just $ GEInGroup groupId (GACommand (memberRole' m) t) + _ -> Nothing + +-- A refresh reads the code's count when it runs, so the last one queued shows every claim before it. +coalesceTrackerRefreshes :: [GroupEvent] -> [GroupEvent] +coalesceTrackerRefreshes = snd . foldr keepLast (S.empty, []) + where + keepLast ev (seen, kept) = case ev of + GETracker badgeCodeId _ + | badgeCodeId `S.member` seen -> (seen, kept) + | otherwise -> (S.insert badgeCodeId seen, ev : kept) + _ -> (seen, ev : kept) + +logUncaught :: HasCallStack => IO () -> IO () +logUncaught a = a `catchOwn'` withFrozenCallStack (logError . tshow) + +handleGroupEvent :: ChatController -> GroupId -> GroupEvent -> IO () +handleGroupEvent cc groupId ev = logUncaught (handle ev) + where + handle = \case + GEInGroup gid action + | gid /= groupId -> pure () + | otherwise -> case action of + GAJoined -> promoteOwner cc groupId + GACommand role t -> case groupCmdAction role t of + RunCmd cmd -> runGroupCmd cc groupId cmd + ReplyText txt -> void $ sendGroupText cc groupId "command reply" txt + IgnoreMsg -> pure () + GETracker badgeCodeId code -> updateTracker cc groupId badgeCodeId code + +promoteFirstOwner :: ChatController -> GroupInfo -> GroupMember -> IO () +promoteFirstOwner cc g@GroupInfo {groupId} member = + -- The flag is set before promoting, so a retry cannot promote a second owner. + withDB' "markOwnerBootstrapped" cc (`markOwnerBootstrapped` groupId) >>= \case + Right True -> + sendChatCmd cc (APIMembersRole groupId (groupMemberId' member :| []) GROwner) >>= \case + -- The core returns no members when every store update failed. + Right CRMembersRoleUser {members = _ : _} -> logInfo $ "badge group owner promoted: member " <> tshow (groupMemberId' member) + r -> logError $ "badge group owner promotion failed: " <> tshow r <> " - " <> noOwnerHint (groupName' g) + _ -> pure () + +-- Nothing is a group that is not served, so its events are drained. +runGroupLane :: ChatController -> TQueue GroupEvent -> Maybe GroupId -> IO () +runGroupLane cc q groupId_ = case groupId_ of + Just groupId -> do + promoteOwner cc groupId + -- Reconciling precedes the first batch, so queued events write after the correction. + reconcileTrackers cc groupId + forever $ do + evs <- atomically $ (:) <$> readTQueue q <*> flushTQueue q + mapM_ (handleGroupEvent cc groupId) (coalesceTrackerRefreshes evs) + Nothing -> forever $ void $ atomically (readTQueue q) + +-- The earliest joined member is promoted, not the one who just joined, so a join the lane never handled +-- (under --run-cli, or lost in a crash) or a failed attempt never hands the group to a later joiner. +promoteOwner :: ChatController -> GroupId -> IO () +promoteOwner cc groupId = + withDB' "getManagedGroup" cc getManagedGroup >>= \case + Right (Just ManagedGroup {mgOwnerBootstrapped}) -> + sendChatCmd cc (APIListMembers groupId) >>= \case + Right CRGroupMembers {group = Group {groupInfo, members}} + -- An owner made by hand only needs the flag set, or promoting would add a second owner. + | any (\m -> memberRole' m == GROwner && memberCurrent m) members -> + unless mgOwnerBootstrapped $ void $ withDB' "markOwnerBootstrapped" cc (`markOwnerBootstrapped` groupId) + -- A crash between setting the flag and promoting leaves no owner, and only the operator may retry. + | mgOwnerBootstrapped -> logError $ noOwnerHint (groupName' groupInfo) + | otherwise -> mapM_ (promoteFirstOwner cc groupInfo) $ listToMaybe $ sortOn groupMemberId' $ filter joined members + r -> logError $ "badge group members not listed: " <> tshow r + _ -> pure () + where + -- A member still joining cannot act yet, so it is not a candidate. + joined m = memberStatus m `elem` [GSMemConnected, GSMemComplete] + +-- The live local name, quoted as --run-cli parses it, can differ from the configured one after a name clash or a rename. +noOwnerHint :: GroupName -> Text +noOwnerHint gName = "the badge group has no member owner, make one with /mr #" <> viewName gName <> " owner in --run-cli mode" + +runGroupCmd :: ChatController -> GroupId -> GroupCmd -> IO () +runGroupCmd cc groupId = \case + GCIssue bt months uses -> + issueOneCode cc bt months CPSFree uses >>= \case + Left _ -> reply issueFailedText + Right (code, badgeCodeId) + | not (hasTracker uses) -> reply ("Code: " <> formatBadgeCode code) + | otherwise -> do + now <- truncateToSecond <$> getCurrentTime + sendGroupText cc groupId ("tracker, code " <> tshow badgeCodeId <> " is lost") (initialTrackerBody code uses) + >>= mapM_ (\iid -> withDB' "setCodeGroupItem" cc $ \db -> setCodeGroupItem db badgeCodeId iid now) + GCBulk bt months count -> do + codes <- replicateM count (issueOneCode cc bt months CPSFree singleUse) + let (errs, ok) = partitionEithers codes + issued = map (formatBadgeCode . fst) ok + reply . T.intercalate "\n" $ case errs of + [] -> issued + _ : _ -> issued <> ["Issued " <> tshow (length issued) <> " of " <> tshow count <> " codes. The remaining codes could not be issued."] + -- Naming the code tells concurrent revokes apart; the command already made it public. + GCRevoke code -> do + outcome <- either id id <$> revokeWithTracker cc code + reply (formatBadgeCode code <> ": " <> outcome) + where + reply = void . sendGroupText cc groupId "reply, any codes in it are lost" + +-- The label says what is lost when the send fails, since the log line is its only trace. +sendGroupText :: HasCallStack => ChatController -> GroupId -> Text -> Text -> IO (Maybe ChatItemId) +sendGroupText cc groupId label txt = + sendChatCmd cc (APISendMessages (SRGroup groupId Nothing False) False Nothing False (ComposedMessage Nothing Nothing (MCText txt) M.empty :| [])) >>= \case + Right CRNewChatItems {chatItems = ci : _} -> pure (Just (aChatItemId ci)) + r -> withFrozenCallStack logError ("badge group message not sent (" <> label <> "): " <> tshow r) $> Nothing + +initialTrackerBody :: BadgeCode -> Int -> Text +initialTrackerBody code total = trackerBody code total total Nothing + +-- The body is dated by the last redemption, not now, because reconcile rewrites it after a restart. +-- The code is green only while it can still be redeemed. +trackerBody :: BadgeCode -> Int -> Int -> Maybe UTCTime -> Text +trackerBody code remaining total redeemedAt + | remaining > 0 = "!2 " <> formatBadgeCode code <> "!\n" <> tshow remaining <> " of " <> tshow total <> " uses remaining" <> lastUsed + | otherwise = formatBadgeCode code <> "\nAll " <> tshow total <> " uses redeemed" <> lastUsed + where + lastUsed = maybe "" ((", last used " <>) . fmtTime) redeemedAt + +revokedBody :: BadgeCode -> Text +revokedBody code = formatBadgeCode code <> "\nRevoked, can no longer be redeemed" + +fmtTime :: UTCTime -> Text +fmtTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M UTC" + +data TrackerAction = Edit | Repost + deriving (Eq, Show) + +-- The core refuses to edit a sent message older than this. +editWindow :: NominalDiffTime +editWindow = nominalDay + +trackerDecision :: UTCTime -> UTCTime -> TrackerAction +trackerDecision now sentAt + | diffUTCTime now sentAt < editWindow = Edit + | otherwise = Repost + +-- A repost publishes the code a second time, so the reconcile pass may only edit. +data RepostPolicy = MayRepost | EditOnly + +-- A deleted or moderated tracker is not reposted, because deleting it does not revoke the code. +setTrackerBody :: ChatController -> GroupId -> Int64 -> RepostPolicy -> (CodeTracker -> Maybe Text) -> IO () +setTrackerBody cc groupId badgeCodeId policy mkBody = + withDB' "getCodeTracker" cc (`getCodeTracker` badgeCodeId) >>= \case + Right (Just tracker@CodeTracker {trackerItemId, trackerSentAt}) -> + forM_ (mkBody tracker) $ \body -> do + now <- truncateToSecond <$> getCurrentTime + let repost = + trackerItemText cc groupId trackerItemId >>= \case + Nothing -> logWarn $ "badge group tracker not reposted, code " <> tshow badgeCodeId <> " is no longer published" + -- Past the window every write reposts, so an unchanged body is not posted again. + Just current -> + unless (current == body) $ + sendGroupText cc groupId ("tracker repost, code " <> tshow badgeCodeId <> " keeps its old message") body + >>= mapM_ (\i -> withDB' "setCodeGroupItem" cc (\db -> setCodeGroupItem db badgeCodeId i now)) + -- The core can also refuse an edit inside the window, because it uses the message's own timestamp. + uneditable = case policy of + MayRepost -> repost + EditOnly -> logWarn $ "badge group tracker left uncorrected, code " <> tshow badgeCodeId <> " can no longer be edited" + case trackerDecision now trackerSentAt of + Edit -> + sendChatCmd cc (APIUpdateChatItem (ChatRef CTGroup groupId Nothing) trackerItemId False (UpdatedMessage (MCText body) M.empty)) >>= \case + Right CRChatItemUpdated {} -> pure () + Right CRChatItemNotChanged {} -> pure () + Left (ChatError CEInvalidChatItemUpdate) -> uneditable + -- Any other failure may still have applied the edit, so a repost could publish the code twice. + r -> logError $ "badge group tracker not updated, code " <> tshow badgeCodeId <> ": " <> tshow r + Repost -> uneditable + _ -> pure () + +-- A revoke and a redemption can arrive in either order, so a revoked tracker is left alone. +counterBody :: BadgeCode -> CodeTracker -> Maybe Text +counterBody code CodeTracker {redeemCount, redeemLimit, revokedAt, redeemedAt} + | isJust revokedAt = Nothing + | otherwise = Just $ trackerBody code (redeemLimit - redeemCount) redeemLimit redeemedAt + +hasTracker :: Int -> Bool +hasTracker redeemLimit = redeemLimit > singleUse + +-- The tracker edit reaches every member, so the group lane runs it off the request path. +-- Without a lane (--run-cli, or no [group]) it runs here, before the response. +refreshTracker :: ChatController -> Maybe (TQueue GroupEvent) -> Int64 -> BadgeCode -> IO () +refreshTracker cc trackerQ_ badgeCodeId code = case trackerQ_ of + Just q -> atomically $ writeTQueue q (GETracker badgeCodeId code) + Nothing -> logUncaught $ withManagedGroup cc $ \groupId -> updateTracker cc groupId badgeCodeId code + +updateTracker :: ChatController -> GroupId -> Int64 -> BadgeCode -> IO () +updateTracker cc groupId badgeCodeId code = setTrackerBody cc groupId badgeCodeId MayRepost (counterBody code) + +-- | Left is a refusal or a failure; both sides are the text to show whoever sent the revoke. +revokeWithTracker :: ChatController -> BadgeCode -> IO (Either Text Text) +revokeWithTracker cc code = + revokeBadgeCode cc code >>= \case + -- The message is retired before the answer, so the answer never sits beside a live counter. + Right (Revoked badgeCodeId) -> Right "Revoked." <$ retire badgeCodeId + -- A repeated revoke repairs a message that an earlier revoke failed to update. + Right (AlreadyRevoked badgeCodeId) -> Right "Already revoked." <$ retire badgeCodeId + Right AlreadyRedeemed -> pure $ Left "Fully redeemed. It cannot be revoked." + Right NoSuchCode -> pure $ Left "No such code." + Left _ -> pure $ Left "The code could not be revoked." + where + retire badgeCodeId = logUncaught $ withManagedGroup cc $ \groupId -> + setTrackerBody cc groupId badgeCodeId MayRepost (const $ Just $ revokedBody code) + +withManagedGroup :: ChatController -> (GroupId -> IO ()) -> IO () +withManagedGroup cc action = + withDB' "getManagedGroup" cc getManagedGroup >>= \case + Right (Just ManagedGroup {mgGroupId}) -> action mgGroupId + _ -> pure () + +-- A claim's refresh is queued after the claim commits, so a crash can drop it. +reconcileTrackers :: ChatController -> GroupId -> IO () +reconcileTrackers cc groupId = do + editableAfter <- addUTCTime (-editWindow) <$> getCurrentTime + withDB' "getEditableTrackers" cc (`getEditableTrackers` editableAfter) >>= \case + Left _ -> logError "badge group trackers not reconciled: tracked code lookup failed" + Right codes -> forM_ codes $ \(badgeCodeId, itemId) -> logUncaught $ reconcileTracker cc groupId badgeCodeId itemId + +-- An unchanged edit still walks every member, so a tracker already showing the right text is skipped. +reconcileTracker :: ChatController -> GroupId -> Int64 -> ChatItemId -> IO () +reconcileTracker cc groupId badgeCodeId itemId = + readTrackerCode cc groupId badgeCodeId itemId >>= mapM_ reconcile + where + reconcile (code, current) = setTrackerBody cc groupId badgeCodeId EditOnly (correctedBody code current) + -- A crash after a revoke committed can leave its tracker still counting, so a revoked code is retired here. + correctedBody code current tracker@CodeTracker {revokedAt} = + mfilter (/= current) $ if isJust revokedAt then Just (revokedBody code) else counterBody code tracker + +readTrackerCode :: ChatController -> GroupId -> Int64 -> ChatItemId -> IO (Maybe (BadgeCode, Text)) +readTrackerCode cc groupId badgeCodeId itemId = + trackerItemText cc groupId itemId >>= \case + Nothing -> do + logWarn $ "badge group tracker not read, code " <> tshow badgeCodeId + pure Nothing + Just current -> case codeInTracker current of + Nothing -> do + logError $ "badge group tracker carries no readable code, code " <> tshow badgeCodeId + pure Nothing + Just code -> pure (Just (code, current)) + +codeInTracker :: Text -> Maybe BadgeCode +codeInTracker = listToMaybe . mapMaybe parseBadgeCode . T.words + +-- The item is read from the store, because the core's item info also loads every edit and every member's delivery status. +-- Moderation can keep a deleted item's content, so itemDeleted is checked. +trackerItemText :: ChatController -> GroupId -> ChatItemId -> IO (Maybe Text) +trackerItemText cc groupId itemId = + readTVarIO (currentUser cc) $>>= \user -> + withDB' "getGroupChatItem" cc (\db -> runExceptT $ getGroupChatItem db user groupId itemId) <&> \case + Right (Right (CChatItem _ ChatItem {content, meta = CIMeta {itemDeleted}})) | isNothing itemDeleted -> Just (ciContentToText content) + _ -> Nothing diff --git a/apps/simplex-badge-service/src/BadgeService/Group/Command.hs b/apps/simplex-badge-service/src/BadgeService/Group/Command.hs new file mode 100644 index 0000000000..799e1a2fcf --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Group/Command.hs @@ -0,0 +1,142 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} + +module BadgeService.Group.Command + ( GroupCmd (..), + CmdAction (..), + groupCmdAction, + groupCommands, + codeP, + badgeTypeP, + textTokenP, + maxMonths, + maxUses, + ) +where + +import Control.Applicative (optional, (<|>)) +import Control.Monad (void) +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Char (isSpace) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat.Badges (BadgeType (..)) +import Simplex.Chat.Badges.Code (BadgeCode, parseBadgeCode) +import Simplex.Chat.Types.Preferences (ChatBotCommand (..)) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Encoding.String (TextEncoding (..)) +import Simplex.Messaging.Util (safeDecodeUtf8) + +maxBulk, maxUses, maxMonths :: Int +maxBulk = 100 +maxUses = 1000 +maxMonths = 255 + +data GroupCmd + = GCIssue BadgeType Int Int + | GCBulk BadgeType Int Int + | GCRevoke BadgeCode + deriving (Eq, Show) + +data CmdAction + = RunCmd GroupCmd + | ReplyText Text + | IgnoreMsg + deriving (Eq, Show) + +groupCmdAction :: GroupMemberRole -> Text -> CmdAction +groupCmdAction role t = case A.parseOnly cmdActionP (encodeUtf8 (T.strip t)) of + Right (tag, r) + | role >= cmdMinRole tag -> either ReplyText RunCmd r + | Right _ <- r, Just refusal <- cmdRefusal tag -> ReplyText refusal + _ -> IgnoreMsg + +-- | Left is the usage reply for an advertised command whose arguments do not parse. +cmdActionP :: A.Parser (CmdTag, Either Text GroupCmd) +cmdActionP = A.choice (map cmdP [minBound .. maxBound]) + where + cmdP tag = (tag,) <$> (A.string ("/" <> encodeUtf8 (cmdName tag)) *> (Right <$> fullArgsP tag <|> Left (usage tag) <$ usageEndP)) + fullArgsP tag = A.char ' ' *> cmdArgsP tag <* A.endOfInput + -- Without the space check "/issued" would get a usage reply. + usageEndP = void A.space <|> A.endOfInput + usage tag = "Usage: /" <> cmdName tag <> " " <> cmdParams tag + +cmdArgsP :: CmdTag -> A.Parser GroupCmd +cmdArgsP = \case + CTIssue -> GCIssue <$> badgeTypeP <*> monthsOpt <*> keyOpt "uses" maxUses + CTBulk -> GCBulk <$> badgeTypeP <*> monthsOpt <*> (A.space *> keyValue "count" maxBulk) + CTRevoke -> GCRevoke <$> codeP + where + monthsOpt = keyOpt "months" maxMonths + keyOpt kw hi = fromMaybe 1 <$> optional (A.space *> keyValue kw hi) + keyValue kw hi = A.string kw *> A.space *> boundedInt kw hi + +groupCommands :: [ChatBotCommand] +groupCommands = map command [minBound .. maxBound] + where + command tag = CBCCommand (cmdName tag) (cmdLabel tag) (Just (cmdParams tag)) + +codeP :: A.Parser BadgeCode +codeP = A.takeWhile1 (not . isSpace) >>= maybe (fail "not a badge code") pure . parseBadgeCode . safeDecodeUtf8 + +-- attoparsec's decimal wraps silently at Int, so the bound is checked on the wider Integer. +boundedInt :: ByteString -> Int -> A.Parser Int +boundedInt kw hi = do + n <- A.decimal :: A.Parser Integer + if n >= 1 && n <= fromIntegral hi + then pure (fromInteger n) + else fail (B.unpack kw <> " out of range") + +-- BadgeType decodes anything to BTUnknown, so a typo would issue an unusable code. +badgeTypeP :: A.Parser BadgeType +badgeTypeP = + textTokenP >>= \case + BTUnknown t -> fail $ "unknown badge type " <> T.unpack t + bt -> pure bt + +textTokenP :: TextEncoding a => A.Parser a +textTokenP = do + t <- A.takeWhile1 (not . isSpace) + maybe (fail "invalid value") pure $ textDecode $ safeDecodeUtf8 t + +-- The advertised menu lists the commands in this order. +data CmdTag = CTIssue | CTBulk | CTRevoke + deriving (Bounded, Enum) + +cmdName :: CmdTag -> Text +cmdName = \case + CTIssue -> "issue" + CTBulk -> "bulk" + CTRevoke -> "revoke" + +cmdLabel :: CmdTag -> Text +cmdLabel = \case + CTIssue -> "Generate a badge code" + CTBulk -> "Generate many single-use codes" + CTRevoke -> "Revoke a code" + +-- | The parameters are quoted in the usage reply and advertised to the group, so the two cannot drift apart. +cmdParams :: CmdTag -> Text +cmdParams = \case + CTIssue -> " [months ] [uses ]" + CTBulk -> " [months ] count " + CTRevoke -> "" + +cmdMinRole :: CmdTag -> GroupMemberRole +cmdMinRole = \case + CTIssue -> GRModerator + CTBulk -> GRModerator + CTRevoke -> GRAdmin + +-- | This is the reply to a well-formed command from a sender who may not run it; Nothing means silence. +cmdRefusal :: CmdTag -> Maybe Text +cmdRefusal = \case + CTIssue -> Nothing + CTBulk -> Nothing + -- The sender has just published a code that stays redeemable, so they must learn it was not revoked. + CTRevoke -> Just "Only admins can revoke codes. This code is now visible to all members." diff --git a/apps/simplex-badge-service/src/BadgeService/Poller.hs b/apps/simplex-badge-service/src/BadgeService/Poller.hs index f066ed5d0c..989faa4396 100644 --- a/apps/simplex-badge-service/src/BadgeService/Poller.hs +++ b/apps/simplex-badge-service/src/BadgeService/Poller.hs @@ -45,7 +45,7 @@ import qualified Data.Set as S import Data.Text (Text) import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime) import Numeric.Natural (Natural) -import Simplex.Chat.PaymentService.Types (InvoiceStatus (..), PaymentProvider) +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) @@ -182,6 +182,7 @@ listPass env now p = pure False Right ListPass {lpMoved, lpSkipped} -> do owners <- mapM (\s -> safelyWith (skipWhat s) SkipUnaccounted (reportSkip env (pProvider p) now s)) lpSkipped + reportStrangers env (pProvider p) now [reason | (SkipStranger, (_, reason)) <- zip owners lpSkipped] settled <- mapM (\m -> safely (settleWhat m) (settleMoved env (pProvider p) now m)) lpMoved pure (all (== SkipStranger) owners && and settled) where @@ -291,13 +292,26 @@ skipOwner PollerEnv {peStore} provider = \case reportSkip :: PollerEnv -> PaymentProvider -> UTCTime -> (Maybe Text, Text) -> IO SkipOwner reportSkip env provider now (ref, reason) = do owner <- skipOwner env provider ref - due <- dueToWarn env now reason - when due $ case owner of - SkipOurs -> logError ("badge poller: an invoice this service sold was not read, so its payment cannot be detected: " <> reason) - SkipUnaccounted -> logError ("badge poller: part of the window was not read, so a payment to any invoice in it cannot be detected: " <> reason) - SkipStranger -> logWarn ("badge poller: the list pass could not read everything: " <> reason) + let raise msg = dueToWarn env now reason >>= (`when` logError (msg <> reason)) + case owner of + SkipOurs -> raise "badge poller: an invoice this service sold was not read, so its payment cannot be detected: " + SkipUnaccounted -> raise "badge poller: part of the window was not read, so a payment to any invoice in it cannot be detected: " + SkipStranger -> pure () pure owner +-- | One limiter key per provider, so a store full of invoices sold elsewhere costs one line an hour, not one per invoice. +reportStrangers :: PollerEnv -> PaymentProvider -> UTCTime -> [Text] -> IO () +reportStrangers env provider now = \case + [] -> pure () + reasons@(example : _) -> do + due <- dueToWarn env now ("stranger skips: " <> tshow provider) + when due $ logWarn ("badge poller: " <> tshow (length reasons) <> " unreadable invoice(s) not created by this service; use a dedicated " <> home <> "; first: " <> example) + where + home = case provider of + PPCrypto -> "BTCPay store" + PPStripe -> "Stripe account" + other -> textEncode other <> " account" + dueToWarn :: PollerEnv -> UTCTime -> Text -> IO Bool dueToWarn PollerEnv {peSkipped} now reason = atomically $ do seen <- readTVar peSkipped diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 02519095de..8eafc16f2e 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -1,6 +1,4 @@ -{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -23,7 +21,10 @@ module BadgeService.Service where import BadgeService.Catalog (StoreProduct (..), defaultCatalog, storeProduct) +import BadgeService.Codes (issueFailedText, issueOneCode, singleUse) import BadgeService.Config (BadgeIssuerKey (..), ServiceConfig (..), readServiceConfig) +import BadgeService.Group (GroupEvent, ensureManagedGroup, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane) +import BadgeService.Group.Command (badgeTypeP, codeP, maxMonths, textTokenP) import BadgeService.Log (logError, logInfo, logWarn) import BadgeService.Options import BadgeService.Poller (newPollerEnv, newReadHints, runPoller) @@ -44,13 +45,11 @@ import qualified Data.Aeson as J import qualified Data.Aeson.KeyMap as KM import qualified Data.Attoparsec.ByteString.Char8 as A 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.Int (Int64) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, maybeToList) +import Data.Maybe (fromMaybe, isJust, maybeToList) import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) import Data.Word (Word32) @@ -59,22 +58,20 @@ import Simplex.Chat.Badges.Code import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) -import Simplex.Chat.Bot (initializeBotAddress', sendMessage) +import Simplex.Chat.Bot (initializeBotAddress') import Simplex.Chat.Bot.Store (withDB, withDB') import Simplex.Chat.Controller 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.Chat.Types (AgentInvId (..), 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.Encoding.String (strEncode) import Simplex.Messaging.Util (catchOwn', raceAny_, safeDecodeUtf8, tshow) import Simplex.Messaging.Version (isCompatible) import System.Directory (getAppUserDataDirectory) @@ -83,7 +80,7 @@ import System.Exit (exitFailure) data ServiceState = ServiceState { serviceCC :: TMVar ChatController, serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object), - chatRedeemQ :: TQueue (Contact, T.Text), + groupEventQ :: TQueue GroupEvent, storeVerifier :: StoreVerifier } @@ -93,8 +90,8 @@ newServiceState :: IO ServiceState newServiceState = do serviceCC <- newEmptyTMVarIO serviceRequestQ <- newTQueueIO - chatRedeemQ <- newTQueueIO - pure ServiceState {serviceCC, serviceRequestQ, chatRedeemQ, storeVerifier = noStoreVerifier} + groupEventQ <- newTQueueIO + pure ServiceState {serviceCC, serviceRequestQ, groupEventQ, storeVerifier = noStoreVerifier} welcomeGetOpts :: IO BadgeServiceOpts welcomeGetOpts = do @@ -145,30 +142,30 @@ badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do serviceCfg <- traverse readConfigOrExit serviceConfigFile key <- requireIssuerKey opts serviceCfg cfg waiters <- newWaiters - let devRedeem = maybe False devChatRedeem serviceCfg - chatHooks = - defaultChatHooks - { preStartHook = Just $ badgePreStartHook opts, - postStartHook = Just $ badgePostStartHook opts devRedeem env, - 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 - raceAny_ $ - [ forever $ + let groupCfg = serviceCfg >>= \ServiceConfig {group} -> group + trackerQ_ = groupEventQ env <$ groupCfg + readEvents cc = + forever $ atomically (readTBQueue $ outputQ cc) >>= \case (_, Right (CEvtServiceRequest u reqId sigKey reqData)) -> atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) - (_, Right CEvtNewChatItems {chatItems = AChatItem _ SMDRcv (DirectChat ct) ChatItem {content = mc@CIRcvMsgContent {}} : _}) - | devRedeem -> atomically $ writeTQueue (chatRedeemQ env) (ct, ciContentToText mc) - _ -> pure (), - processQueuedRequests key requestEnv - ] - <> [processChatRedeems key env | devRedeem] - <> lanes + (_, Right ev) + | isJust groupCfg -> forM_ (groupEvent ev) $ atomically . writeTQueue (groupEventQ env) + _ -> pure () + startLanes cc = do + lanes <- maybe (pure []) (serviceLanes waiters cc) serviceCfg + -- The group is resolved before the other lanes start, so a failed lookup exits at once rather than waiting for them to end. + groupLane_ <- forM groupCfg $ \gc -> runGroupLane cc (groupEventQ env) <$> ensureManagedGroup cc gc + raceAny_ $ processQueuedRequests key trackerQ_ requestEnv : maybeToList groupLane_ <> lanes + chatHooks = + defaultChatHooks + { preStartHook = Just $ badgePreStartHook opts, + postStartHook = Just $ badgePostStartHook opts env, + preCmdHook = Just badgeCmdHook + } + -- The reader runs from the start and must not block, since outputQ carries every chat event and a full queue stalls the core. + simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> raceAny_ [readEvents cc, startLanes cc] where serviceLanes :: Waiters -> ChatController -> ServiceConfig -> IO [IO ()] serviceLanes ws ChatController {chatStore} sc = do @@ -205,13 +202,13 @@ badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, - postStartHook = Just $ badgePostStartHook opts False env, + postStartHook = Just $ badgePostStartHook opts env, preCmdHook = Just badgeCmdHook, eventHook = Just eventHook } raceAny_ [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, - processQueuedRequests key env + processQueuedRequests key Nothing env ] -- | The terminal prints each request, and requests carry codes and store receipts, which are bearer secrets. @@ -229,26 +226,16 @@ runBadgeCmd :: ChatController -> ByteString -> IO (Either ChatError ChatResponse runBadgeCmd cc cmd | Right issueOpts <- A.parseOnly issueCmdP cmd = issueBadgeCode cc issueOpts >>= \case - Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code} - Left e -> pure $ chatCmdError $ "issuing code: " <> e + Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "Code: " <> formatBadgeCode code} + Left _ -> pure $ chatCmdError (T.unpack issueFailedText) | Right code <- A.parseOnly revokeCmdP cmd = - revokeBadgeCode cc code >>= \case - Right Revoked -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "revoked"} - Right AlreadyRevoked -> pure $ chatCmdError "code was revoked already" - Right AlreadyRedeemed -> pure $ chatCmdError "code was redeemed already, so it cannot be revoked" - Right NoSuchCode -> pure $ chatCmdError "no such code" - Left e -> pure $ chatCmdError $ "revoking code: " <> e - | otherwise = pure $ chatCmdError "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], or //revoke " + revokeWithTracker cc code <&> \case + Right response -> Right CRCustomChatResponse {user_ = Nothing, response} + Left e -> chatCmdError (T.unpack e) + | otherwise = pure $ chatCmdError $ "Usage: //issue supporter|legend|investor [months 1-" <> show maxMonths <> "] [paid|unpaid|free], or //revoke " revokeCmdP :: A.Parser BadgeCode -revokeCmdP = - "revoke " *> (A.takeWhile1 (not . isSpace) >>= maybe (fail "not a badge code") pure . parseBadgeCode . safeDecodeUtf8) - <* (A.skipSpace *> A.endOfInput) - -revokeBadgeCode :: ChatController -> BadgeCode -> IO (Either String RevokeResult) -revokeBadgeCode cc code = do - now <- truncateToSecond <$> getCurrentTime - withDB' "revokeBadgeCode" cc $ \db -> revokeCode db (badgeCodeHash code) now +revokeCmdP = "revoke " *> codeP <* (A.skipSpace *> A.endOfInput) issueCmdP :: A.Parser IssueCodeOpts issueCmdP = @@ -264,17 +251,8 @@ issueCmdP = where -- Integer, because attoparsec's decimal wraps silently at Int, so the guard would check a truncated count. checkMonths n - | n >= 1 && n <= 255 = pure (fromInteger n) - | otherwise = fail "months must be between 1 and 255" - -- BadgeType decodes anything to BTUnknown, so a typo would issue an unusable code - badgeTypeP = - textTokenP >>= \case - BTUnknown t -> fail $ "unknown badge type " <> T.unpack t - bt -> pure bt - textTokenP :: TextEncoding a => A.Parser a - textTokenP = do - t <- A.takeWhile1 (not . isSpace) - maybe (fail "invalid value") pure $ textDecode $ safeDecodeUtf8 t + | n >= 1 && n <= fromIntegral maxMonths = pure (fromInteger n) + | otherwise = fail $ "months must be between 1 and " <> show maxMonths data IssueCodeOpts = IssueCodeOpts { badgeType :: BadgeType, @@ -282,66 +260,43 @@ data IssueCodeOpts = IssueCodeOpts paymentStatus :: BadgeCodePaymentStatus } --- | The caller sees the code once; only its hash is stored, so a lost code cannot be recovered. issueBadgeCode :: ChatController -> IssueCodeOpts -> IO (Either String BadgeCode) -issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = do - code <- randomBadgeCode $ random cc - now <- getCurrentTime - r <- withDB' "issueBadgeCode" cc $ \db -> insertBadgeCode db (badgeCodeHash code) badgeType months paymentStatus now - pure $ code <$ r +issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = + fmap fst <$> issueOneCode cc badgeType months paymentStatus singleUse -processQueuedRequests :: BadgeIssuerKey -> ServiceState -> IO () -processQueuedRequests key env = do +processQueuedRequests :: BadgeIssuerKey -> Maybe (TQueue GroupEvent) -> ServiceState -> IO () +processQueuedRequests key trackerQ_ env = do cc <- atomically $ readTMVar $ serviceCC env forever $ do (u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env - 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 - survive "a chat redemption" $ chatRedeem key cc ct msg + survive ("request " <> safeDecodeUtf8 (strEncode reqId)) $ handleServiceRequest key (storeVerifier env) cc trackerQ_ u reqId sigKey reqData -- | 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 () -chatRedeem key cc ct msg = case T.stripPrefix "/redeem" (T.strip msg) of - Just rest | not (T.null (T.strip rest)) -> do - masterKey <- generateMasterKey (random cc) - (purchaseKey, _) <- atomically $ C.generateKeyPair (random cc) :: IO (C.KeyPair 'C.Ed25519) - resp <- redeemCode key cc purchaseKey masterKey (T.strip rest) - sendMessage cc ct $ case resp of - BSPBadgeCredential {credential = Just cred} -> safeDecodeUtf8 $ LB.toStrict $ J.encode cred - BSPError {code} -> "error: " <> textEncode code - _ -> "unexpected response" - _ -> sendMessage cc ct "send: /redeem " - badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO () badgePreStartHook opts ChatController {config, chatStore} = runBadgeServiceMigrations opts config chatStore -badgePostStartHook :: BadgeServiceOpts -> Bool -> ServiceState -> ChatController -> IO () -badgePostStartHook BadgeServiceOpts {noAddress, testing} devRedeem env cc = do +badgePostStartHook :: BadgeServiceOpts -> ServiceState -> ChatController -> IO () +badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do -- Core starts this False and gates service request delivery on it, so the hook must set it. atomically $ writeTVar (processServiceRequests cc) True readTVarIO (currentUser cc) >>= \case Nothing -> putStrLn "No current user" >> exitFailure Just _ -> do - unless noAddress $ initializeBotAddress' (not testing) (Just True) devRedeem cc + -- The address carries service RPC only, so contact requests are never auto-accepted. + unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc void $ atomically $ tryPutTMVar (serviceCC env) cc -handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () -handleServiceRequest key verifier cc User {userId} reqId sigKey reqData = do +handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () +handleServiceRequest key verifier cc trackerQ_ User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) logInfo $ "badge service request " <> reqIdT resp <- - badgeServiceResponse key verifier cc sigKey reqData `catchOwn'` \_ -> + badgeServiceResponse key verifier cc trackerQ_ sigKey reqData `catchOwn'` \_ -> logError ("badge service request " <> reqIdT <> " failed") $> errorResponse BSEInternal sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () @@ -364,15 +319,15 @@ 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 -> StoreVerifier -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse -badgeServiceResponse key verifier cc sigKey reqData = case J.fromJSON (J.Object reqData) of +badgeServiceResponse :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse +badgeServiceResponse key verifier cc trackerQ_ 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 | purchaseKey /= sigKey -> pure $ errorResponse BSEBadRequest | otherwise -> case request of BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of - Just k -> redeemCode key cc k masterKey code + Just k -> redeemCode key cc trackerQ_ k masterKey code Nothing -> pure $ errorResponse BSEBadRequest BSCPurchaseBadge {masterKey, payment, upgrade} | Just receipt_ <- toStoreReceipt verifier payment -> case (purchaseKey, upgrade) of @@ -412,42 +367,51 @@ credentialResponse credential previousEntryId entries = BSPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}} -- | Nothing is written until the credential is signed, so a signing failure leaves the code unspent. -redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse -redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText of +redeemCode :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse +redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode codeText of Nothing -> pure $ errorResponse BSECodeInvalid Just code -> do now <- badgeNow cc withDB "getBadgeCode" cc (readCode now code) >>= \case Left _ -> pure $ errorResponse BSEInternal Right (Left resp) -> pure resp - Right (Right IssuedCode {badgeCodeId, badgeType, months}) -> + Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> 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. + -- If the code was revoked or used up 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 + Left resp -> pure (resp, False) + Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, False) + Just purchaseId -> liftIO $ (,True) <$> firstMonthResponse db purchaseId Nothing firstMonth + let (resp, claimed) = fromRight (errorResponse BSEInternal, False) r + when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code + pure resp where readCode now code db = liftIO $ getBadgeCode db (badgeCodeHash code) >>= \case Nothing -> pure $ Left $ errorResponse BSECodeInvalid - Just c@IssuedCode {revokedAt, paymentStatus, expiresAt, redemption} - -- Revoked is checked first, so it answers as if the code never existed. - | Just _ <- revokedAt -> pure $ Left $ errorResponse BSECodeInvalid - -- Redeeming an unpaid code would issue a free badge, so unpaid is refused. - | CPSUnpaid <- paymentStatus -> pure $ Left $ errorResponse BSEPaymentPending - | otherwise -> - creditedResponse db BSECodeUsed purchaseKey redemption >>= \case - Left resp -> pure $ Left resp - Right () - | maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired - | otherwise -> pure $ Right c + Just c@IssuedCode {badgeCodeId, revokedAt, paymentStatus, expiresAt, redeemLimit, redeemCount} -> + getCodePurchaseForKey db badgeCodeId purchaseKey >>= \case + -- A key that already redeemed gets its credential back without a use, even if the code has since + -- expired or been revoked: a client whose reply was lost retries, and would otherwise lose the badge. + KeyRedeemed KeyPurchase {badgePurchaseId, credential} -> + maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) + <$> getLedgerEntries db badgePurchaseId 0 + -- code_used would make the client drop its keys, so the holder could never get the badge back. + KeyRedeemedUnreadable -> + logError "badge service: a redeemed code's credential is missing or unreadable" $> Left (errorResponse BSEInternal) + KeyUnredeemed + -- Revoked is checked first, so it answers as if the code never existed. + | Just _ <- revokedAt -> pure $ Left $ errorResponse BSECodeInvalid + -- Redeeming an unpaid code would issue a free badge, so unpaid is refused. + | CPSUnpaid <- paymentStatus -> pure $ Left $ errorResponse BSEPaymentPending + | redeemCount >= redeemLimit -> pure $ Left $ errorResponse BSECodeUsed + | maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired + | otherwise -> pure $ Right c -- | Every refusal is answered before anything is written, so it leaves the receipt unclaimed; and -- nothing is written until the credential is signed. @@ -476,7 +440,7 @@ purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTran Credited CreditedPurchase {purchaseKey = k} -> k == purchaseKey _ -> False answerCredit credit = - withDB' "answerStoreCredit" cc (\db -> creditedResponse db BSEReceiptUsed purchaseKey credit) <&> \case + withDB' "answerStoreCredit" cc (\db -> creditedResponse db 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 @@ -492,7 +456,7 @@ purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTran 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 + liftIO (getStorePaymentCredit db provider providerRef >>= creditedResponse db 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 @@ -507,12 +471,12 @@ storeRefusalResponse = \case 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 +creditedResponse :: DB.Connection -> C.PublicKeyEd25519 -> FundingCredit -> IO (Either BadgeServiceResponse ()) +creditedResponse db purchaseKey = \case Uncredited -> pure $ Right () CreditedUnreadable -> pure $ Left $ errorResponse BSEInternal Credited CreditedPurchase {purchaseKey = k, badgePurchaseId, credential} - | k /= purchaseKey -> pure $ Left $ errorResponse usedCode + | k /= purchaseKey -> pure $ Left $ errorResponse BSEReceiptUsed | otherwise -> maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) <$> getLedgerEntries db badgePurchaseId 0 diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index f81fbe4fe8..6d238e78f4 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -8,12 +8,20 @@ module BadgeService.Store ( IssuedCode (..), + KeyRedemption (..), + KeyPurchase (..), FundingCredit (..), CreditedPurchase (..), NewCodePurchase (..), NewStorePurchase (..), ServicePurchase (..), + ManagedGroup (..), + getManagedGroup, + insertManagedGroup, + clearCodeGroupItems, + markOwnerBootstrapped, getBadgeCode, + getCodePurchaseForKey, getStorePaymentCredit, purchaseKeyExists, getPurchaseByKey, @@ -25,6 +33,10 @@ module BadgeService.Store createCodePurchase, createStorePurchase, insertBadgeCode, + setCodeGroupItem, + CodeTracker (..), + getCodeTracker, + getEditableTrackers, RevokeResult (..), revokeCode, ) @@ -35,6 +47,7 @@ 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) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType) @@ -43,7 +56,7 @@ import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementEntry (.. 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 Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (textEncode) @@ -64,7 +77,18 @@ data IssuedCode = IssuedCode paymentStatus :: BadgeCodePaymentStatus, revokedAt :: Maybe UTCTime, expiresAt :: Maybe UTCTime, - redemption :: FundingCredit + redeemLimit :: Int, + redeemCount :: Int + } + +data KeyRedemption + = KeyUnredeemed + | KeyRedeemed KeyPurchase + | KeyRedeemedUnreadable + +data KeyPurchase = KeyPurchase + { badgePurchaseId :: Int64, + credential :: BadgeCredential } data FundingCredit @@ -101,29 +125,85 @@ data ServicePurchase = ServicePurchase badgeType :: BadgeType } +data ManagedGroup = ManagedGroup + { mgGroupId :: Int64, + mgGroupLink :: Text, + mgOwnerBootstrapped :: Bool + } + deriving (Eq) + +-- The join link is a bearer secret, so it is left out. +instance Show ManagedGroup where + show ManagedGroup {mgGroupId, mgOwnerBootstrapped} = "managed group " <> show mgGroupId <> ", owner set up: " <> show mgOwnerBootstrapped + +getManagedGroup :: DB.Connection -> IO (Maybe ManagedGroup) +getManagedGroup db = + maybeFirstRow toGroup $ + DB.query_ db "SELECT group_id, group_link, owner_bootstrapped FROM sx_badge_service_group LIMIT 1" + where + toGroup (mgGroupId, mgGroupLink, BI mgOwnerBootstrapped) = ManagedGroup {mgGroupId, mgGroupLink, mgOwnerBootstrapped} + +-- getManagedGroup reads with no ordering, so a second row would change which group is used. +insertManagedGroup :: DB.Connection -> Int64 -> Text -> UTCTime -> IO () +insertManagedGroup db gid link now = + DB.execute + db + [sql| + INSERT INTO sx_badge_service_group (group_id, group_link, owner_bootstrapped, created_at) + SELECT ?,?,0,? WHERE NOT EXISTS (SELECT 1 FROM sx_badge_service_group) + |] + (gid, link, now) + +-- A tracker's item id is only found in the group it was posted to, so a new group starts with none. +clearCodeGroupItems :: DB.Connection -> IO () +clearCodeGroupItems db = + DB.execute_ db "UPDATE sx_badge_service_badge_codes SET group_item_id = NULL, group_item_sent_at = NULL WHERE group_item_id IS NOT NULL" + +markOwnerBootstrapped :: DB.Connection -> Int64 -> IO Bool +markOwnerBootstrapped db gid = + (> 0) + <$> executeChanging + db + "UPDATE sx_badge_service_group SET owner_bootstrapped = 1 WHERE group_id = ? AND owner_bootstrapped = 0" + (Only gid) + getBadgeCode :: DB.Connection -> ByteString -> IO (Maybe IssuedCode) getBadgeCode db codeHash = maybeFirstRow toCode $ DB.query db [sql| - SELECT c.badge_code_id, c.badge_type, c.months, c.code_payment_status, c.revoked_at, - c.expires_at, p.badge_purchase_id, p.purchase_key, i.credential - FROM sx_badge_service_badge_codes c - LEFT JOIN sx_badge_service_badge_purchases p ON p.badge_code_id = c.badge_code_id - LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id - WHERE c.code_hash = ? - ORDER BY i.period_end DESC - LIMIT 1 + SELECT badge_code_id, badge_type, months, code_payment_status, revoked_at, expires_at, redeem_limit, redeem_count + FROM sx_badge_service_badge_codes + WHERE code_hash = ? |] (Only (Binary codeHash)) where - toCode (badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, purchaseId_, purchaseKey_, credential_) = - IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redemption = fundingCredit purchaseId_ purchaseKey_ credential_} + toCode (badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redeemLimit, redeemCount) = + IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redeemLimit, redeemCount} + +getCodePurchaseForKey :: DB.Connection -> Int64 -> C.PublicKeyEd25519 -> IO KeyRedemption +getCodePurchaseForKey db badgeCodeId key = + maybeFirstRow' KeyUnredeemed toRedemption $ + DB.query + db + [sql| + SELECT p.badge_purchase_id, i.credential + FROM sx_badge_service_badge_purchases p + LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id + WHERE p.badge_code_id = ? AND p.purchase_key = ? + ORDER BY i.period_end DESC + LIMIT 1 + |] + (badgeCodeId, key) + where + toRedemption (badgePurchaseId, credential_) = case decodeCredential =<< credential_ of + Just credential -> KeyRedeemed KeyPurchase {badgePurchaseId, credential} + Nothing -> KeyRedeemedUnreadable getStorePaymentCredit :: DB.Connection -> PaymentProvider -> Text -> IO FundingCredit getStorePaymentCredit db provider providerRef = - maybeFirstRow' Uncredited (\(purchaseId, purchaseKey, credential_) -> fundingCredit (Just purchaseId) (Just purchaseKey) credential_) $ + maybeFirstRow' Uncredited toCredit $ DB.query db [sql| @@ -136,15 +216,13 @@ getStorePaymentCredit db provider providerRef = 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 + toCredit (badgePurchaseId, purchaseKey, credential_) = case decodeCredential =<< credential_ of + Just credential -> Credited CreditedPurchase {badgePurchaseId, purchaseKey, credential} + Nothing -> CreditedUnreadable + +decodeCredential :: Binary ByteString -> Maybe BadgeCredential +decodeCredential (Binary bs) = J.decodeStrict' bs purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool purchaseKeyExists db key = @@ -263,15 +341,14 @@ appendLedgerPlan db purchaseId creditPaymentId_ rows issuance_ = do ((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. --- Mark the code as redeemed before adding the purchase. On Postgres, a revoke or redemption running --- at the same time then waits, sees the code is taken, and fails. +-- The claim takes one use before adding the purchase, so a concurrent revoke or redemption waits on this row and sees the new count. +-- Run it in the credential's transaction. createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO (Maybe Int64) createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do claimed <- executeChanging db - "UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ? AND redeemed_at IS NULL AND revoked_at IS NULL" + "UPDATE sx_badge_service_badge_codes SET redeem_count = redeem_count + 1, redeemed_at = ? WHERE badge_code_id = ? AND redeem_count < redeem_limit AND revoked_at IS NULL" (now, badgeCodeId) if claimed == 0 then pure Nothing @@ -312,32 +389,79 @@ createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, paymentId, now, now) Just <$> insertedRowId db -data RevokeResult = Revoked | AlreadyRevoked | AlreadyRedeemed | NoSuchCode +-- | Revoked and AlreadyRevoked carry the code id, so the caller can retire the code's group tracker, +-- or repair one an earlier revoke left live. +data RevokeResult = Revoked Int64 | AlreadyRevoked Int64 | AlreadyRedeemed | NoSuchCode deriving (Eq, Show) --- | A code that was already redeemed can't be revoked, because its badge was already given out. +-- | A code with no uses left can't be revoked, because every badge it grants was already given out. revokeCode :: DB.Connection -> ByteString -> UTCTime -> IO RevokeResult revokeCode db codeHash now = do revoked <- executeChanging db - "UPDATE sx_badge_service_badge_codes SET revoked_at = ? WHERE code_hash = ? AND revoked_at IS NULL AND redeemed_at IS NULL" + "UPDATE sx_badge_service_badge_codes SET revoked_at = ? WHERE code_hash = ? AND revoked_at IS NULL AND redeem_count < redeem_limit" (now, Binary codeHash) - if revoked > 0 - then pure Revoked - else - maybeFirstRow' NoSuchCode refusal $ - DB.query db "SELECT revoked_at FROM sx_badge_service_badge_codes WHERE code_hash = ?" (Only (Binary codeHash)) + -- The result is read in the same transaction as the UPDATE, so the row it answers about is the row that changed. + maybeFirstRow' NoSuchCode (result revoked) $ + DB.query db "SELECT badge_code_id, revoked_at FROM sx_badge_service_badge_codes WHERE code_hash = ?" (Only (Binary codeHash)) where - refusal :: Only (Maybe UTCTime) -> RevokeResult - refusal (Only revokedAt) = maybe AlreadyRedeemed (const AlreadyRevoked) revokedAt + result :: Int -> (Int64, Maybe UTCTime) -> RevokeResult + result revoked (badgeCodeId, revokedAt) + | revoked > 0 = Revoked badgeCodeId + | isJust revokedAt = AlreadyRevoked badgeCodeId + | otherwise = AlreadyRedeemed -insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO () -insertBadgeCode db codeHash badgeType months paymentStatus now = +insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> Int -> UTCTime -> IO Int64 +insertBadgeCode db codeHash badgeType months paymentStatus redeemLimit now = do DB.execute db [sql| - INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) - VALUES (?,?,?,?,?) + INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, redeem_limit, created_at) + VALUES (?,?,?,?,?,?) |] - (Binary codeHash, badgeType, months, paymentStatus, now) + (Binary codeHash, badgeType, months, paymentStatus, redeemLimit, now) + insertedRowId db + +setCodeGroupItem :: DB.Connection -> Int64 -> Int64 -> UTCTime -> IO () +setCodeGroupItem db badgeCodeId itemId sentAt = + DB.execute + db + "UPDATE sx_badge_service_badge_codes SET group_item_id = ?, group_item_sent_at = ? WHERE badge_code_id = ?" + (itemId, sentAt, badgeCodeId) + +data CodeTracker = CodeTracker + { trackerItemId :: Int64, + trackerSentAt :: UTCTime, + redeemLimit :: Int, + redeemCount :: Int, + revokedAt :: Maybe UTCTime, + redeemedAt :: Maybe UTCTime + } + +getCodeTracker :: DB.Connection -> Int64 -> IO (Maybe CodeTracker) +getCodeTracker db badgeCodeId = + maybeFirstRow toTracker $ + DB.query + db + [sql| + SELECT group_item_id, group_item_sent_at, redeem_limit, redeem_count, revoked_at, redeemed_at + FROM sx_badge_service_badge_codes + WHERE badge_code_id = ? AND group_item_id IS NOT NULL AND group_item_sent_at IS NOT NULL + |] + (Only badgeCodeId) + where + toTracker (trackerItemId, trackerSentAt, redeemLimit, redeemCount, revokedAt, redeemedAt) = + CodeTracker {trackerItemId, trackerSentAt, redeemLimit, redeemCount, revokedAt, redeemedAt} + +getEditableTrackers :: DB.Connection -> UTCTime -> IO [(Int64, Int64)] +getEditableTrackers db sentAfter = + DB.query + db + [sql| + SELECT badge_code_id, group_item_id + FROM sx_badge_service_badge_codes + WHERE group_item_id IS NOT NULL AND group_item_sent_at > ? + ORDER BY badge_code_id + |] + (Only sentAfter) 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 a58df6d02b..92d3b5fd47 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -18,6 +18,7 @@ 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), + ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops), ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] @@ -110,6 +111,48 @@ DROP INDEX @idx_badge_purchases_code; DROP TABLE @badge_codes; |] +m20260918_badge_group_ops :: Text +m20260918_badge_group_ops = + withPrefix + servicePrefix + [r| +CREATE TABLE @group( + group_id BIGINT NOT NULL PRIMARY KEY, + group_link TEXT NOT NULL, + owner_bootstrapped SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL +); + +ALTER TABLE @badge_codes ADD COLUMN redeem_limit INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE @badge_codes ADD COLUMN redeem_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE @badge_codes ADD COLUMN group_item_id BIGINT; + +ALTER TABLE @badge_codes ADD COLUMN group_item_sent_at TIMESTAMPTZ; + +-- Redemptions made before this migration must count against the new limit, or every code +-- redeemed already would read as unspent and could be redeemed once more. +UPDATE @badge_codes SET redeem_count = 1 WHERE redeemed_at IS NOT NULL; + +DROP INDEX @idx_badge_purchases_code; + +CREATE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id); +|] + +-- The index stays non-unique, since a multi-use code may already have several purchases. +down_m20260918_badge_group_ops :: Text +down_m20260918_badge_group_ops = + withPrefix + servicePrefix + [r| +ALTER TABLE @badge_codes DROP COLUMN group_item_sent_at; +ALTER TABLE @badge_codes DROP COLUMN group_item_id; +ALTER TABLE @badge_codes DROP COLUMN redeem_count; +ALTER TABLE @badge_codes DROP COLUMN redeem_limit; +DROP TABLE @group; +|] + m20260925_badge_store_receipts :: Text m20260925_badge_store_receipts = withPrefix 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 fed5fd5ed0..cd3d97f0ac 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -19,6 +19,7 @@ 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), + ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops), ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] @@ -111,6 +112,48 @@ DROP INDEX @idx_badge_purchases_code; DROP TABLE @badge_codes; |] +m20260918_badge_group_ops :: Query +m20260918_badge_group_ops = + withPrefix + servicePrefix + [sql| +CREATE TABLE @group( + group_id INTEGER NOT NULL PRIMARY KEY, + group_link TEXT NOT NULL, + owner_bootstrapped INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +) STRICT; + +ALTER TABLE @badge_codes ADD COLUMN redeem_limit INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE @badge_codes ADD COLUMN redeem_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE @badge_codes ADD COLUMN group_item_id INTEGER; + +ALTER TABLE @badge_codes ADD COLUMN group_item_sent_at TEXT; + +-- Redemptions made before this migration must count against the new limit, or every code +-- redeemed already would read as unspent and could be redeemed once more. +UPDATE @badge_codes SET redeem_count = 1 WHERE redeemed_at IS NOT NULL; + +DROP INDEX @idx_badge_purchases_code; + +CREATE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id); +|] + +-- The index stays non-unique, since a multi-use code may already have several purchases. +down_m20260918_badge_group_ops :: Query +down_m20260918_badge_group_ops = + withPrefix + servicePrefix + [sql| +ALTER TABLE @badge_codes DROP COLUMN group_item_sent_at; +ALTER TABLE @badge_codes DROP COLUMN group_item_id; +ALTER TABLE @badge_codes DROP COLUMN redeem_count; +ALTER TABLE @badge_codes DROP COLUMN redeem_limit; +DROP TABLE @group; +|] + m20260925_badge_store_receipts :: Query m20260925_badge_store_receipts = withPrefix diff --git a/apps/simplex-calculator-bot/.gitignore b/apps/simplex-calculator-bot/.gitignore new file mode 100644 index 0000000000..902b28133e --- /dev/null +++ b/apps/simplex-calculator-bot/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +data/ diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md new file mode 100644 index 0000000000..028e470964 --- /dev/null +++ b/apps/simplex-calculator-bot/README.md @@ -0,0 +1,36 @@ +# SimpleX Calculator + +A calculator bot built with the [SimpleX Chat Node.js library](../../packages/simplex-chat-nodejs/). + +Each user who connects via the bot's business address receives a calculator message with keys as commands. The bot replaces this message after each input. + +- `×` and `÷` are applied before `+` and `-`, so `2 + 3 × 4 =` equals 14. +- `%` is computed as in Apple's calculator: `100 + 15 % =` equals 115. +- Tap `C` to clear the number, and `C` again to clear the calculation. +- Numbers are shown without exponent, up to 15 digits; larger results are shown as `Error`. +- You can also send numbers and keys like `+`, `=`, `add` or `c`. Expressions like `(2 + 3) × 4` are entered as one number, and the bot replies to them with the result. +- The bot deletes keys and numbers, tapped or sent, and logs each computed operation, such as `15 + 10 = 25`. +- After 10 minutes without input, the bot turns the calculator off and discards the calculation; tap any key to turn it on. +- The bot sends keys like `/+` to apps with chat protocol version 21 or later, and keys like `/add` to older apps. + +## Run + +```bash +npm install +npm run build +npm start +``` + +The bot prints its address and keeps its data in `./data`. + +To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`. + +To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`. + +## Test + +```bash +npm test +``` + +A local SMP server is started for the end-to-end test. On Linux, `smp-server` is downloaded from [simplexmq releases](https://github.com/simplex-chat/simplexmq/releases); to use another build, or on other systems, set `SMP_SERVER` to the path of `smp-server`. diff --git a/apps/simplex-calculator-bot/calculator.test.ts b/apps/simplex-calculator-bot/calculator.test.ts new file mode 100644 index 0000000000..e7662666c4 --- /dev/null +++ b/apps/simplex-calculator-bot/calculator.test.ts @@ -0,0 +1,202 @@ +import {describe, test, expect} from "vitest" +import {Calc, Key, calculatorText, initialCalc, press, textInput} from "./src/calculator.js" + +type Input = {tap: string} | {text: string} + +function run(inputs: Input[]): {calc: Calc, logLines: string[]} { + const logLines: string[] = [] + const calc = inputs.reduce((current, input) => { + const [next, logLine] = "tap" in input ? press(current, input.tap as Key) : textInput(input.text)!.update(current) + if (logLine) logLines.push(logLine) + return next + }, initialCalc) + return {calc, logLines} +} + +const tap = (keys: string) => run(keys.split(" ").map(key => ({tap: key}))) +const type = (...texts: string[]) => run(texts.map(text => ({text}))) +const display = (keys: string) => tap(keys).calc.display + +describe("keys", () => { + test("enter numbers", () => { + expect(display("1 2 . 5")).toBe("12.5") + expect(display("0 0 7")).toBe("7") + expect(display(". 5")).toBe("0.5") + expect(display("1 . . 5")).toBe("1.5") + }) + + test("compute × and ÷ before + and -", () => { + expect(tap("2 + 2 =")).toEqual({calc: expect.objectContaining({display: "4"}), logLines: ["2 + 2 = 4"]}) + expect(tap("2 + 3 × 4 =").logLines).toEqual(["3 × 4 = 12\n2 + 12 = 14"]) + expect(tap("2 × 3 + 4 =").logLines).toEqual(["2 × 3 = 6", "6 + 4 = 10"]) + expect(display("1 0 - 2 × 3 =")).toBe("4") + expect(display("1 0 - 2 - 3 =")).toBe("5") + expect(display("8 ÷ 4 ÷ 2 =")).toBe("1") + }) + + test("show the operand of the pressed operator", () => { + expect(display("2 + 3 ×")).toBe("3") + expect(display("2 × 3 +")).toBe("6") + expect(display("2 + 3 × 4 ×")).toBe("12") + expect(display("2 + 3 × 4 +")).toBe("14") + }) + + test("log each computed operation", () => { + expect(tap("1 5 + 1 0 + 5 + 1 =").logLines).toEqual(["15 + 10 = 25", "25 + 5 = 30", "30 + 1 = 31"]) + expect(tap("2 + 3 × 4 × 5 +").logLines).toEqual(["3 × 4 = 12", "12 × 5 = 60\n2 + 60 = 62"]) + expect(tap("2 + 3 × 4").logLines).toEqual([]) + expect(tap("5 =").logLines).toEqual([]) + }) + + test("replace operator", () => { + expect(tap("2 + × 3 =").logLines).toEqual(["2 × 3 = 6"]) + expect(tap("2 + 3 × + 4 =").logLines).toEqual(["2 + 3 = 5", "5 + 4 = 9"]) + expect(tap("2 × 3 + × 4 =").logLines).toEqual(["2 × 3 = 6", "6 × 4 = 24"]) + }) + + test("continue from result", () => { + expect(tap("2 + 2 = + 3 =").logLines).toEqual(["2 + 2 = 4", "4 + 3 = 7"]) + expect(display("2 + 2 = 5")).toBe("5") + }) + + test("percent", () => { + expect(display("1 0 0 + 1 5 %")).toBe("15") + expect(tap("1 0 0 + 1 5 % =").logLines).toEqual(["100 + 15% = 115"]) + expect(display("1 0 0 - 1 5 %")).toBe("15") + expect(display("1 0 0 - 1 5 % =")).toBe("85") + expect(display("5 0 × 1 0 %")).toBe("0.1") + expect(display("5 0 × 1 0 % =")).toBe("5") + expect(display("5 0 ÷ 1 0 %")).toBe("0.1") + expect(display("5 0 ÷ 1 0 % =")).toBe("500") + expect(display("1 5 %")).toBe("0.15") + expect(display("1 0 0 + 2 × 1 5 %")).toBe("0.15") + expect(tap("1 0 0 + 2 × 1 5 % =").logLines).toEqual(["2 × 15% = 0.3\n100 + 0.3 = 100.3"]) + expect(display("2 × 3 + 1 0 %")).toBe("0.6") + }) + + test("square root and sign", () => { + expect(display("9 √")).toBe("3") + expect(tap("1 + 9 √ =").logLines).toEqual(["1 + √9 = 4"]) + expect(display("5 ±")).toBe("-5") + expect(display("5 ± ±")).toBe("5") + expect(display("± 5")).toBe("-5") + expect(display("2 + 2 = ±")).toBe("-4") + }) + + test("clear entry, then clear all", () => { + expect(tap("2 + 3 C 4 =").logLines).toEqual(["2 + 4 = 6"]) + expect(tap("2 + 3 C C").calc).toEqual(initialCalc) + }) + + test("display without exponent", () => { + expect(display("0 . 1 + 0 . 2 =")).toBe("0.3") + expect(display("1 ÷ 3 =")).toBe("0.33333333333333") + expect(display("1 ÷ 1 0 0 0 0 0 0 0 =")).toBe("0.0000001") + expect(display("1 ÷ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 =")).toBe("0.00000000000001") + expect(display(". 0 0 0 0 0 0 0 1 × . 0 0 0 0 0 0 0 1 =")).toBe("0") + }) + + test("limit digits and overflow", () => { + expect(display("1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7")).toBe("123456789012345") + expect(display("9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 + 1 =")).toBe("Error") + }) + + test("errors", () => { + expect(display("1 ÷ 0 =")).toBe("Error") + expect(display("2 ± √")).toBe("Error") + expect(display("1 ÷ 0 = 5")).toBe("5") + expect(display("1 ÷ 0 + 2 =")).toBe("Error") + expect(tap("1 ÷ 0 = C").calc).toEqual(initialCalc) + }) +}) + +describe("typed messages", () => { + test("numbers and keys", () => { + expect(type("25", "+", "25", "=")).toEqual({calc: expect.objectContaining({display: "50"}), logLines: ["25 + 25 = 50"]}) + expect(type("25", "add", "25", "eq").logLines).toEqual(["25 + 25 = 50"]) + expect(type("6", "x", "7", "=").logLines).toEqual(["6 × 7 = 42"]) + expect(type("5", "*", "5", "/", "2", "=").logLines).toEqual(["5 × 5 = 25", "25 ÷ 2 = 12.5"]) + expect(type("25", "+", "3", "c", "4", "=").logLines).toEqual(["25 + 4 = 29"]) + }) + + test("mixed with taps", () => { + expect(run([{text: "25"}, {tap: "+"}, {text: "25"}, {tap: "="}]).logLines).toEqual(["25 + 25 = 50"]) + }) + + test("expressions are entered as a number", () => { + const result = (text: string) => type(text).calc.display + expect(result("2 + 2")).toBe("4") + expect(result("12 × 3 + 4")).toBe("40") + expect(result("12 * 3 + 4 =")).toBe("40") + expect(result("8 / 2")).toBe("4") + expect(result("3x3")).toBe("9") + expect(result("-5")).toBe("-5") + expect(result("2 × -3")).toBe("-6") + expect(result("100 + 15%")).toBe("115") + expect(result("42")).toBe("42") + expect(result("2 + 3 × 4")).toBe("14") + expect(result("(2 + 3) × 4")).toBe("20") + expect(result("2 × (3 + 4)")).toBe("14") + expect(result("((1 + 2) × 3)")).toBe("9") + expect(result("-(2 + 3)")).toBe("-5") + expect(result("100 + (10 + 5)%")).toBe("115") + expect(type("10", "×", "2 + 3", "=").logLines).toEqual(["10 × 5 = 50"]) + expect(type("10", "+", "25=").calc.display).toBe("35") + }) + + test("result only for expressions", () => { + const result = (text: string) => textInput(text)?.result + expect(result("(2 + 3) × 4")).toBe("20") + expect(result("(2 + 3) × 4 =")).toBe("20") + expect(result("15%")).toBe("0.15") + expect(result("1 / 0")).toBe("Error") + expect(result("25")).toBeUndefined() + expect(result("-5")).toBeUndefined() + expect(result("25=")).toBeUndefined() + expect(result("+")).toBeUndefined() + }) + + test("overflow in typed numbers", () => { + expect(type("1234567890123456").calc.display).toBe("Error") + expect(type("123456789012345").calc.display).toBe("123456789012345") + }) + + test("reject other text", () => { + expect(textInput("hello")).toBeUndefined() + expect(textInput("2 ^ 3")).toBeUndefined() + expect(textInput("2.3.4")).toBeUndefined() + expect(textInput("(2 + 3")).toBeUndefined() + expect(textInput("2 + 3)")).toBeUndefined() + expect(textInput("()")).toBeUndefined() + expect(textInput("2(3)")).toBeUndefined() + expect(textInput("2 +")).toBeUndefined() + }) +}) + +describe("calculator text", () => { + const nbsp = String.fromCharCode(0xa0) + + test("symbol keys", () => { + expect(calculatorText(initialCalc, true)).toBe([ + "*0*", + "/C /± /% /÷", + "/7 /8 /9 /×", + "/4 /5 /6 /-", + "/1 /2 /3 /+", + "/√ /0 /. /=", + ].join("\n")) + }) + + test("switched off: keys without number", () => { + const [displayLine, ...rows] = calculatorText(undefined, true).split("\n") + expect(displayLine).toBe(`*${nbsp}*`) + expect(rows).toEqual(calculatorText(initialCalc, true).split("\n").slice(1)) + }) + + test("word keys padded to equal width", () => { + const pad = (n: number) => `\`${nbsp.repeat(n)}\`` + const [displayLine, firstRow] = calculatorText(initialCalc, false).split("\n") + expect(displayLine).toBe("*0*") + expect(firstRow).toBe(`/C ${pad(4)}/neg ${pad(2)}/pct ${pad(2)}/div ${pad(2)}`) + }) +}) diff --git a/apps/simplex-calculator-bot/calculatorBot.test.ts b/apps/simplex-calculator-bot/calculatorBot.test.ts new file mode 100644 index 0000000000..85a40c0550 --- /dev/null +++ b/apps/simplex-calculator-bot/calculatorBot.test.ts @@ -0,0 +1,74 @@ +import {afterAll, beforeAll, test, expect} from "vitest" +import {mkdtempSync, rmSync} from "fs" +import {tmpdir} from "os" +import {join} from "path" +import {T} from "@simplex-chat/types" +import {api, bot, util} from "simplex-chat" +import {runCalculatorBot} from "./src/calculatorBot.js" +import {SmpServer, startSmpServer} from "./test/smpServer.js" + +let smpServer: SmpServer + +beforeAll(async () => { smpServer = await startSmpServer() }, 120000) + +afterAll(() => smpServer?.stop()) + +async function useSmpServer(chat: api.ChatApi): Promise { + expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"}) +} + +async function prepareBotDatabase(dbOpts: bot.BotDbOpts): Promise { + const chat = await api.ChatApi.init(dbOpts) + await chat.apiCreateActiveUser({displayName: "SimpleX Calculator", fullName: ""}) + await chat.startChat() + await useSmpServer(chat) + await chat.close() +} + +type ItemCheck = (ci: T.AChatItem) => boolean + +const calculatorShows = (display: string): ItemCheck => ci => ci.chatItem.meta.itemText.startsWith(`*${display}*\n`) + +const hasText = (text: string): ItemCheck => ci => ci.chatItem.meta.itemText === text + +const repliesTo = (quoted: string, text: string): ItemCheck => ({chatItem}) => + chatItem.meta.itemText === text && chatItem.quotedItem?.content.text === quoted + +test("calculator in business chat", async () => { + const dir = mkdtempSync(join(tmpdir(), "calculator-bot-")) + const botDbOpts: bot.BotDbOpts = {type: "sqlite", filePrefix: join(dir, "bot")} + await prepareBotDatabase(botDbOpts) + const [calculator, _botUser, address] = await runCalculatorBot(botDbOpts) + const alice = await api.ChatApi.init({type: "sqlite", filePrefix: join(dir, "alice")}) + const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""}) + await alice.startChat() + await useSmpServer(alice) + const receives = (check: ItemCheck) => alice.wait("newChatItems", ({chatItems}) => chatItems.some(check), 30000) + const deletes = (text: string) => + alice.wait("chatItemsDeleted", ({chatItemDeletions}) => chatItemDeletions.some(({deletedChatItem}) => hasText(text)(deletedChatItem)), 30000) + try { + const [_plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(address!.connLinkContact)) + const firstCalculator = receives(calculatorShows("0")) + await alice.apiConnect(aliceUser.userId, false, link) + const calculatorItem = (await firstCalculator)?.chatItems.find(calculatorShows("0")) + expect(calculatorItem?.chatInfo.type).toBe(T.ChatType.Group) + const groupId = (calculatorItem!.chatInfo as T.ChatInfo.Group).groupInfo.groupId + const send = async (texts: string[], ...events: Promise[]) => { + for (const text of texts) await alice.apiSendTextMessage([T.ChatType.Group, groupId], text) + for (const event of events) expect(await event).toBeDefined() + } + await send(["/2", "/+", "/2", "/="], receives(hasText("2 + 2 = 4")), receives(calculatorShows("4")), deletes("/+")) + await send(["2 × (3 + 4) - 1"], receives(repliesTo("2 × (3 + 4) - 1", "13")), receives(calculatorShows("13"))) + await send( + ["15", "+", "10", "+", "5", "="], + receives(hasText("15 + 10 = 25")), + receives(hasText("25 + 5 = 30")), + receives(calculatorShows("30")), + deletes("+"), + ) + } finally { + await alice.close() + await calculator.close() + rmSync(dir, {recursive: true, force: true}) + } +}, 120000) diff --git a/apps/simplex-calculator-bot/package.json b/apps/simplex-calculator-bot/package.json new file mode 100644 index 0000000000..262676ec3e --- /dev/null +++ b/apps/simplex-calculator-bot/package.json @@ -0,0 +1,22 @@ +{ + "name": "simplex-calculator-bot", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run" + }, + "dependencies": { + "@simplex-chat/types": "^0.12.0", + "simplex-chat": "^7.1.0-beta.4.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + }, + "author": "SimpleX Chat", + "license": "AGPL-3.0" +} diff --git a/apps/simplex-calculator-bot/src/calculator.ts b/apps/simplex-calculator-bot/src/calculator.ts new file mode 100644 index 0000000000..12e33159f3 --- /dev/null +++ b/apps/simplex-calculator-bot/src/calculator.ts @@ -0,0 +1,221 @@ +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" +type Operator = "+" | "-" | "×" | "÷" +export type Key = Digit | Operator | "C" | "±" | "%" | "√" | "." | "=" +export type Update = (calc: Calc) => [Calc, string?] + +export interface Calc { + display: string + operand: string + terms: Term[] + mode: "typing" | "result" | "operator" +} + +interface Operand { + text: string + value: number +} + +interface Term extends Operand { + op: Operator +} + +export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "typing"} + +const keypad: Key[][] = [ + ["C", "±", "%", "÷"], + ["7", "8", "9", "×"], + ["4", "5", "6", "-"], + ["1", "2", "3", "+"], + ["√", "0", ".", "="], +] + +const keyWords: Partial> = { + "±": "neg", + "%": "pct", + "÷": "div", + "×": "mul", + "-": "sub", + "+": "add", + "√": "sqrt", + ".": "dot", + "=": "eq", +} + +function keyWord(key: Key): string { + return keyWords[key] ?? key +} + +const operatorNames = new Map([ + ["+", "+"], ["-", "-"], ["−", "-"], ["×", "×"], ["x", "×"], ["*", "×"], ["÷", "÷"], ["/", "÷"], +]) + +export const keyNames = new Map([ + ...keypad.flat().flatMap((key): [string, Key][] => [[key, key], [keyWord(key), key]]), + ...operatorNames, + ["c", "C"], +]) + +const maxDigits = 15 +const errorDisplay = "Error" + +export function press(calc: Calc, key: Key): [Calc, string?] { + switch (key) { + case "C": return [clear(calc)] + case "=": return equals(calc) + case "+": case "-": case "×": case "÷": return operator(calc, key) + case "%": return [showNumber(calc, percent(calc), `${calc.operand}%`)] + case "√": return [showNumber(calc, Math.sqrt(value(calc)), `√${calc.operand}`)] + case "±": return [negate(calc)] + default: return [enter(calc, key)] + } +} + +function clear(calc: Calc): Calc { + if (calc.display === "0" || calc.display === errorDisplay) return initialCalc + return {...calc, display: "0", operand: "0", mode: "typing"} +} + +function enter(calc: Calc, key: Digit | "."): Calc { + const display = append(calc.mode === "typing" ? calc.display : "0", key) + return {...calc, display, operand: display, mode: "typing"} +} + +function append(display: string, key: Digit | "."): string { + if (key === ".") return display.includes(".") ? display : `${display}.` + if (display.replace(/\D/g, "").length >= maxDigits) return display + return display.replace(/^(-?)0$/, "$1") + key +} + +function negate(calc: Calc): Calc { + if (calc.mode !== "typing") return showNumber(calc, -value(calc)) + const display = calc.display.startsWith("-") ? calc.display.slice(1) : `-${calc.display}` + return {...calc, display, operand: display} +} + +function percent(calc: Calc): number { + const last = calc.terms.at(-1) + const share = value(calc) / 100 + return last && isAdditive(last.op) ? last.value * share : share +} + +function showNumber(calc: Calc, n: number, operand?: string): Calc { + const display = format(n) + return {...calc, display, operand: operand ?? display, mode: "result"} +} + +function operator(calc: Calc, op: Operator): [Calc, string?] { + const terms = calc.mode === "operator" ? calc.terms.slice(0, -1) : calc.terms + const reduced = reduce(terms, {text: calc.operand, value: value(calc)}, precedence(op)) + const display = format(reduced.right.value) + const logLine = reduced.lines.length > 0 ? reduced.lines.join("\n") : undefined + return [{display, operand: display, terms: [...reduced.terms, {...reduced.right, op}], mode: "operator"}, logLine] +} + +function equals(calc: Calc): [Calc, string?] { + if (calc.terms.length === 0) return [calc] + const {right, lines} = reduce(calc.terms, {text: calc.operand, value: value(calc)}, 0) + return [{display: right.text, operand: right.text, terms: [], mode: "result"}, lines.join("\n")] +} + +function reduce(terms: Term[], right: Operand, minPrecedence: number): {terms: Term[], right: Operand, lines: string[]} { + const left = terms.at(-1) + if (!left || precedence(left.op) < minPrecedence) return {terms, right, lines: []} + const text = format(apply(left.value, left.op, right.value)) + const reduced = reduce(terms.slice(0, -1), {text, value: Number(text)}, minPrecedence) + return {...reduced, lines: [`${left.text} ${left.op} ${right.text} = ${text}`, ...reduced.lines]} +} + +function precedence(op: Operator): number { + return isAdditive(op) ? 1 : 2 +} + +function isAdditive(op: Operator): boolean { + return op === "+" || op === "-" +} + +function apply(a: number, op: Operator, b: number): number { + switch (op) { + case "+": return a + b + case "-": return a - b + case "×": return a * b + case "÷": return a / b + } +} + +function value(calc: Calc): number { + return Number(calc.display) +} + +function format(n: number): string { + const rounded = Number(n.toPrecision(maxDigits)) + if (!Number.isFinite(rounded) || Math.abs(rounded) >= 10 ** maxDigits) return errorDisplay + const integerDigits = Math.trunc(Math.abs(rounded)).toString().length + const fixed = rounded.toFixed(maxDigits - integerDigits) + const trimmed = fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed + return trimmed === "-0" ? "0" : trimmed +} + +export function textInput(text: string): {update: Update, result?: string} | undefined { + const input = text.replace(/\s/g, "") + const entry = input.replace(/=$/, "") + const n = expressionValue(entry) + if (n !== undefined) { + const update: Update = calc => { + const entered = showNumber(calc, n) + return input.endsWith("=") ? press(entered, "=") : [entered] + } + return {update, result: Number.isNaN(Number(entry)) ? format(n) : undefined} + } + const key = keyNames.get(input.toLowerCase()) + return key ? {update: calc => press(calc, key)} : undefined +} + +const tokenPattern = /\d+(?:\.\d*)?|\.\d+|[-+−×x*÷\/%()]/g + +interface Parsed { + value: number + rest: string[] +} + +function expressionValue(text: string): number | undefined { + const tokens = text.match(tokenPattern) ?? [] + const parsed = tokens.join("") === text ? expression(tokens, initialCalc) : undefined + return parsed?.rest.length === 0 ? parsed.value : undefined +} + +function expression(tokens: string[], calc: Calc): Parsed | undefined { + const operand = term(tokens) + if (!operand) return undefined + const percent = operand.rest[0] === "%" + const shown = showNumber(calc, operand.value) + const entered = percent ? press(shown, "%")[0] : shown + const rest = percent ? operand.rest.slice(1) : operand.rest + const op = operatorNames.get(rest[0]) + return op ? expression(rest.slice(1), press(entered, op)[0]) : {value: value(press(entered, "=")[0]), rest} +} + +function term([first, ...rest]: string[]): Parsed | undefined { + if (first === "(") { + const inner = expression(rest, initialCalc) + return inner?.rest[0] === ")" ? {value: inner.value, rest: inner.rest.slice(1)} : undefined + } + if (operatorNames.get(first) === "-") { + const operand = term(rest) + return operand && {value: -operand.value, rest: operand.rest} + } + const n = Number(first) + return Number.isNaN(n) ? undefined : {value: n, rest} +} + +const nbsp = String.fromCharCode(0xa0) +const wordWidth = Math.max(...keypad.flat().map(key => keyWord(key).length)) + +export function calculatorText(calc: Calc | undefined, symbolKeys: boolean): string { + const rows = keypad.map(row => symbolKeys ? row.map(key => `/${key}`).join(" ") : row.map(wordKey).join("")) + return [`*${calc?.display ?? nbsp}*`, ...rows].join("\n") +} + +function wordKey(key: Key): string { + const word = keyWord(key) + return `/${word} \`${nbsp.repeat(wordWidth + 1 - word.length)}\`` +} diff --git a/apps/simplex-calculator-bot/src/calculatorBot.ts b/apps/simplex-calculator-bot/src/calculatorBot.ts new file mode 100644 index 0000000000..85cd1b50d4 --- /dev/null +++ b/apps/simplex-calculator-bot/src/calculatorBot.ts @@ -0,0 +1,100 @@ +import {T} from "@simplex-chat/types" +import {api, bot, util} from "simplex-chat" +import {Calc, Update, calculatorText, initialCalc, keyNames, press, textInput} from "./calculator.js" +import {calculatorIcon} from "./icon.js" + +const anyTextCommandsVersion = 21 +const idleMinutes = 10 + +const welcomeMessage = `Tap the keys or send numbers, keys like + and =, or expressions like (2 + 3) × 4. +The calculator turns off after ${idleMinutes} minutes; any key turns it on.` +const hint = "Send a number, a key like + or =, or an expression like (2 + 3) × 4." + +interface Session { + calc?: Calc + itemId: number + timer?: NodeJS.Timeout +} + +interface Sender { + groupId: number + member: T.GroupMember +} + +const sessions = new Map() + +function groupSender({chatInfo, chatItem}: T.AChatItem): Sender | undefined { + return chatInfo.type === "group" && chatItem.chatDir.type === "groupRcv" + ? {groupId: chatInfo.groupInfo.groupId, member: chatItem.chatDir.groupMember} + : undefined +} + +async function showCalculator(chat: api.ChatApi, {groupId, member}: Sender, calc: Calc): Promise { + const symbolKeys = member.memberChatVRange.maxVersion >= anyTextCommandsVersion + const [sent] = await chat.apiSendTextMessage([T.ChatType.Group, groupId], calculatorText(calc, symbolKeys)) + const itemId = sent.chatItem.meta.itemId + const previous = sessions.get(groupId) + const timer = setTimeout(() => turnOff(chat, groupId, itemId, symbolKeys), idleMinutes * 60_000) + sessions.set(groupId, {calc, itemId, timer}) + if (previous) { + clearTimeout(previous.timer) + await chat.apiDeleteChatItems(T.ChatType.Group, groupId, [previous.itemId], T.CIDeleteMode.Broadcast) + } +} + +function turnOff(chat: api.ChatApi, groupId: number, itemId: number, symbolKeys: boolean): void { + sessions.set(groupId, {itemId}) + chat.apiUpdateChatItem(T.ChatType.Group, groupId, itemId, {type: "text", text: calculatorText(undefined, symbolKeys)}, false) + .catch(e => console.log("error turning calculator off", e)) +} + +async function updateCalculator(chat: api.ChatApi, sender: Sender, update: Update): Promise { + const [calc, logLine] = update(sessions.get(sender.groupId)?.calc ?? initialCalc) + if (logLine) await chat.apiSendTextMessage([T.ChatType.Group, sender.groupId], logLine) + await showCalculator(chat, sender, calc) +} + +function tapCommand(update: Update) { + return async (ci: T.AChatItem, _command: util.BotCommand, chat: api.ChatApi): Promise => { + const sender = groupSender(ci) + if (!sender) return + await chat.apiDeleteMemberChatItem(sender.groupId, [ci.chatItem.meta.itemId]) + await updateCalculator(chat, sender, update) + } +} + +const keyCommands = Object.fromEntries([...keyNames].map(([name, key]) => [name, tapCommand(calc => press(calc, key))])) + +async function onMessage(ci: T.AChatItem, content: T.MsgContent, chat: api.ChatApi): Promise { + const sender = groupSender(ci) + if (!sender || content.type !== "text") return + const input = textInput(content.text) + if (!input) { + await chat.apiSendTextReply(ci, hint) + return + } + if (input.result) await chat.apiSendTextReply(ci, input.result) + else await chat.apiDeleteMemberChatItem(sender.groupId, [ci.chatItem.meta.itemId]) + await updateCalculator(chat, sender, input.update) +} + +export function runCalculatorBot(dbOpts: bot.BotDbOpts, simplexDomain?: string): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { + return bot.run({ + profile: {displayName: "SimpleX Calculator", fullName: "", image: calculatorIcon, preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}}, + simplexDomain, + dbOpts, + options: { + addressSettings: {businessAddress: true, welcomeMessage}, + commands: [{type: "command", keyword: "calc", label: "Show calculator"}], + }, + onMessage, + onCommands: { + ...keyCommands, + calc: tapCommand(calc => [calc]), + "": async (ci, _command, chat) => { await chat.apiSendTextReply(ci, hint) }, + }, + events: { + joinedGroupMember: ({groupInfo, member}, chat) => showCalculator(chat, {groupId: groupInfo.groupId, member}, initialCalc), + }, + }) +} diff --git a/apps/simplex-calculator-bot/src/icon.ts b/apps/simplex-calculator-bot/src/icon.ts new file mode 100644 index 0000000000..80bb55a347 --- /dev/null +++ b/apps/simplex-calculator-bot/src/icon.ts @@ -0,0 +1 @@ +export const calculatorIcon = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSgBBwcHCggKEwoKEygaFhooKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKP/AABEIAMAAwAMBEQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AKFfrB+QCE0DEoGITQMbQAhNAxKBiGgY00DENACGgYlAxpNAxKAEJoGJQMaTQMSgBCaBjaBiE0DEoGITQA0mgZuE0HnCUDEJoGNoAQmgYlAxDQMaaBiGgBDQMSgY0mgYlACE0DEoGNJoGJQAhNAxtAxCaBiUDEJoAaTQMSgDcoPPEJoGNoAQmgYlAxDQMaaBiGgBDQMSgY0mgYlACE0DEoGNJoGJQAhNAxtAxCaBiUDEJoAaTQMSgBCaBm4TQeeNoAQmgYlAxDQMaaBiGgDf0nwZ4i1aFZbHSrh4mGVd8Rqw9QWIBrgr5nhaD5ak1f7/AMjvoZZiq65qcHb7vzND/hWfi7/oE/8AkzD/APF1h/bmB/5+fg/8jo/sPHf8+/xX+Yh+Gfi7/oE/+TMP/wAXR/bmB/5+fg/8h/2Jjv8An3+K/wAxP+FZeLv+gR/5Mw//ABdH9uYH/n5+D/yH/YmO/wCff4r/ADEPwy8X/wDQI/8AJmH/AOLo/tzA/wDPz8H/AJB/YmN/k/Ff5if8Kx8X/wDQI/8AJmH/AOLo/tzA/wDPz8H/AJB/YmN/k/Ff5iH4Y+L/APoEf+TMP/xdH9uYH/n5+D/yH/YmN/k/Ff5if8Kw8X/9Aj/yZh/+Lo/tzA/8/Pwf+Q/7Fxv8n4r/ADA/DDxh/wBAj/yZh/8Ai6P7cwP/AD8/B/5B/YuN/k/Ff5mZrHgnxHpELTX+k3CQqMtImJFUepKkgCt6GZ4Wu+WnNX+78zGtl2JoK84O33/kc2TXecYlAxCaAGk0DEoAQmgY2gZuUHnCE0DEoGIaBjTQMQ0Aey/CDwPbvZxa7q8IleQ5tYXGVUA/fI7k9vbnuMfJZ7mslN4ai7W3f6f5n12R5VFwWJrK99l+v+R69Xyh9UFABQAUAFABQAUAFABQAUAeNfGfwHbpZS6/o0KxPGc3cKDCsCfvgdiO/tz2Ofq8izWbmsNWd77P9P8AI+ZznLIqLxFJWtuv1/zPESa+tPlxpNAxKAEJoGNoGITQM3CaDzhKBiGgY00DENACGgZ9fWNslnZW9rEAI4Y1jUD0UYH8q/Kqk3Um5vdu5+qU4KnBQWyVj55/as8d6ppNzp/hnSLmW0S4t/tV1LExV5FLMqpkcgfIxPrx+O+Hpp+8zOtJrRHy+xLMWYkknJJ712HOJQAUAFAHY+H/AIfarruiLqlpcWKW7bsLK7hvlJB4CkdvWuinhp1I8yPn8dxJhcDiHhqkZOWmyVtfmjjq5z6A6rwl4G1LxTZTXWnz2cccUnlsJ3YHOAeMKfWt6WHlVV4niZpn+HyyoqdaMm2r6Jfq0c7eW8ljfz27sPNt5WjLITjcpxkH8KxkrNpnr0aqrU41I7SSf3n0z+yn471TVrnUPDOr3Mt2lvb/AGq1llYs8ahlVkyeSPnUj05/DixFNL3kdlGTejPoW/tUvbG5tZgDHPG0TAjPDDB/nWFObpzU1unc0qQVSLg9nofGJNfqp+aiUAITQMbQMQmgYlAG5QeeIaBjTQMQ0AIaBiUDPsWvyc/Vj5D/AGvf+Slab/2CIv8A0dNXdhvhOat8R4dXQYhQBu+BVDeMtGDAEG6jyD9a1ofxI+p5mctrAVmv5X+Ro/FhVT4gaqqKFUeVgAYH+qSqxX8V/wBdDk4ZbeWUm/73/pTKegeH9fvore50+zupbMvw6H5eDz3pQpVJWcVodGNzHA0HKnWmlK3XfY6P45xpH4ttBGiqPsKHCjH/AC0krXGq1Reh5HBsnLAzbd/ff5ROT0TRNb1S3kl0i1uZoVbaxiPAbHTrWEKc5K8Ue7jMfg8NJRxM0m+52vxhtzbaL4SjkjEcy27rIMYO4LFnP45rpxatGH9dj5zhWr7TEYuUXdcyt6XkdH+yF/yUrUv+wRL/AOjoa8rE/Cfc0fiPryuE6T4pr9YPzIQmgY2gYhNAxKAEJoGbhoPPGmgYhoAQ0DEoGNJoGfY9fk5+qnyH+17/AMlK03/sERf+jpq7sN8JzVviPDq6DEKAN3wKwXxloxYgAXUeSfrWtD+JH1PMzlN4Csl/K/yOo8USIfjbC4dSn26zO4Hj7sVbVX/tPzX6Hi5bFrh6Sa15Kn5yLfxM8Ra7aeK7u30u+uUsgiYWLleUGe1Vias1NqL0MOHsswVXBQqV4Jzu9999Dy+uI+0NnRNb1vS7eSLSLq5hhZtzCIcFsdelaQqTirRZ5+MwGDxMlLEwTa7na/GG4NzovhKSSQSTNbu0hzk7isWc/jmunFu8Yf12PnOFaXs8Ri4xVlzK3peR0f7IX/JStS/7BEv/AKOhrysT8J9zR+I+vK4TpPigmv1g/MxtAxCaBiUAITQMbQM3DQeeIaAENAxKBjSaBiUAfZFfk5+qnzn+1Z4E1TVrnT/E2kW0t2lvb/ZbqKJSzxqGZlfA5I+dgfTj8OrD1EvdZhWi3qj5fYFWKsCCDgg9q7DnEoAKACgDsfD/AMQdV0LRF0u0t7F7dd2GlRy3zEk8hgO/pXRTxM6ceVHz+O4bwuOxDxNSUlLTZq2nyZx1c59AdV4S8c6l4WsprXT4LOSOWTzGM6MTnAHGGHpW9LESpK0TxM0yDD5nUVStKSaVtGv1TOdvLiS+v57h1Hm3ErSFUBxuY5wB+NYyd22z16NJUacacdopL7j6Z/ZT8CappNzqHibV7aW0S4t/strFKpV5FLKzPg8gfIoHrz+PFiKifuo7KMWtWfRlcpufE1frB+aCE0DEoAQmgY2gYhoA3DQeeIaBiUDGk0DEoAQmgZ9k1+Tn6oFABQAUAFABQAUAFABQAUAFABQB8Sk1+sH5qJQAhNAxtAxDQA00DN00HniUDGk0DEoAQmgYlAz7Kr8nP1Mq6hcNAihPvN39K0pxUtyZOxn/AG24/wCen/jorX2cexHMzRtrgyWrSOOVznHfFYyhaVkWndXM9r6ckkOAPQAVt7OJHMyxY3ckkwjkO7PQ4xUTgkroqMm3ZhfXckcxjjO3HU4zRCCauwlJp2RXW+nBBLgj0IFX7OJPMzP+IPiOXw74ZF7aIrXE0ixR7xkKSCckfRTXXlWBjjMR7Ob0Suzz83x8sFhvaQWrdl/XyPID8R/FWf8AkKY/7d4v/ia+u/sPA/8APv8AF/5nxv8Ab+P/AOfn4R/yPUPhZ4qu/EtheJqIRrq0ZMyKu0OrZxkdM/KentXzGd5dTwU4ultK+na3/Dn1WRZlUx1OSq/FG2ve/wDwx3FeIe6fElfrB+aiE0DG0DENADTQMDQM3KDzxpNAxKAEJoGJQMaTQM+zK/Jz9SM/VwdsRxwMj+VbUepEzNrczNOyRjp8oxy27HvxWE376NI7GZW5mWdNBN2hA4GSfyrOp8JUdw1IEXbkjg4I/Kin8IS3K1aEnPfGW2ml8EWxjQsILmN5MfwrsZc/mwr0+HpxjjGm907femeJxLTlLBppbSTf3NfqeGV9wfBHsHwFt5UttZuGQiGRokRuxKhyf/Qh+dfI8Tzi5U4J6q/42/yPsuFYSUas2tHb8L/5nq1fKn1p8Rk1+sH5sNoGIaAGmgYGgY00DNwmg88SgBCaBiUDGk0DEoA+za/Jz9SIriWOKPMvIPGMZzVRTb0E2luU/tVp/wA8P/HBWnJPuTzR7F6KRJIw6H5f5Vk007MpO5Te7tC5zFuPrsHNaqE+5PMia0ngkJWJdh64xjNROMlqxpp7BdzwRkLKu89cYziiEZPVA2luQpd2gcYi2n12DircJ9xcyE13U7HStLmu9TdVtVGGyN27PGAO+arDUKuIqqnSXvGWKxFLD0nUrP3TzU+NvAmT/wAU1n3+wQc/+PV9J/ZOaf8AP/8A8ml/kfMf2vlP/Pj/AMlj/megeE9Z0rWtLEuihY7eM7DCECGM9cbRx+XFeBjsLXw1Xlr6t9d7n0OAxeHxVLmw+iXS1rfI2q4juPiGv1g/NxDQA00DA0DGmgYlAzcoPOEJoGJQMaTQMSgBCaBn2dX5OfqJnax/yy/H+lbUepEzOrczNGy/5B0//Av5VhP40aR+FmdW5mWNO/4/I/x/kazqfCyo7hqP/H5J+H8hRT+FBLcr1oScz8aSf+EK0/3u4/8A0W9erw5/vk/8L/NHhcTf7nH/ABL8meI19sfCHrfwDPGujt+4/wDalfJ8Uf8ALr/t79D7DhT/AJff9u/qet18mfXnxAa/WD83GmgYGgY00DEoGNJoGbpNB5wlAxpNAxKAEJoGNoGfaFfk5+oEN1brcIAxwR0NVGXKJq5U/sz/AKa/+O//AF609t5E8hchgSKHyxyD1z3rNybdykrKxUbTRk7ZSB6Fc1p7byJ5Ca1slgfeW3N24xiplU5lYajYLqyWd94ba3fjOaI1OVWBxuQrpoyN0pI9AuKr23kLkIPE2g2viHR30+7LIhIZHTqjDoR+o/GtcFjJ4OqqsP8Ahzmx2Cp42i6NT/hmedn4Oc8a7x/16f8A2dfQ/wCtH/Tr/wAm/wCAfOf6qf8AT7/yX/gndeC/Ctr4WsJILeR5ppmDSzMMFsdAB2A5/M14eY5jPHzUpKyWyPey3LaeX03GLu3uzoa889E+HjX6wfnAGgY00DEoGNJoGJQBu0HnjSaBiUAITQMbQMQmgZ9o1+Tn6eUNWdlSNVOA2c1rSV9SJszK6DM1bOVzYyMTkpnBP0zXPOK5kjSL0MtmLEliST3Nb7GZa012W5VQflbOR+FRUXu3Ki9Q1J2a5ZSflXGB+FFNe7cJPUqqxUgqSCO4q9yTF+LeqXVh4Mie0laJ7mZInZDg7SrMcH/gIr0Mhw8KuLamr8qb/FL9TyOIcRUo4NezduZpfKzf6HghOTk9a+8Pz49n+BupXV3YanZ3EryQ2zRmLccld27IHt8o4+tfG8S0IU5wqRVnK9/lb/M+24XxFSpTqU5O6ja3zv8A5Hp9fMn1J8PGv1g/ORpoGJQMaTQMSgBCaBm4TQeeJQAhNAxtAxCaBiUDPtKvyc/TinqcLyojIMlc5ArWlJLciSuZvkTf88pP++TW3Mu5FmadpAy2bxvwz5/DIxWE5JyujRLSxmtbTKSDE/4DNb88e5nZlnT7eQXAd1KqueoxWdSStZFRTuGoW8huC6KWVsdBminJWswkncrLbTMQBE/4jFac8e5NmZXxO0G71rwmltYL5txbypMIx1cBWUge/wA2fwrtyXGU8NiuepommvTZ/oeZnmDqYvC8lJXaadu+6/U8SPhjXgcf2Jqf/gLJ/hX2v1/C/wDP2P3r/M+F/s7F/wDPqX/gL/yPXPg54ev9FsL+41KFrd7tkCROMMAu7kjtnd09q+T4gxtLEzhCk78t9fW3+R9hw7gauFpznVVua2npf/M9Dr54+jPhw1+sH50JQMaTQMSgBCaBiUDNyg84QmgY2gYhNAxKBiE0Afadfk5+nFe9ufs6LgZZumauEOYmTsUv7Rm/ux/kf8a19kiedl63uBLbmUjG3OR9KylGzsWndXKTalJk7UQD3zWvskRzsms71ppdkigE9CKidPlV0OMrheXrQy7I1BI6k0Qp8yuwlKxCupSZG5EI9s1fskLnZk/EbxfH4O8LNqpg+0SyOsMEROAzsCRk+gAJ/Ct8uwLxtf2V7LdmOLxKw1L2lrniJ+PfifJxYaLj/rjL/wDHK+o/1awv80vvX+R4n9s1+y/H/M9d+Evj0+OdLu3uLZLa+s2VZljJKMGB2sM8j7rcc9K+ezbLfqE0ou8ZbfI9bAYz61F3Vmju68k7z4br9YPzsaTQMSgBCaBiUDEJoGbhNB5w2gYhNAxKBiE0ANJoGfZ1hdJe2NtdQkGKeNZVIOeGAI/nX5VUg6c3B7p2P0qnNTiprZ6lXVxxEe3P9Kuj1CZnVuZmlZA/2dNx13Y/KsJ/GjSPwmbW5mWdOH+mJ+P8qzqfCVHcNRH+mP8Ah/Kin8IS3K1aEnCftHRu3w508qpIjvomfA+6PKkGT+JA/GvV4daWMl/hf5o8/N0/q69V+TPmivtj5s99/ZajcReJZCpEbG2UN2JHm5H6j86+S4oavSXr+h72SLSb9P1PcNQuo7GwubuYgRW8TSuSccKCT/Kvl6cHUmoLdux7c5qEXJ9D4gJr9VPz4SgBCaBiUDEJoGNoA3KDzxCaBiUDEJoAaTQMSgD274LePrZLKLw/rM6wvGcWk0hwrKT9wnsRnj247DPyWe5VNzeJoq991+v+Z9Rk2ZRUVh6rtbZ/oezSRrIu11DD3r5VNrY+lauQ/Yrf/nn/AOPGq9pLuTyonVVRQqgADsKhu5RC1nAxJMYz7Eir9pLuTyofFBHDny0AJ70nJvcaSQSwRzY8xASO9Ck1sDSYxbOBSCIxn3JNP2ku4uVDdSsLTU7GWz1C3juLWUbXikXKsKKdSdKSnB2aCcIzjyyV0cYfhF4HJJ/sPr/09z//ABden/bmO/5+fgv8ji/szC/y/i/8zrNC0XTtBsFstHtIrS2U7tiDqfUk8k+5rz6+IqYifPVldnXSpQpR5YKyPIPjh8QraSxl8O6JOszynF5PGcqqg/6sHuSRz7cdzj6TIsqkprE1la2y/X/I8LNswi4uhSd+7/Q8Hr64+dEJoGJQMQmgY2gBCaBm4TQeeJQMQmgBpNAxKAEJoGNoGdHo/jnxLo0Kw6fq9ykKDCxviVVHoA4IArgr5Zha75qkFf7vyO2jmGJoq0Ju33/maP8AwtXxn/0Gf/JWH/4isP7DwP8Az7/F/wCZv/a+M/n/AAX+QH4q+M/+gz/5Kw//ABFH9h4H/n3+L/zD+18Z/P8Agv8AIb/wtbxn/wBBn/yVh/8AiKP7DwP/AD7/ABf+Y/7Xxn8/4L/ID8VvGf8A0Gf/ACVh/wDiKP7DwP8Az7/F/wCYf2vjP5/wX+Qn/C1/Gn/QZ/8AJWH/AOIo/sPA/wDPv8X/AJj/ALXxn8/4L/IQ/Ffxp/0Gf/JWH/4ij+w8D/z7/F/5j/tbF/z/AIL/ACE/4Wx40/6DX/krB/8AEUf2Hgf+ff4v/MP7Wxf8/wCC/wAhP+FseNf+g1/5Kwf/ABFH9h4H/n3+L/zH/a2L/n/Bf5GVrfjzxPrUDQ6jrNy8LjDRx4iVh6EIACPrW9DLMLQfNTgr/f8AmZVcfiKytObt935HMV3nIITQMSgYhNAxtACE0DEoGblB54hNADSaBiUAITQMbQMQmgYlACE0DG0DENADTQMDQMaaBiUDGk0DEoAQmgYlAxCaBjaAEJoGJQMQmgDcJoPPGk0DEoAQmgY2gYhNAxKAEJoGNoGIaAGmgYGgY00DEoGNJoGJQAhNAxKBiE0DG0AITQMSgYhNADaBm4TQeeJQAhNAxtAxCaBiUAITQMbQMQ0ANNAwNAxpoGJQMaTQMSgBCaBiUDEJoGNoAQmgYlAxCaAG0DEJpDP/2Q==" diff --git a/apps/simplex-calculator-bot/src/index.ts b/apps/simplex-calculator-bot/src/index.ts new file mode 100644 index 0000000000..5464e25ec8 --- /dev/null +++ b/apps/simplex-calculator-bot/src/index.ts @@ -0,0 +1,12 @@ +import {mkdirSync} from "fs" +import {parseArgs} from "util" +import {runCalculatorBot} from "./calculatorBot.js" + +const {values: {domain}} = parseArgs({options: {domain: {type: "string"}}}) + +mkdirSync("data", {recursive: true}) + +runCalculatorBot({type: "sqlite", filePrefix: "./data/calculator"}, domain).catch(e => { + console.log("fatal error", e) + process.exit(1) +}) diff --git a/apps/simplex-calculator-bot/test/smpServer.ts b/apps/simplex-calculator-bot/test/smpServer.ts new file mode 100644 index 0000000000..c7f0d87ec7 --- /dev/null +++ b/apps/simplex-calculator-bot/test/smpServer.ts @@ -0,0 +1,101 @@ +import {ChildProcess, spawn} from "child_process" +import {createHash} from "crypto" +import {once} from "events" +import {copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from "fs" +import {AddressInfo, createConnection, createServer} from "net" +import {tmpdir} from "os" +import {dirname, join} from "path" + +const tlsFixtures = join(__dirname, "../../../tests/fixtures/tls") +const smpServerRelease = "v7.1.0-beta.3" +const releaseArch: Record = {x64: "x86-64", arm64: "aarch64"} + +export interface SmpServer { + address: string + stop: () => Promise +} + +export async function startSmpServer(): Promise { + const port = await freePort() + const dir = mkdtempSync(join(tmpdir(), "smp-server-")) + const configDir = join(dir, "config") + mkdirSync(configDir) + for (const file of ["ca.crt", "server.crt", "server.key"]) copyFileSync(join(tlsFixtures, file), join(configDir, file)) + const fingerprint = caFingerprint(join(configDir, "ca.crt")) + writeFileSync(join(configDir, "fingerprint"), `${fingerprint}\n`) + writeFileSync(join(configDir, "smp-server.ini"), `[STORE_LOG]\nenable: off\n\n[TRANSPORT]\nhost: localhost\nport: ${port}\nwebsockets: off\n`) + const server = spawn(await smpServerExecutable(), ["start"], { + env: {...process.env, SMP_SERVER_CFG_PATH: configDir, SMP_SERVER_LOG_PATH: join(dir, "logs")}, + stdio: ["ignore", "ignore", "inherit"], + }) + const stop = async () => { + if (server.exitCode === null) { + const exited = once(server, "exit") + server.kill() + await exited + } + rmSync(dir, {recursive: true, force: true}) + } + try { + await once(server, "spawn") + await waitForServer(server, port) + } catch (e) { + await stop() + throw e + } + return {address: `smp://${fingerprint}@localhost:${port}`, stop} +} + +async function smpServerExecutable(): Promise { + if (process.env.SMP_SERVER) return process.env.SMP_SERVER + const path = join(__dirname, "../node_modules/.cache/smp-server", `smp-server-${smpServerRelease}`) + if (!existsSync(path)) await downloadSmpServer(path) + return path +} + +async function downloadSmpServer(path: string): Promise { + const arch = releaseArch[process.arch] + if (process.platform !== "linux" || !arch) { + throw new Error(`smp-server release binaries are not available for ${process.platform} ${process.arch}, set SMP_SERVER to the smp-server path`) + } + const url = `https://github.com/simplex-chat/simplexmq/releases/download/${smpServerRelease}/smp-server-ubuntu-22_04-${arch}` + const response = await fetch(url) + if (!response.ok) throw new Error(`error downloading ${url}: ${response.status}`) + mkdirSync(dirname(path), {recursive: true}) + writeFileSync(`${path}.download`, Buffer.from(await response.arrayBuffer()), {mode: 0o755}) + renameSync(`${path}.download`, path) +} + +function caFingerprint(caFile: string): string { + const der = Buffer.from(readFileSync(caFile, "utf-8").replace(/-----[^-]+-----|\s/g, ""), "base64") + return createHash("sha256").update(der).digest("base64").replace(/\+/g, "-").replace(/\//g, "_") +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = createServer() + probe.on("error", reject) + probe.listen(0, "127.0.0.1", () => { + const {port} = probe.address() as AddressInfo + probe.close(() => resolve(port)) + }) + }) +} + +async function waitForServer(server: ChildProcess, port: number): Promise { + const deadline = Date.now() + 15_000 + while (!(await canConnect(port))) { + if (server.exitCode !== null || Date.now() > deadline) throw new Error(`smp-server did not start on port ${port}`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +function canConnect(port: number): Promise { + return new Promise(resolve => { + const socket = createConnection({host: "localhost", port}, () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => resolve(false)) + }) +} diff --git a/apps/simplex-calculator-bot/tsconfig.json b/apps/simplex-calculator-bot/tsconfig.json new file mode 100644 index 0000000000..821fa663e3 --- /dev/null +++ b/apps/simplex-calculator-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "module": "Node16", + "moduleResolution": "Node16", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2022", + "types": ["node"] + } +} 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/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg index 715a6daf99..93f959845f 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg index f70ecb91db..d0de67a017 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg index 48bfe6caa7..782d2b3823 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg index 2bab828717..b489708eca 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg index 704cae8710..56c0b20a37 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg index d7c1f4e088..1406f27805 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg index e39e0836cb..17e4bc94ca 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg index ce9b4a86ef..99d7484555 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg differ diff --git a/bots/README.md b/bots/README.md index 80c6689dce..ee2760ad78 100644 --- a/bots/README.md +++ b/bots/README.md @@ -43,7 +43,7 @@ To distinguish SimpleX user profile as a bot, set its `peerType` property to `"b ### Configure bot commands -Bot commands are messages that start from `/` character. Normally, they would consist of lowercase latin letters, but commands can use any letters, digits and underscores. Commands can have parameters. +Bot commands are messages that start from `/` character. Normally, they would consist of lowercase latin letters, but commands can use any characters, including symbols such as `/+` or `/-`. Commands can have parameters. All commands in messages will be highlighted in the chats with the bot, and when users tap them, they will be instantly sent. If the message has a single line and starts from `/` character, the whole message will be highlighted. Otherwise, if command is included as part of the message, it will be highlighted until the first space after `/` character: e.g., `/list` command in Directory service shows user's groups. diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index f90e8e08da..af7b0146f7 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -7,6 +7,7 @@ This file is generated automatically. - [APIDeleteMyAddress](#apideletemyaddress) - [APIShowMyAddress](#apishowmyaddress) - [APISetProfileAddress](#apisetprofileaddress) +- [APISetUserDomain](#apisetuserdomain) - [APISetAddressSettings](#apisetaddresssettings) [Message commands](#message-commands) @@ -246,6 +247,53 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APISetUserDomain + +Set or remove SimpleX name of bot address. The name must be registered with the address short link. + +*Network usage*: interactive. + +**Parameters**: +- userId: int64 +- simplexDomain: string? + +**Syntax**: + +``` +/_set domain [ ] +``` + +```javascript +'/_set domain ' + userId + (simplexDomain ? ' ' + simplexDomain : '') // JavaScript +``` + +```python +'/_set domain ' + str(userId) + ((' ' + simplexDomain) if simplexDomain is not None else '') # Python +``` + +**Responses**: + +UserProfileUpdated: User profile updated. +- type: "userProfileUpdated" +- user: [User](./TYPES.md#user) +- fromProfile: [Profile](./TYPES.md#profile) +- toProfile: [Profile](./TYPES.md#profile) +- updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary) + +UserProfileNoChange: User profile was not changed. +- type: "userProfileNoChange" +- user: [User](./TYPES.md#user) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +**Errors**: +- SimplexDomainNotReady: The name does not resolve to the address short link. + +--- + + ### APISetAddressSettings Set bot address settings. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index ae1789f47e..ca95a37f4e 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -81,6 +81,14 @@ chatCommandsDocsData = ("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete_address " <> Param "userId"), ("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink", "CRChatCmdError"], [], Nothing, "/_show_address " <> Param "userId"), ("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"), + ( "APISetUserDomain", + [], + "Set or remove SimpleX name of bot address. The name must be registered with the address short link.", + ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], + [TD "CESimplexDomainNotReady" "The name does not resolve to the address short link"], + Just UNInteractive, + "/_set domain " <> Param "userId" <> Optional "" (" " <> Param "$0") "simplexDomain" + ), ("APISetAddressSettings", [], "Set bot address settings.", ["CRUserContactLinkUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_address_settings " <> Param "userId" <> OnOffParam "pq_ratchet" "pqRatchet" Nothing <> " " <> Json "settings") ] ), @@ -440,7 +448,6 @@ undocumentedCommands = "APISetServerOperators", "APISetUserContactReceipts", "APISetUserGroupReceipts", - "APISetUserDomain", "APISetUserServers", "APISetUserUIThemes", "APIStandaloneFileInfo", diff --git a/cabal.project b/cabal.project index b8c73b14cf..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: 5294b7d8b7285a887b849b738e29bec207e5dde8 + 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/docs/protocol/badges-overview.md b/docs/protocol/badges-overview.md index 41ed3c5eaa..255f3cd88f 100644 --- a/docs/protocol/badges-overview.md +++ b/docs/protocol/badges-overview.md @@ -92,7 +92,7 @@ In SimpleX: - A badge does not restrict anything that is available today: the defaults are unchanged, and a badge only raises them. - A badge does not create an identity: it has no persistent identifier, it is not linked across conversations, and an incognito profile does not show it. -- A badge cannot be transferred: a code can be redeemed once, and the credential obtained with it is usable only with the master key it was issued for. +- A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for. - A badge does not exempt its holder from the limits a server applies: it lowers the cost of a resource without removing the limit on it. - A badge cannot be revoked; credentials are issued for one month at a time instead. @@ -139,11 +139,11 @@ A purchase is identified by an Ed25519 key pair that the app generates for it an A badge is bought for one or more months, either on the web or in the app. -On the web the purchase produces a code, which is then redeemed in the app. The code is the only data passed from the web site to the app: the web site does not learn which app redeems a code, and the app does not see the payment. The code is generated in the buyer's browser, and the service stores only its hash; at redemption the app presents the code itself, and the service matches it against the stored hash. A badge issued without a sale, for example in compensation for a problem, is a code generated by the operator and redeemed in the same way. +On the web the purchase produces a code, which is then redeemed in the app. The code is the only data passed from the web site to the app: the web site does not learn which app redeems a code, and the app does not see the payment. The code is generated in the buyer's browser, and the service stores only its hash; at redemption the app presents the code itself, and the service matches it against the stored hash. A badge issued without a sale, for example in compensation for a problem, is a code generated by the operator, or by a moderator of the service's managed group, and redeemed in the same way. In the app, the user pays by card, in cryptocurrency, or through the app store. The app requests an invoice from the service, or presents the receipt of the app store, and the service issues the credential once the payment is confirmed. -In both cases the app generates the master key and the purchase key pair before the purchase. To redeem a code, the app sends the code and the master key to the service, which issues the credential. A code redeemed a second time with the same purchase key returns the same credential; a code redeemed with a different purchase key is refused. +In both cases the app generates the master key and the purchase key pair before the purchase. To redeem a code, the app sends the code and the master key to the service, which issues the credential. A code redeemed a second time with the same purchase key returns its latest credential; a code redeemed with a different purchase key is refused once all its uses are taken. ### Monthly issuance @@ -203,7 +203,7 @@ Service requests are protected by the double ratchet of the [SimpleX agent](http 1. A proof discloses no value that links it to another proof or to the purchase. 2. A proof used in one context, whether a session, a conversation or a file, cannot be used in another context. 3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. -4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, and a request about a purchase can be made only by the holder of the purchase key. +4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, other than the redemptions of one multi-use code and the codes issued in the service's managed group or redeemed by its members, and a request about a purchase can be made only by the holder of the purchase key. 5. A credential cannot be forged: the issuer keys are fixed in apps and servers, and the app verifies a credential before storing it. 6. A missing or failed proof leaves the default limit in place, and a server cannot be configured to grant a badge type less than the default. @@ -217,10 +217,12 @@ This threat model assumes the [SimpleX network threat model](https://github.com/ - See the purchase key of every badge, the master key generated for it, the number of months bought, and the payment record. - Issue any credential, or refuse to issue one, as it holds the issuer key. +- Link a code issued in its managed group to the member who asked for it and to that group, as the group's messages, codes included, are kept in the service's chat database. +- Link a redemption to a member of its managed group whose profile shows a new badge soon after it. *cannot:* -- Connect a purchase with a profile, a contact, a group or a session with a server - a proof contains nothing that refers back to the purchase. +- Connect a purchase with a profile, a contact, a group or a session with a server, other than a code issued in its managed group or redeemed by one of its members, as above - a proof contains nothing that refers back to the purchase. - Learn where a badge is shown or used. - Learn the network address of the app - requests reach the service through SMP servers, on connections created for the request. @@ -236,6 +238,20 @@ This threat model assumes the [SimpleX network threat model](https://github.com/ - Reuse a profile proof or a file proof in another context. - Distinguish the holder from the other supporters whose badges expire in the same week. +**A member of the badge service's managed group** + +*can:* + +- Redeem any code issued in the group that is not revoked and has uses left, as every code is posted to the group. +- See when each use of a multi-use code was redeemed. +- Issue free codes of any type as a moderator or above, and revoke any code whose text they hold as an admin or above. +- Become the group's owner by being the first to join it, before an owner is set up. +- Guess who redeemed a code, when a member's profile shows a new badge soon after the code's message changes. + +*cannot:* + +- Redeem a revoked code, or a code with no uses left. + **A server operator** *can:* @@ -271,7 +287,7 @@ An attacker who obtains the credential and the purchase key can use the badge un **Interception of a code** -A code is a bearer secret until it is redeemed; once redeemed, it is refused to any other purchase key. +A code is a bearer secret until its last use is taken; after that, it is refused to any other purchase key. **A passive network observer** diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index b35e6b8001..42b602f4d2 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -19,7 +19,7 @@ A purchase record is created by `redeemBadgeCode`, by `getBadgeInvoice`, or by ` A timeout hides the outcome, so the client repeats the identical signed request at its next trigger, never on a poll timer. - `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. +- `redeemBadgeCode` — a code already redeemed by the signing key returns its latest `badgeCredential` and writes nothing; once other keys have taken all its uses, `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; 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. @@ -31,7 +31,7 @@ The client never states what is signed. The tier is that of whatever funded the - `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. +- `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 other keys have taken all its uses (a code has one use unless issued with more), `code_expired` past a redemption deadline. - `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. @@ -70,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 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. +`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` — all its uses taken by other keys; `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/simplex-chat.md b/docs/protocol/simplex-chat.md index 040064864c..066b465d0d 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -90,7 +90,7 @@ The syntax of compressed message is defined by the following ABNF notation: compressedMessage = %s"X" 1*15780 OCTET; compressed message data ``` -Compressed message is required to fit into 13388 bytes, accounting for agent overhead (see Protocol's maxCompressedMsgLength). +Compressed message is required to fit into 15602 bytes, or into 13380 bytes for a connection with PQ encryption, accounting for agent overhead (see Protocol's maxEncodedMsgLength and maxEncodedMsgLengthPQ). The actual JSON message is required to fit into 15610 bytes, accounting for group message forwarding (x.grp.msg.forward) overhead (see Protocol's maxEncodedMsgLength). @@ -264,7 +264,7 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.direct.inv` message is sent to a group member to propose establishing a direct connection between members, thus creating a contact with another member. -`x.grp.msg.forward` message is sent by inviting member to forward messages between introduced members, while they are connecting. +`x.grp.msg.forward` message is sent by inviting member to forward messages between introduced members, while they are connecting. This message MUST NOT contain another `x.grp.msg.forward` message. ### Channels: relay-mediated groups @@ -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/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 4d353cbdae..24ea58b66b 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.11.4", + "version": "0.12.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index c217ef944c..cc01ae9472 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -66,6 +66,21 @@ export namespace APISetProfileAddress { } } +// Set or remove SimpleX name of bot address. The name must be registered with the address short link. +// Network usage: interactive. +export interface APISetUserDomain { + userId: number // int64 + simplexDomain?: string +} + +export namespace APISetUserDomain { + export type Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + export function cmdString(self: APISetUserDomain): string { + return '/_set domain ' + self.userId + (self.simplexDomain ? ' ' + self.simplexDomain : '') + } +} + // Set bot address settings. // Network usage: interactive. export interface APISetAddressSettings { diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md index 739b41b34e..05c99eca5c 100644 --- a/packages/simplex-chat-nodejs/README.md +++ b/packages/simplex-chat-nodejs/README.md @@ -24,13 +24,13 @@ Simple bot that replies with squares of numbers you send to it: const {bot} = await import("simplex-chat") // if you are running from this GitHub repo: // const {bot} = await import("../dist/index.js") - const [chat, _user, _address] = await bot.run({ + await bot.run({ profile: {displayName: "Squaring bot example", fullName: ""}, dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, options: { addressSettings: {welcomeMessage: "Send a number, I will square it.", }, - onMessage: async (ci, content) => { + onMessage: async (ci, content, chat) => { const n = +content.text const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` @@ -62,6 +62,8 @@ There is an example with more options in [./examples/squaring-bot.ts](./examples You can run it with: `npx ts-node ./examples/squaring-bot.ts` +A larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/). + ## PostgreSQL backend By default, the package uses SQLite. To use PostgreSQL instead: diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md index bf40b3165c..13efcec7dd 100644 --- a/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md +++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md @@ -6,7 +6,7 @@ # Type Alias: EventSubscriberFunc\ -> **EventSubscriberFunc**\<`K`\> = (`event`) => `void` \| `Promise`\<`void`\> +> **EventSubscriberFunc**\<`K`\> = (`event`, `chat`) => `void` \| `Promise`\<`void`\> Defined in: [src/api.ts:50](../src/api.ts#L50) @@ -22,6 +22,10 @@ Defined in: [src/api.ts:50](../src/api.ts#L50) `ChatEvent` & `object` +### chat + +[`ChatApi`](api.Class.ChatApi.md) + ## Returns `void` \| `Promise`\<`void`\> diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md index 5397cf3aeb..8fbb699f80 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -18,7 +18,7 @@ Defined in: [src/bot.ts:108](../src/bot.ts#L108) ### onMessage -((`chatItem`, `content`) => `void` \| `Promise`\<`void`\>) \| `undefined` +((`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\>) \| `undefined` ### commands diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md index f763f414da..3f7757041b 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -34,13 +34,13 @@ Defined in: [src/bot.ts:42](../src/bot.ts#L42) #### Index Signature -\[`key`: `string`\]: ((`chatItem`, `command`) => `void` \| `Promise`\<`void`\>) \| `undefined` +\[`key`: `string`\]: ((`chatItem`, `command`, `chat`) => `void` \| `Promise`\<`void`\>) \| `undefined` *** ### onMessage? -> `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\> +> `optional` **onMessage?**: (`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\> Defined in: [src/bot.ts:40](../src/bot.ts#L40) @@ -54,6 +54,10 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40) `MsgContent` +##### chat + +[`ChatApi`](api.Class.ChatApi.md) + #### Returns `void` \| `Promise`\<`void`\> diff --git a/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js b/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js index 8899e9bf15..0b369a2053 100644 --- a/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js +++ b/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js @@ -1,12 +1,12 @@ (async () => { const {bot} = await import("../dist/index.js") - const [chat, _user, _address] = await bot.run({ + await bot.run({ profile: {displayName: "Squaring bot example", fullName: ""}, dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, options: { addressSettings: {welcomeMessage: "Send a number, I will square it."}, }, - onMessage: async (ci, content) => { + onMessage: async (ci, content, chat) => { const n = +content.text const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` diff --git a/packages/simplex-chat-nodejs/examples/squaring-bot.ts b/packages/simplex-chat-nodejs/examples/squaring-bot.ts index dbb58d90dc..e3f746c1d8 100644 --- a/packages/simplex-chat-nodejs/examples/squaring-bot.ts +++ b/packages/simplex-chat-nodejs/examples/squaring-bot.ts @@ -3,7 +3,7 @@ import {bot, util} from "../dist" (async () => { const welcomeMessage = "Hello! I am a simple squaring bot.\n\nIf you send me a number, I will calculate its square." - const [chat, _user, _address] = await bot.run({ + await bot.run({ profile: {displayName: "Squaring bot example", fullName: ""}, dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, options: { @@ -15,7 +15,7 @@ import {bot, util} from "../dist" logContacts: true, logNetwork: false }, - onMessage: async (ci, content) => { + onMessage: async (ci, content, chat) => { const n = +content.text const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` @@ -23,11 +23,11 @@ import {bot, util} from "../dist" await chat.apiSendTextReply(ci, reply) }, onCommands: { // command handlers can be different from commands to be shown in client UI - "help": async (ci: T.AChatItem, _cmd: util.BotCommand) => { + "help": async (ci: T.AChatItem, _cmd: util.BotCommand, chat) => { await chat.apiSendTextMessage(ci.chatInfo, welcomeMessage) }, // fallback handler that will be called for all other commands - "": async (ci: T.AChatItem, _cmd: util.BotCommand) => { + "": async (ci: T.AChatItem, _cmd: util.BotCommand, chat) => { await chat.apiSendTextReply(ci, "This command is not supported") } }, diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index f405be260d..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.4", + "version": "7.1.0-beta.6", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 8c11e9fde3..8d4e75b181 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -47,7 +47,7 @@ export const defaultBotAddressSettings: BotAddressSettings = { businessAddress: false } -export type EventSubscriberFunc = (event: ChatEvent & {type: K}) => void | Promise +export type EventSubscriberFunc = (event: ChatEvent & {type: K}, chat: ChatApi) => void | Promise export type EventSubscribers = {[K in CEvt.Tag]?: EventSubscriberFunc} @@ -177,7 +177,7 @@ export class ChatApi { if (subs) { for (const {subscriber, once} of [...subs]) { try { - const p = (subscriber as EventSubscriberFunc)(event) + const p = (subscriber as EventSubscriberFunc)(event, this) if (p instanceof Promise) await p } catch(e) { console.log(`${event.type} event processing error`, e) @@ -187,7 +187,7 @@ export class ChatApi { } for (const r of [...this.receivers]) { try { - const p = r(event) + const p = r(event, this) if (p instanceof Promise) await p } catch(e) { console.log(`${event.type} event processing error`, e) @@ -422,7 +422,18 @@ export class ChatApi { const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings})) if (r.type !== "userContactLinkUpdated") { throw new ChatCommandError("error changing user contact address settings", r) - } + } + } + + async apiSetUserDomain(userId: number, simplexDomain?: string): Promise { + const r = await this.sendChatCmd(CC.APISetUserDomain.cmdString({userId, simplexDomain})) + switch (r.type) { + case "userProfileUpdated": + case "userProfileNoChange": + return r.user + default: + throw new ChatCommandError("error setting SimpleX name", r) + } } /** diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts index 68e787ca3e..83037e310e 100644 --- a/packages/simplex-chat-nodejs/src/bot.ts +++ b/packages/simplex-chat-nodejs/src/bot.ts @@ -35,17 +35,18 @@ const defaultOpts: Required = { export interface BotConfig { profile: T.Profile, + simplexDomain?: string | null, dbOpts: BotDbOpts, options: BotOptions, - onMessage?: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise, + onMessage?: (chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise, // command handlers can be different from commands to be shown in client UI - onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)}, + onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise)}, // If you use `onMessage` and to subscribe "newChatItems" event, exclude content messages from processing // If you use `onCommands` and to subscribe "newChatItems" event, exclude commands from processing events?: api.EventSubscribers } -export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { +export async function run({profile, simplexDomain, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize) const opts = fullOptions(options) if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands) @@ -60,7 +61,8 @@ export async function run({profile, dbOpts, options = defaultOpts, onMessage, on console.log(`Bot address: ${addressLink}`) if (opts.useBotProfile) botProfile.contactLink = addressLink } - await updateBotUserProfile(bot, user, botProfile, opts) + const namedUser = await updateBotSimplexDomain(bot, user, simplexDomain, opts) + await updateBotUserProfile(bot, namedUser, botProfile, opts) return [bot, user, address] } @@ -107,8 +109,8 @@ function mkBotProfile(profile: T.Profile, opts: Required): T.Profile export function subscribeChatItems( bot: api.ChatApi, - onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) | undefined, - commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)} + onMessage: ((chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise) | undefined, + commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise)} ) { bot.on("newChatItems", async ({chatItems}) => { for (const ci of chatItems) { @@ -117,8 +119,8 @@ export function subscribeChatItems( const cmd = util.ciBotCommand(ci.chatItem) const cmdFunc = cmd && (commands[cmd.keyword] || commands[""]) try { - if (cmd && cmdFunc) await cmdFunc(ci, cmd) - else if (onMessage) await onMessage(ci, content.msgContent) + if (cmd && cmdFunc) await cmdFunc(ci, cmd, bot) + else if (onMessage) await onMessage(ci, content.msgContent, bot) } catch (e) { console.log(cmd && cmdFunc ? `${cmd.keyword} command processing error` : "message processing error", e) } @@ -180,15 +182,33 @@ async function createOrUpdateAddress(bot: api.ChatApi, user: T.User, opts: Requi } } - return address + return address +} + +async function updateBotSimplexDomain(bot: api.ChatApi, user: T.User, simplexDomain: string | null | undefined, opts: Required): Promise { + if (simplexDomain === undefined) return user + const domain = simplexDomain === null ? undefined : simplexDomain.toLowerCase() + if (user.profile.contactDomain?.domain === domain) return user + if (!opts.updateAddress) { + console.log("Bot SimpleX name changed") + return user + } + console.log("Bot SimpleX name changed, updating...") + try { + return await bot.apiSetUserDomain(user.userId, domain) + } catch (e) { + console.log("Error updating bot SimpleX name", e) + return user + } } async function updateBotUserProfile(bot: api.ChatApi, user: T.User, profile: T.Profile, opts: Required): Promise { const {userId} = user - if (!equal(util.fromLocalProfile(user.profile), profile)) { + const {contactDomain, ...currentProfile} = util.fromLocalProfile(user.profile) + if (!equal(currentProfile, profile)) { if (opts.updateProfile) { console.log("Bot profile changed, updating...") - const summary = await bot.apiUpdateProfile(userId, profile) + const summary = await bot.apiUpdateProfile(userId, {...profile, contactDomain}) console.log( summary ? `Bot profile updated: ${summary.updateSuccesses} updated contact(s), ${summary.updateFailures} failed contact update(s).` diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index cb92699e69..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.4'; +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-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index dffb0ce1bc..6b8915e93a 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -53,8 +53,8 @@ export function botAddressSettings({addressSettings}: T.UserContactLink): BotAdd } } -export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType}: T.LocalProfile): T.Profile { - const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} +export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain}: T.LocalProfile): T.Profile { + const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain: contactDomain && {domain: contactDomain.domain}} for (const key in profile) { if (typeof (profile as any)[key] === "undefined") delete (profile as any)[key] } diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts index c5a4367b11..b6b92bf93b 100644 --- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -1,4 +1,4 @@ -import {ChatResponse, T} from "@simplex-chat/types" +import {ChatEvent, ChatResponse, T} from "@simplex-chat/types" import * as api from "../src/api" import * as core from "../src/core" @@ -30,6 +30,13 @@ describe("documented success responses", () => { await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []}) }) + it("apiSetUserDomain sets and removes the name", async () => { + const chat = await chatWithResponse({type: "userProfileNoChange", user}) + await expect(chat.apiSetUserDomain(1, "calc.simplex")).resolves.toEqual(user) + await expect(chat.apiSetUserDomain(1)).resolves.toEqual(user) + expect(jest.mocked(core.chatSendCmd).mock.calls.map(([, cmd]) => cmd)).toEqual(["/_set domain 1 calc.simplex", "/_set domain 1"]) + }) + it("apiReceiveFile reports a file cancelled by sender", async () => { const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}}) await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender") @@ -94,6 +101,15 @@ describe("startChat lifecycle", () => { await chat.stopChat() }) + it("passes the chat api to event subscribers", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"}) + jest.mocked(core.chatRecvMsgWait).mockResolvedValueOnce({type: "hostConnected"} as ChatEvent) + const subscriberChat = new Promise(resolve => chat.on("hostConnected", (_event, c) => resolve(c))) + await chat.startChat() + await expect(subscriberChat).resolves.toBe(chat) + await chat.stopChat() + }) + it("receives with a 500 ms wait", async () => { const chat = await chatWithResponses() await chat.recvChatEvent() diff --git a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts index 39f9358996..62ff10aab4 100644 --- a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts @@ -1,6 +1,6 @@ import {ChatEvent, T} from "@simplex-chat/types" import * as api from "../src/api" -import {subscribeChatItems} from "../src/bot" +import {run, subscribeChatItems} from "../src/bot" type Handler = (evt: ChatEvent) => Promise @@ -40,6 +40,14 @@ describe("subscribeChatItems", () => { expect(calls).toEqual(["message:/unknown", "message:hello"]) }) + it("passes the chat api to handlers", async () => { + const {bot, deliver} = fakeBot() + const chats: api.ChatApi[] = [] + subscribeChatItems(bot, async (_ci, _content, chat) => { chats.push(chat) }, {help: async (_ci, _cmd, chat) => { chats.push(chat) }}) + await deliver([item("rcvMsgContent", "/help"), item("rcvMsgContent", "hello")]) + expect(chats).toEqual([bot, bot]) + }) + it("ignores sent items", async () => { const {bot, deliver} = fakeBot() const calls: string[] = [] @@ -48,3 +56,75 @@ describe("subscribeChatItems", () => { expect(calls).toEqual([]) }) }) + +describe("run", () => { + const address = { + connLinkContact: {connFullLink: "full", connShortLink: "short"}, + addressSettings: {businessAddress: false, autoAccept: {acceptIncognito: false}}, + } as unknown as T.UserContactLink + + function fakeChat(contactDomain?: T.SimplexDomainClaim) { + const user = {userId: 1, profile: {displayName: "Old", fullName: "", contactDomain}} as unknown as T.User + const chat = { + on: jest.fn(), + apiGetActiveUser: jest.fn().mockResolvedValue(user), + startChat: jest.fn(), + apiGetUserAddress: jest.fn().mockResolvedValue(address), + apiSetAddressSettings: jest.fn(), + apiSetUserDomain: jest.fn(async (_userId: number, domain?: string) => ({...user, profile: {...user.profile, contactDomain: domain && {domain}}})), + apiUpdateProfile: jest.fn().mockResolvedValue({updateSuccesses: 0, updateFailures: 0}), + } + jest.spyOn(api.ChatApi, "init").mockResolvedValue(chat as unknown as api.ChatApi) + return chat + } + + const runBot = (simplexDomain?: string | null, options = {}) => + run({profile: {displayName: "Calculator", fullName: ""}, simplexDomain, dbOpts: {type: "sqlite", filePrefix: "unused"}, options}) + + const updatedProfile = (chat: ReturnType) => chat.apiUpdateProfile.mock.calls[0][1] + + beforeEach(() => jest.spyOn(console, "log").mockImplementation(() => {})) + afterEach(() => jest.restoreAllMocks()) + + it("sets the configured SimpleX name", async () => { + const chat = fakeChat() + await runBot("Calc.simplex") + expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, "calc.simplex") + expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"}) + }) + + it("removes the SimpleX name set to null", async () => { + const chat = fakeChat({domain: "calc.simplex"}) + await runBot(null) + expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, undefined) + expect(updatedProfile(chat).contactDomain).toBeUndefined() + }) + + it("keeps the stored SimpleX name when it is not configured", async () => { + const chat = fakeChat({domain: "calc.simplex"}) + await runBot() + expect(chat.apiSetUserDomain).not.toHaveBeenCalled() + expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"}) + }) + + it("keeps the SimpleX name when updating the profile", async () => { + const chat = fakeChat({domain: "calc.simplex", proof: {presHeader: "header", signature: "signature"}}) + await runBot("calc.simplex") + expect(chat.apiSetUserDomain).not.toHaveBeenCalled() + expect(updatedProfile(chat).displayName).toBe("Calculator") + expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"}) + }) + + it("continues when the SimpleX name cannot be set", async () => { + const chat = fakeChat() + chat.apiSetUserDomain.mockRejectedValue(new Error("simplexDomainNotReady")) + await expect(runBot("calc.simplex")).resolves.toBeDefined() + expect(updatedProfile(chat).contactDomain).toBeUndefined() + }) + + it("does not change the SimpleX name without updateAddress", async () => { + const chat = fakeChat() + await runBot("calc.simplex", {updateAddress: false}) + expect(chat.apiSetUserDomain).not.toHaveBeenCalled() + }) +}) diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 9c60a54bb1..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.0b4" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.4" # 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) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index e6155c9ce7..365eff7e10 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -59,6 +59,19 @@ def APISetProfileAddress_cmd_string(self: APISetProfileAddress) -> str: APISetProfileAddress_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError +# Set or remove SimpleX name of bot address. The name must be registered with the address short link. +# Network usage: interactive. +class APISetUserDomain(TypedDict): + userId: int # int64 + simplexDomain: NotRequired[str] + + +def APISetUserDomain_cmd_string(self: APISetUserDomain) -> str: + return '/_set domain ' + str(self['userId']) + ((' ' + self.get('simplexDomain')) if self.get('simplexDomain') is not None else '') + +APISetUserDomain_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + # Set bot address settings. # Network usage: interactive. class APISetAddressSettings(TypedDict): diff --git a/plans/2026-09-10-blur-media-resample.md b/plans/2026-09-10-blur-media-resample.md new file mode 100644 index 0000000000..0eb3cfe0ab --- /dev/null +++ b/plans/2026-09-10-blur-media-resample.md @@ -0,0 +1,126 @@ +# Blur media by resampling the preview + +## Problem + +"Blur media" is a per-frame effect. Every frame a blurred image or video preview is drawn, a +Gaussian is convolved over an offscreen layer the size of the drawn media - about 500.dp wide, so +roughly 1500x1125 px and a 6 MB render target on a 3x-density phone, for each blurred item on +screen. + +It also does nothing on Android 8.0 to 11: the setting reads Soft, Medium or Strong and the media +is drawn sharp. + +And the media the blur hides is still fetched in full. An image scrolled past is read from disk, +decoded at its original resolution and kept in the image cache, only ever to be drawn as an +unrecognisable smear. + +## Cause + +`Modifier.blur` compiles to `graphicsLayer { renderEffect = BlurEffect(...) }`. In +`androidx.compose.ui:ui-android:1.8.2`, which is what this build resolves: + +| layer implementation | API range | what `setRenderEffect` does | +| -------------------- | --------- | ------------------------------------- | +| `GraphicsLayerV23` | 23-28 | stores the field, never applies it | +| `GraphicsLayerV29` | 29+ | applies only when `SDK_INT >= 31` | +| `GraphicsViewLayer` | fallback | applies only when `SDK_INT >= 31` | + +`minSdk` is 26, so on Android 8.0 to 11 the effect is silently dropped and the media is drawn +unblurred. The codebase already knows this - `SimpleXAPI.kt` has +`deviceSupportsBlur = ... androidApiLevel >= 32` - but that gates the app bar blur and the bar +alpha, never the media blur. + +The radius is in *display* pixels: `BlurKt` converts it with `toPx` on the `GraphicsLayerScope` +before constructing the `BlurEffect`. The convolution therefore runs over the layer, not over the +source bitmap - its cost does not depend on the source resolution, and it is paid again on every +frame the layer is drawn. + +The loading is independent of the blur. `CIImageView` calls `getLoadedImage`, which reads the file +bytes and decodes a 1000 px bitmap, and then hands the same bytes to Coil at `Size.ORIGINAL`. +`imageLoader` overrides neither `memoryCache` nor `memoryCachePolicy`, so Coil 2.6.0 installs its +default memory cache with strong references and retains those full-resolution bitmaps across items. + +## Fix + +A blur and a downscale discard the same thing: detail finer than their radius. So the preview is +resampled to about one pixel per radius, then doubled back up until its longest side is at least +200 px, and drawn stretched to the item with the bilinear filtering `drawImage` uses by default. +This runs once when the item composes rather than once per frame, needs no `RenderEffect`, and so +works on every supported Android version. + +The doubling is what makes it read as a blur rather than as pixels. Stretching a 30 px image +straight to the screen interpolates between its pixels with a tent, and a tent 12 to 48 screen +pixels wide shows the grid it sits on as soft squares and crosses. Each bilinear doubling +convolves that tent with another one, so three or four doublings turn it into a bell close to the +Gaussian that `Modifier.blur` drew, and the last stretch to the screen is short enough that no +grid shows. Compose cannot do this at draw time: on Android `FilterQuality` only toggles +`Paint.isFilterBitmap`, so `High` is still bilinear there, and only desktop maps it to a cubic +resampler. + +`BLURRED_MEDIA_WIDTH_DP` fixes the strength: it is the width the radius is measured against, so +Soft, Medium and Strong resample to 400/12, 400/24 and 400/48, that is 33, 16 and 8 px wide. It is +the one number to move if the result reads too soft or too sharp. It started at 360, the width the +media is drawn at, and was calibrated to 400 by fitting: for each setting the pipeline's output was +matched against Gaussians of every sigma to find the one it resembles most. At 360 the fitted sigma +was 105%, 120% and 175% of the old blur's for Soft, Medium and Strong, the last because 7 px is too +coarse to hold the picture; at 400 it is 105%, 110% and 95% on a 300 px preview and 90%, 95% and +105% on a 100 px one. 440 sits at 80-95% across the board, so 400 is the closest match and 440 the +next step lighter. + +Resampling is also the better direction for a privacy control. A Gaussian is a convolution and is +in principle partly invertible; a 7 px bitmap does not contain the detail to recover. + +While the blur hides the media, the file is left unread - no disk read, no 1000 px decode, no +full-resolution decode, no cache entry. `blurHidesMedia()` is the single definition that both the +modifier drawing the blur and the decision to load the file consult, so the two cannot disagree +about whether the media is on screen. Getting that wrong in either direction is a defect: one way +the app shows what it promised to hide, the other it hides an image it will never load. + +## Bounds + +The media dimensions come from the sender, so the descent is bounded rather than trusted. Its +first step caps both sides at 512 px and the resampled image at 400 px, so no image, whatever its +shape, produces a large intermediate. That first step samples rather than averages - reading every +pixel of a 4K video frame would stall composition - and the halving that follows averages away +most of what it aliases. The ascent stops as soon as the longest side reaches 200 px, so it never +makes an image with a side of 400 px or more; a 4:3 preview ends at 264x192 px, 203 KB. + +The arithmetic was fuzzed over 4840 dimension and radius combinations: no crash, no +non-termination, every dimension at least 1, no side over 400 px after the first step, largest +single intermediate 1.00 MB (the 512 px first step), largest total churn 1.68 MB. A zero radius +cannot reach the function; a negative one - possible from imported settings - yields a 1x1 image +doubled to a flat 256x256, so it fails toward more blur rather than less. + +## Verification + +Built as a desktop AppImage from this branch and run against a profile with images and videos. +Images and video previews render as a smooth blur with no visible blockiness, and the play button +stays suppressed until the media is revealed. The shipped class was checked to contain the new path +and no remaining reference to `BlurKt` or `BlurredEdgeTreatment`. + +The doubling was chosen with a Java2D harness that runs the same arithmetic through the same +bilinear `drawImage` the desktop `scale` uses, on a 300, 100 and 48 px preview at each radius, +next to the Gaussian `Modifier.blur` drew (sigma 0.577r + 0.5, as `BlurEffect` converts it). Without +the ascent the result shows the resample grid at every radius; with it the grid is gone at 1x and +3x density, and RMSE against the Gaussian falls (300 px, Soft: 5.8 to 5.0; 48 px, Soft: 5.3 to +3.5). Doubling past 200 px changes neither the picture nor the figure. + +No before/after CPU figure is included. The only machine available had no GPU, so Skiko fell back +to software rasterisation; a comparison measured there would overstate the gain, because on real +hardware the old blur was largely GPU work. The claim this change rests on is structural - a +constant recomputed every frame is now computed once - not measured. + +The Android 8.0 to 11 no-op is read from the bytecode of the resolved artifact and has not been +confirmed on a device of that vintage. + +## Out of scope + +iOS is unchanged. It has the same per-frame `.blur(radius:)` in `PrivacyBlur`, and `getLoadedImage` +there does no downsampling at all, so the same change applies - but as its own diff. + +`ChatInfoImage.kt` still blurs blocked members' avatars with `Modifier.blur`, so that one remains a +no-op below API 31. It is a different feature and a different call site. + +`CIVideoView` still extracts a full-resolution frame through the video player while the preview is +blurred, the way `CIImageView` used to. The same treatment applies; the player's lifecycle makes it +a larger change than this one. diff --git a/plans/2026-09-10-member-profile-open-without-core.md b/plans/2026-09-10-member-profile-open-without-core.md new file mode 100644 index 0000000000..01e9cebd3f --- /dev/null +++ b/plans/2026-09-10-member-profile-open-without-core.md @@ -0,0 +1,213 @@ +# Open Member Profile Without Waiting for the Core + +## Context + +`#7388` removed `apiListMembers` from the member profile tap, which was O(group +size) and took seconds in a group with 10000 members. It did not remove the +dependency on the core. + +`showMemberInfo` (ChatView.kt:499) still awaited two calls before the modal was +created: + +```kotlin +val r = chatModel.controller.apiGroupMemberInfo(...) // awaited +val (updatedMember, code) = if (...) { + val memCode = chatModel.controller.apiGetGroupMemberCode(...) // awaited + ... +} +... +ModalManager.end.showModalCloseable(...) { ... } // only now +``` + +Both are single-row queries, 1-2 ms when the core is idle, which is why the tap +looks instant in testing. But `sendCmd` is serialized against everything else the +core is doing, so while it is busy - startup, a batch of incoming events, a long +database operation - the tap produces nothing at all until the core drains. The +profile opens at the speed of the core rather than the speed of the UI. + +The same wait is in `GroupChatInfoView.kt:125` and `MemberSupportChatView.kt:78`. + +## Root Cause + +The modal was created inside the coroutine, after both awaits, and the loaded +values were passed into the view as plain parameters: + +```kotlin +fun GroupMemberInfoView( + ... + connectionStats: ConnectionStats?, + connectionCode: String?, + ... +) +``` + +A plain parameter can only be supplied by a caller that has already waited. + +Each call site also resolved the member through the model and rendered nothing +when it was absent: + +```kotlin +remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> ... } +``` + +In a large group where members were never loaded, that is an empty card - which +is why `#7388` had to write the member into the model *before* showing the modal. + +## Solution + +Open the modal first and let the loaded values arrive afterwards. The two +parameters become state the caller creates at the tap and fills in when the +queries return: + +```kotlin +val connStats = mutableStateOf(null) +val connectionCode = mutableStateOf(null) +ModalManager.end.showModalCloseable(...) { close -> + GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, ...) +} +groupMembersJob = scope.launch(Dispatchers.Default) { + ...unchanged... + connStats.value = r?.second + connectionCode.value = code +} +``` + +The card is shown from the member that is already known at the tap - from the +chat item, or from the members list - with the model's copy preferred once it is +there: + +```kotlin +val member = remember(groupMember.groupMemberId) { + derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember } +}.value +``` + +The `?: groupMember` fallback is what removes the dependency: the card no longer +needs the model to contain the member, so nothing has to happen before the modal +opens. + +## Technical Design + +### The state is created at the tap, not in the view + +`ModalManager.showInView` drives `AnimatedContent` from `modalCount` alone +(ModalView.kt:207), so the card is disposed while a sub-screen is open and +re-composed on return. State held by `remember` inside the view would lose the +loaded values there, and the rows would disappear. A `MutableState` created in +the click handler is captured by the modal lambda and outlives every +recomposition of the card, with no storage of its own. + +### The loaded code cannot come from the model + +`APIGetGroupMemberCode` returns the live code beside the member +(Commands.hs:2047); the member it returns carries only the verification record, +which the core *clears* when the code no longer matches. So the code has to be +held by the UI - it cannot be read back off `member.activeConn`. + +### The rows are reserved, not gated and not inserted + +iOS wraps everything below the action buttons in `if connectionLoaded` +(GroupMemberInfoView.swift:123-288) - the action section, Address, Member, +Servers, Connection failed and For console. Until both queries return its card is +a header and three buttons over blank space. That is tolerable when the core +answers in 1-2 ms; it is the wrong trade for the case this change exists for, +where the core is busy and the wait is what the user sees. + +Letting each row appear as its data arrives is no better: the card is complete +from the first frame but grows under the finger. + +So the rows that depend on the load are rendered from the first frame in their +final positions, disabled, and enabled in place when the data arrives: + +```kotlin +val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded) +... +VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null) +``` + +`SectionItemView` already drops the `clickable` modifier when `disabled` +(Section.kt:213), so a disabled row is inert and identical in size to the enabled +one - nothing moves when it is enabled. + +Only rows that can be predicted locally are reserved: + +- **Verify security code.** The enclosing condition is already + `memberActive || (useRelays && memberCurrent)`, so inside it the loader's + `getCode` reduces to `memberRole != Relay` - known from the member at the tap. +- **Servers.** Reserved as a whole section with a disabled "Change receiving + address" and empty Receiving/Sending rows, for a member the same condition says + is connected. +- **Fix connection is not reserved.** `canSyncConn` needs + `cStats.ratchetSyncAllowed`, which is false except during ratchet desync, so a + placeholder for it would disappear on nearly every open - the same jump in the + opposite direction. + +A third state, `connectionLoaded`, is created at the tap beside the other two and +set when the queries return, including on failure. Without it a failed load would +leave the row disabled forever, because "no code yet" and "no code at all" look +the same. + +Network status is reserved with them. It looks like agent state that cannot be +predicted, but `connSubStatus` (simplexmq Agent.hs:2736) returns `Just` whenever +`rcvQueuesInfo` is non-empty and `Nothing` only when it is empty - the same list +that decides whether "Receiving via" is rendered. The two rows appear together, +so reserving one and not the other is what leaves a gap. + +The remaining shift is "Abort changing address", which renders only while +`rcvSwitchStatus != null` on a receiving queue. That is a switch the user +started, so it is absent in normal use and reserving it would add a row that +disappears on nearly every open. + +iOS needs its gate for a reason Kotlin does not have: `newRole` is `@State` +initialised to a placeholder `.member` and corrected inside `.task` +(GroupMemberInfoView.swift:30, :297), so the role picker must not render before +that runs. Kotlin seeds it from the member inline - +`remember { mutableStateOf(member.memberRole) }` - and is correct on the first +frame. + +### The chat-change guard is kept + +`#7388` added `chatModel.chatId.value != groupInfo.id` so that a load finishing +after the chat changed does not write to the model. The modal now opens before +the guard is reached, and `KeyChangeEffect` (:56) closes the card on chat change +as before, so the guard is left exactly as it was. + +`groupMembersJob.cancel()` is kept in the tap handler: `info` (ChatView.kt:390) +preloads members *before* showing its modal, so without the cancel a pending chat +info load would open its modal on top of the member card. + +## Consequences + +- The card renders from the member known at the tap, so it can be marginally + staler for the duration of the load. iOS has the same window - it renders the + header before `connectionLoaded`. +- "Verify security code" and the Servers section are shown disabled until the + queries return, so the card does not change height as they arrive. This is a + third behaviour, matching neither the old Kotlin (rows inserted as data landed) + nor iOS (whole body hidden until loaded); porting it to iOS would be the way to + converge them. +- "Fix connection" still appears on arrival when the ratchet needs syncing. It is + rare enough that reserving space for it would move the card more often than it + saves. +- "Send message" for a member with `!sendMsgEnabled` does nothing if tapped + before the stats arrive. iOS is identical: `createMemberContactButton` has the + same `else if let connStats` with no else, and renders the action buttons + before `connectionLoaded`. +- The modal is now opened from the click handler on the main thread instead of + from `Dispatchers.Default` / `withBGApi`. All three callers are UI click + handlers, so this is strictly safer - `modalViews` is a plain `ArrayList`. + +## Out of Scope + +- **Collapsing the three copies of the fetch.** The same two queries and the same + condition are still written out at all three call sites. That duplication has + already drifted once - `#7255` widened the condition for channel members in two + of the three and in iOS, and `ChatView.kt` was left behind until `#7486` - and + moving the load into the view, as iOS has done since `#5008`, would remove it. + It is a restructure rather than a fix, so it belongs in its own change. +- Message *Info* (ChatView.kt:706) still loads all members; it needs them to + resolve delivery recipients. A narrower core API would be required. +- Opening a second member's profile while one is already open does not switch the + card: `closeModals()` sets `modalCount` to 0 and `showCustomModal` back to 1, so + `AnimatedContent`'s target never changes. The close and open are back to back in + both the old and the new code, so this is neither introduced nor worsened. 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/plans/2026-09-23-android-app-links-exact-paths.md b/plans/2026-09-23-android-app-links-exact-paths.md new file mode 100644 index 0000000000..605f540924 --- /dev/null +++ b/plans/2026-09-23-android-app-links-exact-paths.md @@ -0,0 +1,86 @@ +# Fix Android app opening simplex.chat web pages as connection links + +## Problem + +Tapping `https://simplex.chat/crowdfunding/` in a chat and confirming "Open" in the +"Open web link?" alert shows "Invalid connection link" instead of opening the page +in the browser. `/call/`, `/credits/` and `/about/` are affected the same way. iOS and +desktop are not. + +## Cause + +"Open" calls `safeOpenUri` → `uriHandler.openUri` (`TextItemView.kt`), which on Android +fires an `ACTION_VIEW` intent. The app's own verified app-link filter in +`android/src/main/AndroidManifest.xml` matches the `simplex.chat` host with: + +```xml + + + + + + +``` + +`pathPrefix` is a plain string prefix, so `/c` also matches `/crowdfunding/`, `/call/` +and `/credits/`, and `/a` matches `/about/`. The single-letter prefixes were added +for short links in #5824. Android delivers the intent back to SimpleX, `processIntent` +sets `appOpenUrl`, and `connectIfOpenedViaUri` → `planAndConnect` tries to connect via +the web page URL and fails. + +iOS is not affected: `website/src/.well-known/apple-app-site-association` lists exact +components (`/c` and `/c/*`, etc.), not string prefixes. Desktop registers no https +handler. + +## Fix + +Make the Android filter match the iOS one: replace each `pathPrefix="/X"` with an exact +`path="/X"` plus `pathPrefix="/X/"`. + +```xml + + +``` + +Connection links carry their data in the fragment (`/contact#/?v=…`, `/a#key`), which +is not part of the matched path, so they still match the exact path. Links with a +trailing slash or a subpath (`/contact/#…`, `/c/…`) match the `/X/` prefix, the same as +`/X/*` on iOS. + +App-link verification (`autoVerify`, `assetlinks.json`) is per host and is not changed +by the path list. + +## Alternatives considered + +- Detecting non-connection URLs in `processIntent` and re-opening them in the browser: + a second `ACTION_VIEW` resolves back to the same filter, so the app would need an + explicit browser component. That still sends every such link through SimpleX first, + including links tapped in other apps. +- `pathPattern`: it has no anchors or alternation that `path` + `pathPrefix` doesn't + already cover, and it is harder to read. + +## Verification + +Checked on an API 27 emulator using stub APKs that contain only the app's intent +filter, with `cmd package query-activities -a android.intent.action.VIEW -c +android.intent.category.BROWSABLE -d `: + +| URL | before | after | +|---|---|---| +| `https://simplex.chat/crowdfunding/` | SimpleX + browser | browser | +| `https://simplex.chat/call/` | SimpleX + browser | browser | +| `https://simplex.chat/credits/` | SimpleX + browser | browser | +| `https://simplex.chat/about/` | SimpleX + browser | browser | +| `https://simplex.chat/contacts` | SimpleX + browser | browser | +| `https://simplex.chat/blog/` | browser | browser | +| `https://simplex.chat/contact#/?v=1` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/contact/#/?v=1` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/invitation#/?v=1` | SimpleX + browser | SimpleX + browser | +| `https://smp4.simplex.im/a#abc` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/c#abc` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/c/abc` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/g#x` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/i#x` | SimpleX + browser | SimpleX + browser | + +The built arm64 debug APK's manifest was dumped with `aapt2` and contains the new +`path` / `pathPrefix` entries. diff --git a/plans/2026-09-24-desktop-video-stop-deadlock.md b/plans/2026-09-24-desktop-video-stop-deadlock.md new file mode 100644 index 0000000000..e9293efba6 --- /dev/null +++ b/plans/2026-09-24-desktop-video-stop-deadlock.md @@ -0,0 +1,49 @@ +# Desktop: app freezes when a video is stopped while its format is being negotiated + +## Problem + +On desktop, playing a video in the chat and then opening it fullscreen (sometimes after a few attempts) leaves the fullscreen view black. The whole app stops responding: the back button, other views and the window close button all do nothing. A process restart is the only way out. + +It reproduces with a video that makes VLC renegotiate its output format repeatedly (the log is full of `main filter error: Failed to create video converter`). Test videos that negotiate once did not reproduce it. + +## Evidence + +Reproduced on a build of `master` (VLC 3.0.23 bundled) by looping: play inline, click to stop, click to open fullscreen, back. It froze on the second iteration. Two `jcmd Thread.print` dumps taken 2 s apart were identical: + +| Thread | Where it is | +|---|---| +| `pool-3-thread-1` (`VideoPlayer.playerThread`) | `libvlc_media_player_stop` ← `VideoPlayer.stop()` (`VideoPlayer.desktop.kt:117`) | +| `Thread-10825` (vlc decoder thread in a JNA callback) | `libvlc_video_get_track` ← `SkiaBitmapBufferFormatCallback.getBufferFormat` (`SkiaBitmapVideoSurface.kt:83`, `player.video().track()`) | +| `AWT-EventQueue-0` | `libvlc_media_player_get_time` ← `VideoPlayer.currentPosition` ← progress coroutine on `Dispatchers.Main` (`VideoPlayer.desktop.kt:94`) | + +The JVM needs `-XX:+StartAttachListener` for `jcmd` to attach, because the Haskell RTS takes over `SIGQUIT`. + +## Cause + +In VLC 3.0.23: + +- `libvlc_media_player_stop` (`lib/media_player.c:1065`) takes `lock_input` and, while holding it, calls `release_input_thread`, which ends in `input_Stop` + `input_Close` (`:183-184`). These wait for the input thread, and its decoder threads, to exit. +- `libvlc_video_get_track` (`lib/video.c:628`) and `libvlc_media_player_get_time` (`lib/media_player.c:1358`) both go through `libvlc_get_input_thread`, which takes the same `lock_input` (`lib/media_player.c:198`). + +`getBufferFormat` runs on the decoder thread during format negotiation. When `stop()` arrives at that moment, `stop` holds the lock and waits for the decoder thread, and the decoder thread waits for the lock in `video().track()`. The UI thread's 50 ms `get_time` poll then blocks on the same lock, which freezes the app. + +The `track()` call was added in #7391, which fixed stretched AV1 playback by using the track size instead of the padded size that libvlc passes. + +## Fix + +Stop calling `video().track()` in `getBufferFormat`. Take the track from `media().info().videoTracks()` only, using the `singleOrNull()` fallback the code already used before VLC had selected a track. + +Why the remaining calls cannot deadlock the same way: + +- `release_input_thread` is called only from `stop` (`:1066`) and `set_media` (`:886`), which hold only `lock_input` while waiting, and from `destroy` (`:819`), which holds no lock. +- `media()` → `libvlc_media_player_get_media` takes `object_lock` (`:922`), which is never held while waiting for the input thread. +- `info()` → `libvlc_media_tracks_get` takes only the media item's lock (`lib/media.c:967`). + +Behaviour change: none for a file with one video track, which is every video SimpleX sends. A file with several video tracks gets the size libvlc passed, as it did before #7391. + +## Alternatives considered + +- **No libvlc calls in the callback at all.** Read the track size on `playerThread` before playback and hand it to the surface. This is more code and adds a metadata read before every play, and the lock analysis above shows it buys nothing for this deadlock. +- **Crop the padded frame instead of asking for the track size.** This does not work: vlc scales the picture to whatever size the callback returns, which is why AV1 was stretched before #7391. +- **Size from vlcj elementary-stream events.** These can arrive after the format has been negotiated, so the first frames would use the padded size. +- **Moving the progress polling and click handlers' libvlc calls off the UI thread.** This would stop any future libvlc stall from freezing the whole app, but it is a separate change across several files and is left out of this bugfix. diff --git a/plans/2026-10-02-blur-media-thumbnails.md b/plans/2026-10-02-blur-media-thumbnails.md new file mode 100644 index 0000000000..6a2039a3da --- /dev/null +++ b/plans/2026-10-02-blur-media-thumbnails.md @@ -0,0 +1,124 @@ +# Blur media thumbnails in reply quotes and the chat list + +## Problem + +"Blur media" hides images and videos in a chat until they are tapped (on desktop, hovered), but +the same media is shown sharp in two other places: + +- the thumbnail of a quoted image or video in a reply (`FramedItemView.ciQuoteView`, 68 dp), in + every chat, search result, report and support chat; +- the chat list, where the last message's image, video or link preview is drawn as a 36 sp + thumbnail next to the message text (`ChatPreviewView.chatItemContentPreview`). + +The chat list is on screen most of the time, so it shows exactly what the setting promised to +hide. + +## Cause + +Small views were excluded on purpose: every call passed `privacyBlur(enabled = !smallView, ...)` +and `desktopModifyBlurredState(!smallView, ...)`, the quote thumbnail never called them, and the +chat-list link preview was a plain `Image`. The chat-list video views (`SmallVideoView`, +`SmallVideoViewEncrypted`) created their own `remember { mutableStateOf(false) }` blur state, so +they could not be blurred even if the modifier were enabled. + +Enabling the existing blur for small views is not enough on its own, because +`ImageBitmap.blurredBy` (#7483) resamples the preview to `400 / radius` pixels across the whole +image, a width calibrated for media drawn about 360 dp wide. Drawn into a 36 sp thumbnail, 33 px +at Soft is almost sharp. + +## Fix + +`privacyBlur` takes `fullSize` in place of `enabled`. Existing call sites keep passing +`!smallView` or `true`, so in-chat media takes the same code as before: the same `remember`ed +resample, drawn with `drawWithContent`. Small views (`fullSize = false`): + +- are resampled relative to their drawn width: `blurredBy` gets the drawn width, and the pixel + count is `400 * drawnWidth / 360 / radius`, so a thumbnail is blurred as much on screen as + media in a chat. The default width is 360, which gives exactly master's `400 / radius` for + in-chat media; +- are drawn as the centre crop of the image scaled by `ContentScale.Crop`, clipped to the view, + so the blurred thumbnail covers the same region as the revealed one and is blurred evenly in + both directions; a whole image squeezed into a square would be blurred up to 4x less along + its long side; +- use `drawWithCache`, so the blur is computed once per size and not on every frame. + +`enabled` is removed from `desktopModifyBlurredState`: once small views are blurred every caller +passes `true`. It stays on `blurHidesMedia`, which still has a real caller (`CIImageView`). + +Wiring: + +- quote thumbnails get one blur state per quote, the item's menu state, and the same reveal as + chat media: a tap reveals, a second tap scrolls to the quoted message, long-press opens the + item menu; +- the chat-list link preview gets a remembered bitmap and blur state; +- `SmallVideoView` and `SmallVideoViewEncrypted` take the item's real blur state, and their play + buttons, which open the video full screen, are hidden while it is blurred. + +## Leaks closed by the change + +Blurring the chat list exposed three ways for a thumbnail to be shown, or stay shown, without +the user revealing it: + +- the chat list is keyed by chat, not by message, so state remembered for one last message was + reused for the next: a new image arriving in a chat whose previous image was revealed would be + shown revealed. `key(ci.id)` around `chatItemContentPreview` gives each message its own state; +- the chat list passed CIImageView and CIVideoView a throwaway `showMenu` that their long-press + and right-click set to `true` and nothing reset. On desktop `desktopModifyBlurredState` stops + re-blurring on hover-out while `showMenu` is true, so a right-clicked thumbnail stayed sharp. + The chat list now passes `noMenu`, which a `LaunchedEffect` resets as soon as it is set: a + menu that closes at once; +- `chatViewScrollState` is a global written only by the chat view's list, and its collector was + cancelled without a final value when a chat closed. Leaving a chat while its list was still + flinging left it `true`, and every reveal in the chat list was undone at once by the Android + re-blur, until another chat was opened. The collector now writes `false` on completion. + +## Bounds + +The preview dimensions come from the sender. `base64ToBitmap` never returns a bitmap with a zero +side (it falls back to an error bitmap), so the crop scale is finite. A zero-size view gives a +zero crop, which `blurredBy` clamps to one pixel. `blurredBy` keeps its existing bounds: the +first step caps both sides at 512 px and the output height at 400 px; a very wide preview widens +the resample to at most its own width while its height drops to a few pixels. The doubling +ascent still targets 200 px, so a thumbnail holds a bitmap of up to 384 px (about 590 KB) - more +than it needs at 36 sp, harmless. + +## Base + +The change is built on `stable` after #7483 (blur media by resampling), because the small-view +blur extends #7483's resample. On stable, `SimpleAndAnimatedImageView` has no `blurred` +parameter, which came from #7365 (animated images) on master; neither #7483 nor this change +depends on it. + +## Verification + +Read-only so far; nothing on this branch has been built or run. + +- In-chat media: `(400f * 360 / 360 / r).toInt()` equals master's `400 / r` for every positive + radius, and the `fullSize` path is master's code re-indented. +- The centre crop was checked by simulating Compose's `ContentScale.Crop` / `Alignment.Center` + placement against the blur's crop for 125,388 combinations of preview size (1 to 10000 px, + extreme aspects), view size (0 to 600 px) and measurement mode: no pixel differed. +- `Modifier.kt` was compiled with Kotlin 2.1.20 and the Compose 1.8.2 plugin against the 1.8.2 + desktop jars, app references stubbed; the bytecode shows the `drawWithCache` lambda is + remembered on the preview and radius, so recomposition does not re-blur. +- Independent adversarial reviews, on master and again on the stable base, ended with two + consecutive passes finding no defect. + +To do before merging: build desktop and Android, and check with a test profile receiving an +image, a video and a link as a chat's last message, and a reply quoting each, at Soft, Medium and +Strong. + +## Out of scope + +- Keyboard and TalkBack: the outer click of a blurred video opens it full screen without + revealing it first. This already happens in a chat on stable and master; fixing it changes + in-chat behaviour, so it is a separate change. TalkBack also cannot reveal a blurred chat-list + image. +- Turning blur on does not re-blur media already on screen on Android (desktop does); chat-list + rows pick it up when recomposed from scratch. +- Chat-list reveals re-blur when a chat is scrolled, not when the chat list is. +- At 36 sp, Medium and Strong both resample to a single pixel: a flat colour. +- Chat-link cards (contact and group profile images), compose-bar previews of your own media and + links, and the full-screen gallery (which shows neighbouring media sharp when swiped) are + unchanged. +- iOS has the same gaps in quotes and the chat list; it follows as a separate change. diff --git a/plans/desktop-bars-blur-performance.md b/plans/desktop-bars-blur-performance.md new file mode 100644 index 0000000000..4e113c90cb --- /dev/null +++ b/plans/desktop-bars-blur-performance.md @@ -0,0 +1,100 @@ +# Plan: make the desktop bars blur lightweight + +## Context + +Settings > Appearance > Blur blurs the app bars: `blurredBackgroundModifier` puts a `BlurEffect` on the bar's layer +and draws a copy of the scrolled content inside it, so the blur is recomputed every frame. On desktop this made +scrolling visibly laggy at the default radius of 50. + +## Measurements + +Measured on the software renderer (`SKIKO_RENDER_API=SOFTWARE`), 1376x768 window, density 1, scrolling a chat of 70 +messages. Frames were counted by capturing the window repeatedly and counting distinct images. + +| | blur off | blur 50 | +|---|---|---| +| visible frames per second while scrolling | 14.5 | 5.2 - 8.7 | + +A standalone harness that replays the same drawing through Skiko's `RenderNode` puts the cost of one bar at 29 ms +(radius 30), 42 ms (radius 50) and 71 ms (radius 100). Two bars are drawn in a chat, so at the default radius the +bars alone cost about 80 ms per frame. + +## Cause + +Skia's raster blur runs at full resolution: `Raster8888BlurAlgorithm` only rescales its input above sigma 135 +(`SkBlurEngine.cpp`), and every radius the slider offers is below that. Each bar therefore blurs +`width x (bar + 6 sigma)` pixels per frame, which at radius 100 is the full window width by 404 rows. + +The obvious remedy, blurring a downscaled copy the way Skia's own GPU path does, cannot be expressed by scaling the +layer: **a layer's image filter is evaluated in device space**, so any scale on the layer or the canvas is applied to +the sigma as well. Narrowing a bar eightfold and blurring it with an eighth of the sigma measured 68.5 ms against +68.4 ms for the untouched blur, and looked wrong, because the reduced sigma ended up applied at full resolution. +This is why the blur appeared weak enough to leave text under the bar readable. + +## Fix + +Draw the bar into an offscreen Skia surface that is up to 8x narrower, blur it there, where the surface's own +resolution is the device resolution and the sigma is not scaled back up, then stretch the finished blur across the +bar. Only the width is reduced: a bar is `AppBarHeight` tall, and taking rows away from it visibly weakens the blur, +which the harness confirmed and which was visible in the app. + +`drawBarsBlurred` is an `expect`/`actual` because it needs Skia surfaces directly. Android is untouched: it blurs +through a `RenderEffect` on the layer, where the GPU blur rescales internally, so it never had this cost. + +Blurring a copy has one consequence for when the bars draw. Previously the bar recorded `drawLayer(graphicsLayer)`, a +live reference that replays whatever the content layer holds at playback, so the bar did not have to be redrawn when +the content scrolled. Reading the content into a surface fixes it at the moment the bar draws instead, so the bar has +to be redrawn whenever that copy is re-recorded, which `AppBarHandler.contentVersion` provides: `copyViewToAppBar` +bumps it after each recording and the bar reads it. Without it the blur inside a chat lagged behind the content, +while the chat list looked correct because its bar happened to be invalidated by other state. + +The surfaces are kept per size rather than one pair overall, because the desktop window's panes have different +widths and their bars are drawn in the same frame, which would otherwise reallocate both surfaces for every bar. + +How far the copy is reduced follows from the blur itself. The width is divided until the blur's own sigma would fall +below 1.5, past which the narrowed copy is barely blurred and stretching it back out shows the steps of the narrowing +rather than a blur. The rows are halved once, with the vertical sigma halved to match. The rule has to be written in +terms of the reduced sigma rather than the radius: stopping as soon as the reduced sigma reached 4, as a first version +did, left the small radiuses barely narrowed at all and made radius 10 cost more than radius 50. + +The blur is the surface's own layer paint rather than a copy between two surfaces, which is both one allocation fewer +and, measured with the surfaces reused as the app reuses them, 3-11% quicker. + +Per bar, against the unmodified blur, with surfaces reused: + +| radius | before | after | difference from the original | +|---|---|---|---| +| 10 | 17.9 ms | 2.7 ms | 0.41 / 255 | +| 50 | 40.9 ms | 3.0 ms | 0.23 / 255 | +| 100 | 72.7 ms | 5.0 ms | 0.27 / 255 | + +The difference column is the root mean square difference over the bar, before the bar's own tint is applied, so what +reaches the screen is smaller still. Redrawing the bar and stretching the blur back over it cost 0.5 ms of those +figures whatever the radius, so that is the floor this approach can reach. + +## Keeping the bars in step with the content + +A layer's own filter follows the content it is attached to for free. Blurring a copy does not: the copy is taken when +the bar draws, so the bar has to be redrawn whenever the content it copies has moved. `AppBarHandler.contentVersion` +carries that, and the scroll containers bump it from a collector on the state they actually scroll. + +Two details are load-bearing. The bump must not happen while drawing: an earlier version bumped it inside +`copyViewToAppBar`, and because Compose Desktop redraws the whole scene per frame, the content's draw invalidated the +bar, which requested another frame, which drew the content again. Measured idle, with no interaction: **96% of a core** +against 0% with the blur off and 0% for the unmodified app. And the version has to come from the state the container +actually scrolls, not from the handler's own: a chat supplies its own `LazyListState`, so watching the handler's left +the chat's blur stale while the chat list looked correct. + +## Rejected + +Reusing the blurred copy while the content is unchanged was measured and dropped: it saves nothing while scrolling, +which is the case that lags, and the chat wallpaper is recorded into its own layer without touching the content +version, so a cached blur could outlast a wallpaper change. + +Blurring the bars of both panes in one pass does not apply: only `DefaultAppBar` is blurred, one per screen, and the +two on a desktop window belong to panes of different widths side by side rather than stacked. + +## Verification + +Built as an AppImage and checked by eye across the whole slider range at bar alpha 0.5, where the blur is most +exposed, on top of the chat list and inside a chat. diff --git a/plans/desktop-oshi-windows-npe.md b/plans/desktop-oshi-windows-npe.md new file mode 100644 index 0000000000..9c8f9b3d54 --- /dev/null +++ b/plans/desktop-oshi-windows-npe.md @@ -0,0 +1,47 @@ +# Desktop: crash at startup on Windows when WMI does not answer + +Fixes #7580. + +## Problem + +The desktop app exits at startup on some Windows 10 machines: + +``` +java.lang.ExceptionInInitializerError + at com.jthemedetecor.OsThemeDetector.getDetector(OsThemeDetector.java:43) + at chat.simplex.common.platform.Resources_desktopKt.(Resources.desktop.kt:29) + ... +Caused by: java.lang.NullPointerException: Cannot invoke "String.compareTo(String)" because "buildNumber" is null + at oshi.software.os.OperatingSystem$OSVersionInfo.(OperatingSystem.java:510) + at oshi.software.os.windows.WindowsOperatingSystem.queryFamilyVersionInfo(WindowsOperatingSystem.java:172) + ... + at com.jthemedetecor.util.OsInfo.(OsInfo.java:27) +``` + +## Cause + +`jSystemThemeDetector:3.8` depends on `oshi-core:5.8.6`. Its `OsInfo` static initialiser calls `OperatingSystem.getVersionInfo()`. + +In oshi 5.8.6, `WindowsOperatingSystem.queryFamilyVersionInfo()` starts with `buildNumber = null` and sets it only if the WMI query for `Win32_OperatingSystem` returns a row. `WmiQueryHandler.queryWMI` returns an empty result on any `COMException` (invalid namespace/class, access denied, broken WMI repository or service) or on a timeout. The `OSVersionInfo` constructor then runs: + +```java +if ("10".equals(version) && buildNumber.compareTo("22000") >= 0) { +``` + +It throws when `os.name` is "Windows 10" and WMI returned nothing. So only machines with a failing WMI query crash; a healthy WMI returns the build number. + +The app calls `OsThemeDetector.getDetector()` in top-level `val` initialisers (`Resources.desktop.kt`, `Theme.desktop.kt`). The failure is an `Error` raised while the class loads, so nothing catches it. `settings` lives in the same file, so the first preference read in `runMigrations` fails and the app exits. + +## Fix + +Add `com.github.oshi:oshi-core:6.4.13` to `desktopMain`. Gradle conflict resolution replaces the transitive 5.8.6 with it. + +- The `buildNumber.compareTo` check is in every oshi release up to 6.3.0 and was removed in 6.3.1. There the constructor just stores the null build number. +- 6.4.13 is built against JNA 5.14.0, the version the app already pins (`jna`, `jna-platform`). 6.3.1 used 5.12.1 and 6.6.x uses 5.16.0. +- jSystemThemeDetector 3.8 uses only `SystemInfo.`, `getOperatingSystem`, `getCurrentPlatform`, `OperatingSystem.getFamily`, `getVersionInfo`, `OSVersionInfo.getVersion` and `PlatformEnum.{WINDOWS,MACOS,LINUX}`. All of them exist unchanged in 6.4.13. + +## Verification + +- `new OSVersionInfo("10", "", null)` throws the reported NPE with oshi 5.8.6 and returns `10` with 6.4.13. +- With 6.4.13, jSystemThemeDetector 3.8 and JNA 5.14.0 on the classpath, `OsThemeDetector.getDetector()` loads and returns a detector. +- `:common:dependencyInsight --configuration desktopRuntimeClasspath --dependency oshi-core` shows `5.8.6 -> 6.4.13`. diff --git a/scripts/badge-service/badge_service.ini.example b/scripts/badge-service/badge_service.ini.example index 1dfaaef95c..2dfcecf3f4 100644 --- a/scripts/badge-service/badge_service.ini.example +++ b/scripts/badge-service/badge_service.ini.example @@ -42,10 +42,18 @@ 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 = off +; Optional. When present, the service creates and manages one group on its first +; start without --run-cli, and logs its join link once, on the start that creates it. +; The link is a bearer secret that stays valid, so keep that log private. The first +; member to join is promoted to owner, so join right away. +; Moderators can /issue and /bulk codes; admins and owners can also /revoke. +; display_name must be a name the chat core accepts unchanged: one it would spell +; differently stops the whole service, payments included, from starting. +;[group] +;display_name = SimpleX Badges +;description = Welcome to the badges group + ; 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 +[dev] accept_unverified_store_receipts = off diff --git a/scripts/calculator-bot/Dockerfile b/scripts/calculator-bot/Dockerfile new file mode 100644 index 0000000000..6e049ec3d6 --- /dev/null +++ b/scripts/calculator-bot/Dockerfile @@ -0,0 +1,121 @@ +# syntax=docker/dockerfile:1 + +# The runtime glibc must be the same or newer. +ARG UBUNTU_LIBS=22.04 +ARG GHC=9.6.3 +ARG CABAL=3.10.2.0 +ARG NODE=24 + +# --------------------------------------------------------------------------- # +# libsimplex +# --------------------------------------------------------------------------- # +FROM ubuntu:${UBUNTU_LIBS} AS libsimplex + +ARG GHC +ARG CABAL +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl git libgmp3-dev libnuma-dev \ + libsqlite3-dev libssl-dev llvm patchelf pkg-config zlib1g-dev && \ + rm -rf /var/lib/apt/lists/* + +ENV BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ + BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} \ + BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} \ + BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true \ + BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true +RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh +ENV PATH="/root/.ghcup/bin:/root/.cabal/bin:$PATH" +ENV CABAL_DIR=/root/.cabal + +WORKDIR /src +COPY cabal.project simplex-chat.cabal README.md PRIVACY.md ./ +COPY scripts/cabal.project.local.linux ./cabal.project.local +# The build script checks the export lists in these. +COPY libsimplex.dll.def flake.nix ./ +COPY scripts/desktop ./scripts/desktop +COPY src ./src + +# VLC is only for the desktop app. +RUN sed -i '/prepare-vlc-linux.sh/d' scripts/desktop/build-lib-linux.sh + +# Other versions' build trees are removed: the script's simplex-chat-* glob +# fails with "cd: too many arguments" when the cache holds more than one. +RUN --mount=type=cache,target=/root/.cabal \ + --mount=type=cache,target=/src/dist-newstyle \ + set -eu; \ + ver=$(sed -n 's/^version: *//p' simplex-chat.cabal | tr -d '[:space:]'); \ + build_dir=dist-newstyle/build/$(uname -m)-linux/ghc-${GHC}/simplex-chat-$ver/build; \ + find dist-newstyle/build -maxdepth 3 -type d -name 'simplex-chat-*' \ + ! -path "*/simplex-chat-$ver" -exec rm -rf {} + 2>/dev/null || true; \ + cabal update; \ + scripts/desktop/build-lib-linux.sh sqlite; \ + mkdir -p /libs; \ + cp "$build_dir"/libsimplex.so "$build_dir"/deps/* /libs/ + +# --------------------------------------------------------------------------- # +# build +# --------------------------------------------------------------------------- # +FROM node:${NODE}-bookworm-slim AS build + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential python3 && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=libsimplex /libs /opt/simplex/libs +# Without it, npm re-running the library's preinstall downloads the released libs. +ENV SIMPLEX_LIBS_DIR=/opt/simplex/libs + +WORKDIR /src +COPY packages/simplex-chat-client/types/typescript packages/simplex-chat-client/types/typescript +RUN cd packages/simplex-chat-client/types/typescript && \ + npm install --no-audit --no-fund && \ + npx tsc + +COPY packages/simplex-chat-nodejs packages/simplex-chat-nodejs +RUN cd packages/simplex-chat-nodejs && \ + npm install --no-audit --no-fund && \ + npm install --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript && \ + npx tsc && \ + cp src/simplex.* dist/ + +WORKDIR /src/apps/simplex-calculator-bot +# No package-lock.json, so no npm ci. +COPY apps/simplex-calculator-bot/package.json ./ +RUN npm install --no-audit --no-fund +RUN npm install --no-save --no-audit --no-fund \ + /src/packages/simplex-chat-nodejs \ + /src/packages/simplex-chat-client/types/typescript + +# calculatorBot.test.ts needs network and tests/fixtures/tls. +COPY apps/simplex-calculator-bot ./ +RUN npm run build && npx vitest run calculator.test.ts + +# --------------------------------------------------------------------------- # +# runtime +# --------------------------------------------------------------------------- # +FROM node:${NODE}-bookworm-slim AS runtime + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates dumb-init libffi8 libgmp10 libssl3 zlib1g && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=build --chown=node:node /src /src + +# A named volume takes this ownership; a bind mount must be owned by uid 1000. +RUN mkdir -p /src/apps/simplex-calculator-bot/data && \ + chown node:node /src/apps/simplex-calculator-bot/data && \ + chmod 0700 /src/apps/simplex-calculator-bot/data + +USER node +WORKDIR /src/apps/simplex-calculator-bot + +ENV NODE_ENV=production + +# node as PID 1 ignores SIGTERM. +ENTRYPOINT ["dumb-init", "--", "node", "dist/index.js"] diff --git a/scripts/calculator-bot/Dockerfile.dockerignore b/scripts/calculator-bot/Dockerfile.dockerignore new file mode 100644 index 0000000000..813001b496 --- /dev/null +++ b/scripts/calculator-bot/Dockerfile.dockerignore @@ -0,0 +1,22 @@ +* + +!cabal.project +!simplex-chat.cabal +!README.md +!PRIVACY.md +!libsimplex.dll.def +!flake.nix +!src +!scripts/cabal.project.local.linux +!scripts/desktop +!packages/simplex-chat-client +!packages/simplex-chat-nodejs +!apps/simplex-calculator-bot + +**/node_modules +**/dist +**/build +**/libs +**/data +**/.env +**/.npmrc diff --git a/scripts/calculator-bot/compose.yml b/scripts/calculator-bot/compose.yml new file mode 100644 index 0000000000..5c5403679e --- /dev/null +++ b/scripts/calculator-bot/compose.yml @@ -0,0 +1,16 @@ +services: + calculator-bot: + image: simplex-calculator-bot:latest + build: + context: ../.. + dockerfile: scripts/calculator-bot/Dockerfile + restart: on-failure:5 + # The name must first be registered with the bot address short link. + # command: + # - "--domain" + # - "yourname.simplex" + volumes: + - calculator-bot-data:/src/apps/simplex-calculator-bot/data + +volumes: + calculator-bot-data: diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 0c8396e5db..71192f34e2 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,23 @@ + + https://simplex.chat/blog/20260722-simplex-public-names.html + +

New in v7.0-v7.0.3.

+

SimpleX public names for channels and businesses (BETA).

+

Better channels:

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

Add a longer description to your profile.

+

Simplified app settings.

+
+
https://simplex.chat/blog/20260722-simplex-public-names.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index a77e1d0c8c..1795f6e865 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5294b7d8b7285a887b849b738e29bec207e5dde8" = "0adjvg6mcyk936qzyl3a4daq68kslzpqhpdh369cf8wwcfqkiqg4"; + "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 4a5fa0e5a7..d233583511 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.7 +version: 7.1.0.10 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -431,7 +431,10 @@ executable simplex-badge-service StrictData other-modules: BadgeService.Catalog + BadgeService.Codes BadgeService.Config + BadgeService.Group + BadgeService.Group.Command BadgeService.Log BadgeService.Options BadgeService.Orders @@ -721,7 +724,10 @@ test-suite simplex-chat-test API.Docs.Types API.TypeInfo BadgeService.Catalog + BadgeService.Codes BadgeService.Config + BadgeService.Group + BadgeService.Group.Command BadgeService.Log BadgeService.Options BadgeService.Orders @@ -744,6 +750,8 @@ test-suite simplex-chat-test Bots.BadgeService.FakeBTCPay Bots.BadgeService.FakeStore Bots.BadgeService.FakeStripe + Bots.BadgeService.GroupIntegrationTests + Bots.BadgeService.GroupTests Bots.BadgeService.StripeTests Bots.BadgeService.WaitersTests Bots.BadgeService.WebTests @@ -778,7 +786,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.* @@ -841,5 +849,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.hs b/src/Simplex/Chat.hs index d80184d4a0..61146c0afa 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, @@ -124,6 +126,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/Archive.hs b/src/Simplex/Chat/Archive.hs index b54c23959b..42da70d7b9 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -124,11 +124,15 @@ deleteStorage = do fs <- lift storageFiles 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/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 2719c588d8..1c9fd9d68b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -145,6 +145,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 @@ -177,6 +178,7 @@ data ChatConfig = ChatConfig cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds + callInvitationTTL :: NominalDiffTime, deliveryWorkerDelay :: Int64, -- microseconds deliveryBucketSize :: Int, webPreviewConfig :: Maybe WebPreviewConfig, @@ -450,7 +452,7 @@ data ChatCommand | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile {userId :: UserId, profile :: Profile} - | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe SimplexDomain} + | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe (StrJSON "SimplexDomain" SimplexDomain)} | APISetContactPrefs {contactId :: ContactId, preferences :: Preferences} | APISetContactAlias {contactId :: ContactId, localAlias :: LocalAlias} | APISetGroupAlias {groupId :: GroupId, localAlias :: LocalAlias} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 433e2605fd..942b07dea0 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -13,12 +13,15 @@ module Simplex.Chat.Core ) where +import Control.Concurrent (forkIO) +import Control.Exception (fromException, mask, onException, throwTo) import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.Reader import qualified Data.ByteString.Char8 as B import Data.List (find) +import Data.Maybe (isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -90,12 +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) - a2 <- async $ chat u cc - waitEither_ a1 a2 + | 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/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 043b06d829..b309687589 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -359,7 +359,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 @@ -1519,7 +1521,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) @@ -1547,9 +1550,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) @@ -1604,7 +1607,8 @@ processChatCommand cxt nm = \case withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) - APISetUserDomain userId domain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> + APISetUserDomain userId strDomain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do + let domain_ = unStrJSON <$> strDomain_ if (claimDomain <$> contactDomain) == domain_ then pure $ CRUserProfileNoChange user else do @@ -2876,7 +2880,8 @@ processChatCommand cxt nm = \case Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do (g@(GIK gInfo _), m) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupMemberById db cxt user gmId - assertUserGroupRole gInfo $ max GRModerator role + -- same rule as role change (moderators grant up to member); pending member's role is a stand-in, so treat it as member + assertUserGroupRole gInfo $ roleRequiredToChange GRMember role case memberStatus m of GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve let GroupInfo {groupProfile = GroupProfile {memberAdmission}} = gInfo @@ -2955,6 +2960,8 @@ processChatCommand cxt nm = \case throwCmdError "can't change role of multiple members when admins selected, or new role is admin" when anyPending $ throwCmdError "can't change role of members pending approval" when (anyRelay || newRole == GRRelay) $ throwCmdError "relay role can't be changed" + -- TODO [multi-owner] allow once owners are added via link data - until then promoted owner would lack link authority + when (useRelays' gInfo && newRole == GROwner) $ throwCmdError "owner role can't be assigned in channels" -- TODO allow moderators (needs UI) - relay is rejected above (anyRelay), so drop the GRAdmin floor: -- TODO assertUserGroupRole gInfo (roleRequiredToChange maxRole newRole) assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) @@ -3999,7 +4006,7 @@ processChatCommand cxt nm = \case dm <- case gInfo_ of Just (Just gInfo@(GIK g gks)) | useRelays' g -> case relayMemberId_ of - Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend + Just relayMemberId -> encodeXMemberConnInfo pqSup gInfo relayMemberId profileToSend Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId" | otherwise -> encodeConnInfoPQ pqSup $ XContact profileToSend (Just $ groupMemberKey gks) (Just xContactId) welcomeSharedMsgId msg_ _ -> @@ -6298,7 +6305,7 @@ chatCommandP = "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), - "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> strP)), + "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> (StrJSON <$> strP))), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f6c1fb3b9e..1d4cf969a8 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -443,10 +443,11 @@ xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOr fInv = (xftpFileInvitation fileName fileSize dummyFileDescr :: FileInvitation) {fileBadge} fsFilePath <- lift $ toFSFilePath filePath let srcFile = CryptoFile fsFilePath cfArgs - aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n) Nothing + aFileId <- withAgent $ \a -> xftpPrepareSendFile a (aUserId user) srcFile (roundedFDCount n) Nothing -- TODO CRSndFileStart event for XFTP chSize <- asks $ fileChunkSize . config ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup_ file fInv (AgentSndFileId aFileId) Nothing chSize + withAgent (`xftpStartSendFile` aFileId) let fileSource = Just $ CryptoFile filePath cfArgs ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP, fileExpires = Nothing, fileProhibited = Nothing} pure (fInv, ciFile, ft) @@ -485,9 +486,10 @@ xftpSndFileRedirect user ftId vfd = do let fileName = "redirect.yaml" file = CryptoFile fileName Nothing fInv = xftpFileInvitation fileName (fromIntegral $ B.length $ strEncode vfd) dummyFileDescr - aFileId <- withAgent $ \a -> xftpSendDescription a (aUserId user) vfd (roundedFDCount 1) + aFileId <- withAgent $ \a -> xftpPrepareSendDescription a (aUserId user) vfd (roundedFDCount 1) chSize <- asks $ fileChunkSize . config - withStore' $ \db -> createSndFileTransferXFTP db user Nothing file fInv (AgentSndFileId aFileId) (Just ftId) chSize + ft <- withStore' $ \db -> createSndFileTransferXFTP db user Nothing file fInv (AgentSndFileId aFileId) (Just ftId) chSize + ft <$ withAgent (`xftpStartSendFile` aFileId) dummyFileDescr :: FileDescr dummyFileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} @@ -838,6 +840,16 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} rcvSize = max (toInteger encSize) redirectSize -- 10 MB margin: encryption and chunk-size rounding make the transfer larger than the advertised size maxRcvSize = min expectedFileSize (toInteger FD.maxFileSizeHard) + toInteger (FD.mb 10 :: Int64) + -- TODO re-enable redirects with relay checks + when (isJust redirect) $ do + cxt <- chatStoreCxt + aci_ <- withStore $ \db -> do + liftIO $ updateFileCancelled db user fileId (CIFSRcvError $ FileErrOther "redirect not allowed") + lookupChatItemByFileId db cxt user fileId + forM_ aci_ $ \aci -> do + cleanupACIFile aci + toView $ CEvtChatItemUpdated user aci + throwChatError $ CEInvalidFileDescription "redirect not allowed" when (rcvSize > maxRcvSize) $ throwChatError $ CEFileRcvChunk "declared file size exceeds the file invitation size" if userApprovedRelays then receive' rd True @@ -852,9 +864,10 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} where receive' :: ValidFileDescription 'FRecipient -> Bool -> CM () receive' rd approved = do - aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs approved + aFileId <- withAgent $ \a -> xftpPrepareReceiveFile a (aUserId user) rd cfArgs approved startReceivingFile user fileId withStore' $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) + withAgent (`xftpStartReceiveFile` aFileId) getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user @@ -905,13 +918,14 @@ receiveViaURI :: User -> FileDescriptionURI -> CryptoFile -> CM RcvFileTransfer receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile {cryptoArgs} = do fileId <- withStore $ \db -> createRcvStandaloneFileTransfer db userId cf fileSize chunkSize -- currently the only use case is user migrating via their configured servers, so we pass approvedRelays = True - aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) description cryptoArgs True - withStore $ \db -> do + aFileId <- withAgent $ \a -> xftpPrepareReceiveFile a (aUserId user) description cryptoArgs True + ft <- withStore $ \db -> do liftIO $ do updateRcvFileStatus db fileId FSConnected updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) getRcvFileTransfer db user fileId + ft <$ withAgent (`xftpStartReceiveFile` aFileId) where FD.ValidFileDescription FD.FileDescription {size = FD.FileSize fileSize, chunkSize = FD.FileSize chunkSize} = description @@ -2434,6 +2448,19 @@ batchSendConnMessagesB mode _user conn msgFlags msgs_ = do batchSndMessagesJSON :: BatchMode -> NonEmpty (Either ChatError SndMessage) -> [Either ChatError MsgBatch] batchSndMessagesJSON mode = batchMessages mode maxEncodedMsgLength . L.toList +compressToLimit :: MonadError ChatError m => Int -> MsgBody -> m MsgBody +compressToLimit maxLen s + | B.length s <= maxLen = pure s + | B.length s' <= maxLen = pure s' + | otherwise = throwError $ ChatError $ CEException "large compressed body" + where + s' = compressedBatchMsgBody_ s + +compressConnInfo :: PQSupport -> MsgBody -> CM MsgBody +compressConnInfo pqSup = compressToLimit $ case pqSup of + PQSupportOn -> maxEncodedInfoLengthPQ + PQSupportOff -> maxEncodedInfoLength + encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString encodeConnInfo = encodeConnInfoPQ PQSupportOff @@ -2442,32 +2469,27 @@ encodeConnInfoPQ pqSup chatMsgEvent = do cxt <- chatStoreCxt let info = ChatMessage {chatVRange = vr cxt, msgId = Nothing, chatMsgEvent} case encodeChatMessage maxEncodedInfoLength info of - ECMEncoded connInfo -> case pqSup of - PQSupportOn | B.length connInfo > maxCompressedInfoLength -> do - let connInfo' = compressedBatchMsgBody_ connInfo - when (B.length connInfo' > maxCompressedInfoLength) $ throwChatError $ CEException "large compressed info" - pure connInfo' - _ -> pure connInfo + ECMEncoded connInfo -> compressConnInfo pqSup connInfo ECMLarge -> throwChatError $ CEException "large info" -- conn-info wrapped as a signed element, so the receiver can verify the signature over the body -encodeSignedConnInfo :: MsgEncodingI e => MsgSigning -> ChatMsgEvent e -> CM ByteString -encodeSignedConnInfo signing chatMsgEvent = do +encodeSignedConnInfo :: MsgEncodingI e => PQSupport -> MsgSigning -> ChatMsgEvent e -> CM ByteString +encodeSignedConnInfo pqSup signing chatMsgEvent = do vr <- chatVersionRange let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent} case encodeChatMessage maxEncodedInfoLength info of - ECMEncoded body -> pure $ encodeBatchElement (Just $ signChatMsgBody signing body) body + ECMEncoded body -> compressConnInfo pqSup $ encodeBatchElement (Just $ signChatMsgBody signing body) body ECMLarge -> throwChatError $ CEException "large signed info" -- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries -- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay -encodeXMemberConnInfo :: GroupInfoKeys -> MemberId -> Profile -> CM ByteString -encodeXMemberConnInfo (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) relayMemberId profileToSend = +encodeXMemberConnInfo :: PQSupport -> GroupInfoKeys -> MemberId -> Profile -> CM ByteString +encodeXMemberConnInfo pqSup (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) relayMemberId profileToSend = let memberPrivKey' = memberPrivKey gks xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey') (Just relayMemberId) bindingData = groupBindingData gInfo memberId (C.publicKey memberPrivKey') signing = MsgSigning CBGroup bindingData KRMember memberPrivKey' - in encodeSignedConnInfo signing xMemberEvt + in encodeSignedConnInfo pqSup signing xMemberEvt deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do @@ -2491,21 +2513,20 @@ deliverMessages msgs = deliverMessagesB $ L.map Right msgs deliverMessagesB :: NonEmpty (Either ChatError ChatMsgReq) -> CM (NonEmpty (Either ChatError ([Int64], PQEncryption))) deliverMessagesB msgReqs = do - msgReqs' <- if any connSupportsPQ msgReqs then liftIO compressBodies else pure msgReqs + msgReqs' <- liftIO compressBodies sent <- L.zipWith prepareBatch msgReqs' <$> withAgent (`sendMessagesB` snd (mapAccumL toAgent Nothing msgReqs')) lift . void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent) lift . withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent where + -- group sends share bodies between connections via VRRef, so the smallest limit applies to the batch + maxLen = if any connSupportsPQ msgReqs then maxEncodedMsgLengthPQ else maxEncodedMsgLength connSupportsPQ = \case Right (Connection {pqSupport = PQSupportOn}, _, _) -> True _ -> False compressBodies = forME msgReqs $ \(conn, msgFlags, (mbr, msgIds)) -> runExceptT $ do mbr' <- case mbr of - VRValue i msgBody | B.length msgBody > maxCompressedMsgLength -> do - let msgBody' = compressedBatchMsgBody_ msgBody - when (B.length msgBody' > maxCompressedMsgLength) $ throwError $ ChatError $ CEException "large compressed message" - pure $ VRValue i msgBody' + VRValue i msgBody -> VRValue i <$> compressToLimit maxLen msgBody v -> pure v pure (conn, msgFlags, (mbr', msgIds)) toAgent prev = \case @@ -3041,7 +3062,7 @@ allowAgentConnectionAsync user conn@Connection {pqSupport} confId gInfo_ msg = d Just gInfo@(GIK g _) | useRelays' g || maxVersion (peerChatVRange conn) >= relayWebCapVersion -> groupMsgSigning False gInfo msg _ -> Nothing dm <- case signing_ of - Just signing -> encodeSignedConnInfo signing msg + Just signing -> encodeSignedConnInfo pqSupport signing msg Nothing -> encodeConnInfoPQ pqSupport msg allowAgentConnectionInfo user conn confId dm diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 1dbf1adcb9..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 @@ -1247,7 +1250,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile - dm <- encodeXMemberConnInfo g relayMemberId profileToSend + dm <- encodeXMemberConnInfo pqSup g relayMemberId profileToSend subMode <- chatReadVar subscriptionMode (cmdId, connId') <- prepareAgentJoin user (Just conn) True cReq joinAgentConnectionAsync cmdId True connId' True cReq dm subMode @@ -2826,7 +2829,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag xGrpLinkAcpt :: GroupInfoKeys -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () xGrpLinkAcpt g@(GIK gInfo@GroupInfo {membership} _) m acceptance role memberId msg brokerTs - | memberRole' m < GRModerator || memberRole' m < role = + -- same rule as role change (moderators grant up to member); pending member's role is a stand-in, so treat it as member + | memberRole' m < roleRequiredToChange GRMember role = messageError "x.grp.link.acpt with insufficient member permissions" | sameMemberId memberId membership = processUserAccepted | otherwise = @@ -2896,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 @@ -2905,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 @@ -3009,15 +3007,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 @@ -3028,9 +3034,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 @@ -3039,18 +3042,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 && 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 + 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/Markdown.hs b/src/Simplex/Chat/Markdown.hs index c877a22c2b..c90cd54fab 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -518,11 +518,11 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' commandTextP :: Parser (Text, Text) -commandTextP = do - (cmd, punct) <- displayNameTextP_ - case T.words cmd of - (keyword : _) | T.all (\c -> isAlpha c || isDigit c || c == '_') keyword -> pure (cmd, punct) - _ -> fail "invalid command keyword" +commandTextP = commandText <$> displayNameTextP_ + where + commandText (cmd, punct) + | T.null cmd = (punct, "") + | otherwise = (cmd, punct) splitPunctuation :: Text -> (Text, Text) splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 4671a927d1..723f78d6db 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -92,7 +92,7 @@ import Simplex.Messaging.Version hiding (version) -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 20 +currentChatVersion = VersionChat 21 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -140,6 +140,9 @@ groupRosterVersion = VersionChat 19 groupMemberKeyVersion :: VersionChat groupMemberKeyVersion = VersionChat 20 +anyTextCommandsVersion :: VersionChat +anyTextCommandsVersion = VersionChat 21 + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -922,8 +925,8 @@ maxEncodedMsgLength :: Int maxEncodedMsgLength = 15602 -- maxEncodedMsgLength - 2222, see e2eEncUserMsgLength in agent -maxCompressedMsgLength :: Int -maxCompressedMsgLength = 13380 +maxEncodedMsgLengthPQ :: Int +maxEncodedMsgLengthPQ = 13380 maxDecompressedMsgLength :: Int maxDecompressedMsgLength = 65536 @@ -932,6 +935,9 @@ maxDecompressedMsgLength = 65536 maxBatchElementCount :: Int maxBatchElementCount = 255 +maxFwdDepth :: Int +maxFwdDepth = 1 + -- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the -- promotion cap over the promoted (member/moderator/admin) set. maxGroupRosterSize :: Int @@ -959,8 +965,8 @@ rosterBlobP = do maxEncodedInfoLength :: Int maxEncodedInfoLength = 14694 -maxCompressedInfoLength :: Int -maxCompressedInfoLength = 10968 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent +maxEncodedInfoLengthPQ :: Int +maxEncodedInfoLengthPQ = 10968 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent data EncodedChatMessage = ECMEncoded ByteString | ECMLarge @@ -1384,8 +1390,8 @@ appBinaryToCM AppMessageBinary {msgId, tag, body} = do msg = \case BFileChunk_ -> BFileChunk <$> (SharedMsgId <$> smpP) <*> (unIFC <$> smpP) -appJsonToCM :: AppMessageJson -> Either String (ChatMessage 'Json) -appJsonToCM AppMessageJson {v, msgId, event, params} = do +appJsonToCM :: Int -> AppMessageJson -> Either String (ChatMessage 'Json) +appJsonToCM fwdDepth AppMessageJson {v, msgId, event, params} = do eventTag <- strDecode $ encodeUtf8 event chatMsgEvent <- msg eventTag pure ChatMessage {chatVRange = maybe chatInitialVRange fromChatVRange v, msgId, chatMsgEvent} @@ -1460,11 +1466,12 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error" XGrpRosterRequest_ -> XGrpRosterRequest <$> opt "version" XGrpMsgForward_ -> do + when (fwdDepth >= maxFwdDepth) $ Left "forward depth exceeds limit" fwdSender <- opt "memberId" >>= \case Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName" Nothing -> pure FwdChannel fwdBrokerTs <- p "msgTs" - XGrpMsgForward (GrpMsgForward {fwdSender, fwdBrokerTs}) <$> p "msg" + XGrpMsgForward (GrpMsgForward {fwdSender, fwdBrokerTs}) <$> (appJsonToCM (fwdDepth + 1) =<< p "msg") XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" XInfoProbeOk_ -> XInfoProbeOk <$> p "probe" @@ -1577,7 +1584,7 @@ instance ToJSON (ChatMessage 'Json) where toJSON = (\(AMJson msg) -> toJSON msg) . chatToAppMessage instance FromJSON (ChatMessage 'Json) where - parseJSON v = appJsonToCM <$?> parseJSON v + parseJSON v = appJsonToCM 0 <$?> parseJSON v instance FromField (ChatMessage 'Json) where fromField = blobFieldDecoder J.eitherDecodeStrict' diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 4de4f4f6b7..35e9d6d372 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [7, 1, 0, 5] +minRemoteCtrlVersion = AppVersion [7, 1, 0, 9] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [7, 1, 0, 5] +minRemoteHostVersion = AppVersion [7, 1, 0, 9] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version @@ -517,7 +517,7 @@ handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBo where parseRequest :: ExceptT RemoteProtocolError IO (C.SbKeyNonce, GetChunk, RemoteCommand) parseRequest = do - (rfKN, header, getNext) <- parseDecryptHTTP2Body encryption request reqBody + (rfKN, header, getNext) <- parseDecryptHTTP2Body maxCommandBodySize encryption request reqBody (rfKN,getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecodeStrict header) replyError = reply . RRChatResponse . RRError processCommand :: User -> C.SbKeyNonce -> GetChunk -> RemoteCommand -> CM () diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index b71c6baed8..6f187b98fb 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -40,7 +40,8 @@ import Simplex.Chat.Controller import Simplex.Chat.Remote.Transport import Simplex.Chat.Remote.Types import Simplex.Chat.Types (BoolDef (..)) -import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.FileTransfer.Description (FileDigest (..), mb) +import Simplex.Messaging.Compression (limitDecompress') import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Lazy (LazyByteString) @@ -180,7 +181,7 @@ sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ encFile_ <- mapM (prepareEncryptedFile sfKN) file_ let req = httpRequest encFile_ encCmd HTTP2Response {response, respBody} <- liftError' (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing - (rfKN, header, getNext) <- parseDecryptHTTP2Body encryption response respBody + (rfKN, header, getNext) <- parseDecryptHTTP2Body maxResponseBodySize encryption response respBody rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecodeStrict header >>= JT.parseEither J.parseJSON . convertJSON hostEncoding localEncoding pure (rfKN, getNext, rr) where @@ -273,9 +274,15 @@ encryptEncodeHTTP2Body corrId cmdKN RemoteCrypto {sessionCode, signatures, compr sign :: C.PrivateKeyEd25519 -> CH.Context SHA512 -> ByteString sign k = C.signatureBytes . C.sign' k . BA.convert . CH.hashFinalize +maxCommandBodySize :: Int +maxCommandBodySize = mb 64 + +maxResponseBodySize :: Int +maxResponseBodySize = mb 512 + -- | Parse and decrypt HTTP2 request/response -parseDecryptHTTP2Body :: HTTP2BodyChunk a => RemoteCrypto -> a -> HTTP2Body -> ExceptT RemoteProtocolError IO (C.SbKeyNonce, ByteString, Int -> IO ByteString) -parseDecryptHTTP2Body rc@RemoteCrypto {sessionCode, signatures, compression} hr HTTP2Body {bodyBuffer} = do +parseDecryptHTTP2Body :: HTTP2BodyChunk a => Int -> RemoteCrypto -> a -> HTTP2Body -> ExceptT RemoteProtocolError IO (C.SbKeyNonce, ByteString, Int -> IO ByteString) +parseDecryptHTTP2Body maxSize rc@RemoteCrypto {sessionCode, signatures, compression} hr HTTP2Body {bodyBuffer} = do (corrId, ct) <- getBody (cmdKN, rfKN) <- ExceptT $ atomically $ getRemoteRcvKeys rc corrId s <- liftError PRERemoteControl $ RC.rcDecryptBody cmdKN ct @@ -287,7 +294,7 @@ parseDecryptHTTP2Body rc@RemoteCrypto {sessionCode, signatures, compression} hr corrIdStr <- liftIO $ getNext 4 ctLenStr <- liftIO $ getNext 4 let ctLen = decodeWord32 ctLenStr - when (ctLen > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize + when (ctLen > fromIntegral maxSize) $ throwError RPEInvalidSize chunks <- liftIO $ getLazy $ fromIntegral ctLen let hc = CH.hashUpdates (CH.hashInit @SHA512) [corrIdStr, ctLenStr] hc' = CH.hashUpdates hc chunks @@ -330,8 +337,5 @@ parseDecryptHTTP2Body rc@RemoteCrypto {sessionCode, signatures, compression} hr getNext sz = getBuffered bodyBuffer sz Nothing $ getBodyChunk hr decompress :: LazyByteString -> ExceptT RemoteProtocolError IO ByteString decompress s - | compression = case Z1.decompress $ LB.toStrict s of - Z1.Error e -> throwError $ RPEInvalidBody e - Z1.Skip -> pure B.empty - Z1.Decompress s' -> pure s' + | compression = liftEitherWith RPEInvalidBody $ limitDecompress' maxSize $ LB.toStrict s | otherwise = pure $ LB.toStrict s diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 746aa7f566..90ab3b3279 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -18,6 +18,7 @@ import Control.Monad (when) import qualified Data.Aeson.TH as J import Data.ByteString (ByteString) import Data.Int (Int64) +import qualified Data.Map.Strict as M import Data.Text (Text) import Data.Word (Word16, Word32) import Simplex.Chat.Remote.AppVersion @@ -73,8 +74,10 @@ getRemoteRcvKeys RemoteCrypto {rcvCounter, chainKeys = TSbChainKeys {rcvKey}, sk | otherwise = do -- prevCorrId < corrId writeTVar rcvCounter corrId skipKeys (prevCorrId + 1) + modifyTVar' skippedKeys $ \m -> M.drop (M.size m - maxSkippedKeys) m Right <$> getKeys maxSkip = 256 + maxSkippedKeys = 1024 getKeys = (,) <$> stateTVar rcvKey C.sbcHkdf <*> stateTVar rcvKey C.sbcHkdf skipKeys !cId = when (cId < corrId) $ do 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/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/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index aec4d76287..7c2e56c2b7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -144,7 +144,7 @@ SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chu SEARCH s USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT c.corr_id, cs.user_id, c.command + SELECT c.corr_id, cs.user_id, c.command, c.created_at FROM commands c JOIN connections cs USING (conn_id) WHERE c.command_id = ? @@ -249,6 +249,26 @@ Plan: SEARCH snd_files USING INDEX idx_snd_files_status_created_at (status=? AND created_at>?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE (f.rcv_file_id = ? OR f.redirect_id = ?) AND r.replica_number = 1 + +Plan: +MULTI-INDEX OR +INDEX 1 +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +INDEX 2 +SEARCH f USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?) +SEARCH c USING COVERING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chunk_id=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + Query: SELECT MAX(internal_id) FROM messages @@ -273,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 = ? @@ -495,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 @@ -567,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 = ? @@ -591,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=?) @@ -7462,6 +7474,10 @@ 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 ? || '%' +Plan: +SCAN chat_items + Query: SELECT chat_item_id FROM chat_items WHERE item_text LIKE '%' || ? || '%' ORDER BY chat_item_id DESC LIMIT 1 Plan: SCAN chat_items @@ -7538,9 +7554,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: @@ -7570,6 +7586,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>?) @@ -7636,9 +7656,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: @@ -7676,6 +7696,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 @@ -7756,6 +7780,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=?) @@ -7764,6 +7792,10 @@ Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1 Plan: SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) +Query: SELECT purchase_key FROM badge_code_redemptions +Plan: +SCAN badge_code_redemptions + Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -7824,6 +7856,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=?) @@ -7904,6 +7940,10 @@ Query: UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, paymen Plan: SEARCH badge_store_receipts USING INDEX sqlite_autoindex_badge_store_receipts_1 (invoice_id=?) +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/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/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 30d674d55f..80f7432da2 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -16,6 +16,7 @@ module Simplex.Chat.Web webPreviewWorker, writeCorsConfig, removeStaleFiles, + publicGroupIdFileName, channelContentChanged, channelProfileUpdated, channelRemoved, @@ -416,9 +417,11 @@ removeStaleFiles dir activeFiles = do let f' = if takeExtension f == ".tmp" then dropExtension f else f base = dropExtension f' in takeExtension f' == ".json" && not (null base) && all isBase64Url base - isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' + isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '=' allFiles <- S.filter isPreviewFile . S.fromList <$> listDirectory dir - mapM_ (\f -> removeFile (dir f)) $ S.difference allFiles activeFiles + forM_ (S.difference allFiles activeFiles) $ \f -> + removeFile (dir f) `catchOwn'` \(e :: SomeException) -> + logError $ "web preview: error removing stale file " <> T.pack f <> ": " <> tshow e toFormattedText :: Text -> Maybe MarkdownList toFormattedText t = case parseMaybeMarkdownList t of diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 93b24fd427..82d8eeed1c 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -11,12 +11,14 @@ module Bots.BadgeService.BotTests where +import BadgeService.Codes (issueOneCode) import BadgeService.Config (BadgeIssuerKey (..), readServiceConfig) +import BadgeService.Group (GroupEvent) import Bots.BadgeService.ConfigTests (withIssuer) import Bots.BadgeService.FakeStore import BadgeService.Options import BadgeService.Service -import BadgeService.Store (NewStorePurchase (..), createStorePurchase) +import BadgeService.Store (IssuedCode (..), NewStorePurchase (..), createStorePurchase, getBadgeCode) import BadgeService.Store.Invoices (markCodePaid) import BadgeService.StoreReceipts (StoreRefusal (..), StoreVerifier (..), noStoreVerifier) import Simplex.Messaging.Agent.Store.DB (Binary (..)) @@ -24,8 +26,9 @@ 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.STM (atomically, readTMVar) +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) import qualified Data.Aeson as J @@ -50,17 +53,19 @@ import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeHash, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode) import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, emptyEntry, endOfMondayAfter) import Simplex.Chat.Badges.Service +import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) import Simplex.Chat.Bot.Store (withDB') -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (CRCustomChatResponse)) +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.Chat.PaymentService (ServicePayment (..)) import Simplex.Chat.PaymentService.Types (InvoiceId (..), PaymentProvider (..)) import Simplex.Chat.Store.Badges (getNextHeldStoreReceipt) +import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) -import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import qualified Simplex.Messaging.Crypto as C @@ -86,20 +91,27 @@ badgeServiceTests = do it "should refuse a code that has not been paid for" testRedeemUnpaidCode it "should refuse a badge code past its redemption deadline" testExpiredCode it "should keep answering a code redeemed before its deadline" testRedeemedBeforeTheDeadline - it "should refuse a revoked badge code, and refuse to revoke it twice" testRevokedCode + it "should refuse a revoked badge code, and report a second revoke as already revoked" testRevokedCode it "should refuse to revoke a code that was redeemed, and keep its badge" testRevokeRedeemedCode it "should answer revoking an unknown code as no such code" testRevokeUnknownCode + it "should answer code_invalid to a code that is both unpaid and revoked" testRevokedUnpaidCode + it "should refuse a revoke with trailing input, leaving the code live" testRevokeRejectsTrailingInput it "should refuse to issue a code with an unknown badge type or a nonsense month count" testIssueRejectsBadArguments it "should refuse a request whose purchaseKey is not the verified signer" testPurchaseKeyMismatch it "should refuse to start unless the issuer secret is the key trusted at its index" testIssuerKeyMustMatchConfig it "should refuse to start when the [issuer] key is not one clients trust" testIssuerIniKeyMustBeTrusted it "should credit a code's months and issue one credential per month" testCodeMonthsRenew it "should return the stored credential for a repeat inside an issued period" testRepeatInsideIssuedPeriod + it "should redeem a multi-use code to its limit with no group to track it" testMultiUseWithoutGroup + it "should not spend a multi-use code again when its holder redeems it after the badge ended" testMultiUseRepeatAfterExpiry + it "should answer internal, spending no use, when a holder's stored credential is unreadable" testUnreadableCredentialIsInternal it "should lapse only the months that elapsed while the client was away" testLapseWhileAway it "should round the last month's expiry up to the end of the Monday after it" testLastMonthExpiryRounds it "should sign a renewal with the master key stored on the purchase" testRenewalSignsWithStoredMasterKey it "should leave the client holding the same ledger rows as the service" testClientReplicatesLedger it "should renew a badge whose credential is lapsing, with no command" testWorkerRenews + it "should keep answering and renewing a holder after its partly used multi-use code is revoked" testRevokedMultiUseHolderRenews + it "should return a multi-use holder's renewed credential on a repeat, apart from a later holder" testMultiUseHoldersRenewApart it "should request from the wake it set a day before the credential lapses" testRequestWakeFires it "should present from the wake it set at the credential's expiry" testPresentWakeFires it "should renew a badge whose newest ledger row is of an unknown type" testRenewsAfterUnknownEntry @@ -167,8 +179,11 @@ badgeServiceTests = do 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 +badgeBotName :: Text +badgeBotName = "SimpleX Badges" + 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} +badgeProfile = Profile {displayName = badgeBotName, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} serviceDbPrefix :: FilePath serviceDbPrefix = "badge_service" @@ -177,19 +192,19 @@ 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 = "SimpleX Badges", + serviceName = badgeBotName, clientService = True, noAddress = False, runCLI = False, @@ -197,6 +212,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) @@ -243,45 +260,56 @@ withBadgeServiceVerifier ps verifierOf test = do svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk, badgeCurrentTime = testClockTime clock} verifier = verifierOf store withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () - runBadgeService verifier svcCfg opts $ \_ -> pure () + -- First start: badge service takes the CreateMyAddress branch. + runBadgeService ps verifier 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" - (sLink, _) <- getContactLinks bs False + -- getContactLinks gives the old-clients line only half a second, which a loaded run can miss. + sLink <- getContactLink_ bs False + bs <##. "The contact link for old clients: " bs <## "auto_accept off" pure sLink let clientCfg = svcCfg {badgeServiceAddress = Just $ either (error . ("bad badge service address: " <>)) id $ strDecode (B.pack bsLink)} - runBadgeService verifier svcCfg opts $ \env -> do + -- Second start: badge service takes the ShowMyAddress branch, then serves the test body. + runBadgeService ps 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, bsStore = store, bsVerifier = verifier} issueCode :: HasCallStack => ChatController -> BadgeType -> Int -> IO BadgeCode issueCode cc badgeType months = issueCodeAs cc badgeType months "free" -revokeCodeAs :: HasCallStack => ChatController -> BadgeCode -> IO T.Text -revokeCodeAs cc code = - sendChatCmdStr cc ("//revoke " <> T.unpack (formatBadgeCode code)) >>= \case - Right (CRCustomChatResponse _ response) -> pure response - Left e -> pure (T.pack (show e)) +revokeCodeAs :: HasCallStack => ChatController -> BadgeCode -> IO (Either T.Text T.Text) +revokeCodeAs cc code = revokeRaw cc (formatBadgeCode code) + +-- | Left is a refusal or a failure the service answers as a command error. +revokeRaw :: HasCallStack => ChatController -> T.Text -> IO (Either T.Text T.Text) +revokeRaw cc args = + sendChatCmdStr cc ("//revoke " <> T.unpack args) >>= \case + Right (CRCustomChatResponse _ response) -> pure (Right response) + Left (ChatError (CECommandError e)) -> pure (Left (T.pack e)) r -> error $ "revoke failed: " <> show (() <$ r) issueCodeAs :: HasCallStack => ChatController -> BadgeType -> Int -> String -> IO BadgeCode issueCodeAs cc badgeType months status = sendChatCmdStr cc ("//issue " <> T.unpack (textEncode badgeType) <> " " <> show months <> " " <> status) >>= \case - Right (CRCustomChatResponse _ response) -> case T.stripPrefix "code " response of + Right (CRCustomChatResponse _ response) -> case T.stripPrefix "Code: " response of Just c | Just code <- parseBadgeCode c -> pure code _ -> 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 :: StoreVerifier -> ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () -runBadgeService verifier 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 -> StoreVerifier -> ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () +runBadgeService ps verifier cfg opts action = do env <- (\s -> s {storeVerifier = verifier}) <$> 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 @@ -380,11 +408,13 @@ testIssueRejectsBadArguments ps = refuses "" issueRaw cc "supporter 255 paid" >>= (`shouldSatisfy` isRight) -issueRaw :: ChatController -> String -> IO (Either () ()) +-- | Left is a refusal or a failure the service answers as a command error. +issueRaw :: HasCallStack => ChatController -> String -> IO (Either T.Text ()) issueRaw cc args = sendChatCmdStr cc ("//issue " <> args) >>= \case Right CRCustomChatResponse {} -> pure $ Right () - _ -> pure $ Left () + Left (ChatError (CECommandError e)) -> pure (Left (T.pack e)) + r -> error $ "issue failed: " <> show (() <$ r) testRedeemSecondCode :: HasCallStack => TestParams -> IO () testRedeemSecondCode ps = @@ -427,11 +457,16 @@ testRedeemSameCodeOtherProfile ps = serviceCmd :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse 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 - _ -> error "badge service request must encode as an object" + badgeServiceResponse bsIssuerKey bsVerifier bsController Nothing (Just purchaseKey) (requestObject purchaseKey request) + +serviceCmdWith :: HasCallStack => BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse +serviceCmdWith key cc trackerQ_ purchaseKey request = + badgeServiceResponse key noStoreVerifier cc trackerQ_ (Just purchaseKey) (requestObject purchaseKey request) + +requestObject :: HasCallStack => C.PublicKeyEd25519 -> BadgeServiceCommand -> J.Object +requestObject purchaseKey request = case J.toJSON BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} of + J.Object o -> o + _ -> error "badge service request must encode as an object" entryOf :: StatementEntry -> (Int, Int, UTCTime) entryOf StatementEntry {changeMonths, balanceMonths, balanceStartTs} = (changeMonths, balanceMonths, balanceStartTs) @@ -454,6 +489,17 @@ credentialOf = \case BSPBadgeCredential {credential} -> credential r -> error $ "expected badgeCredential, got " <> show (J.toJSON r) +shouldAnswerError :: HasCallStack => BadgeServiceResponse -> BadgeServiceErrorCode -> IO () +shouldAnswerError r expected = case r of + BSPError {code = ec} -> ec `shouldBe` expected + _ -> expectationFailure $ "expected " <> show expected <> ", got: " <> show (J.toJSON r) + +codeCounts :: DBStore -> ByteString -> IO (Maybe (Int, Int)) +codeCounts st codeHash = fmap (\IssuedCode {redeemLimit, redeemCount} -> (redeemLimit, redeemCount)) <$> withTransaction st (`getBadgeCode` codeHash) + +codeUses :: ChatController -> BadgeCode -> IO (Maybe (Int, Int)) +codeUses cc code = codeCounts (chatStore cc) (badgeCodeHash code) + nextDue :: [StatementEntry] -> UTCTime nextDue entries = let (_, _, start) = entryOf (last entries) in start @@ -463,6 +509,17 @@ newPurchaseKeys = do (purchaseKey, _) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) (purchaseKey,) <$> generateMasterKey g +redeemAsNewPurchase :: HasCallStack => BadgeServiceEnv -> BadgeCode -> IO BadgeServiceResponse +redeemAsNewPurchase BadgeServiceEnv {bsIssuerKey, bsController} = redeemWithQueue bsIssuerKey bsController Nothing . badgeCodeText + +redeemWithKeys :: HasCallStack => BadgeServiceEnv -> (C.PublicKeyEd25519, BadgeMasterKey) -> BadgeCode -> IO BadgeServiceResponse +redeemWithKeys env (purchaseKey, masterKey) code = serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + +redeemWithQueue :: HasCallStack => BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> Text -> IO BadgeServiceResponse +redeemWithQueue key cc trackerQ_ codeText = do + (purchaseKey, masterKey) <- newPurchaseKeys + serviceCmdWith key cc trackerQ_ purchaseKey BSCRedeemBadgeCode {masterKey, code = codeText} + assertBalance :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> StatementEntry -> IO BadgeServiceResponse assertBalance env purchaseKey lastEntry = serviceCmd env purchaseKey BSCIssueBadge {balance = BadgeBalance {lastEntry}} @@ -470,6 +527,9 @@ assertBalance env purchaseKey lastEntry = expiryOf :: HasCallStack => BadgeServiceResponse -> Maybe UTCTime expiryOf r = (\(BadgeCredential _ _ _ BadgeInfo {badgeExpiry}) -> badgeExpiry) <$> credentialOf r +badgeTypeOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeType +badgeTypeOf r = (\(BadgeCredential _ _ _ BadgeInfo {badgeType}) -> badgeType) <$> credentialOf r + masterKeyOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeMasterKey masterKeyOf r = (\(BadgeCredential _ mk _ _) -> mk) <$> credentialOf r @@ -477,8 +537,8 @@ testCodeMonthsRenew :: HasCallStack => TestParams -> IO () testCodeMonthsRenew ps = withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do code <- issueCode cc BTSupporter 3 - (purchaseKey, masterKey) <- newPurchaseKeys - redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + keys@(purchaseKey, _) <- newPurchaseKeys + redeemed <- redeemWithKeys env keys code let (entries, previousEntryId) = statementOf redeemed previousEntryId `shouldBe` Nothing map entryTag entries `shouldBe` ["code", "badge"] @@ -502,19 +562,82 @@ testRepeatInsideIssuedPeriod :: HasCallStack => TestParams -> IO () testRepeatInsideIssuedPeriod ps = withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do code <- issueCode cc BTSupporter 2 - (purchaseKey, masterKey) <- newPurchaseKeys - redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + keys@(purchaseKey, _) <- newPurchaseKeys + redeemed <- redeemWithKeys env keys code let (entries, _) = statementOf redeemed repeated <- assertBalance env purchaseKey (last entries) map entryTag (fst $ statementOf repeated) `shouldBe` [] credentialOf repeated `shouldBe` credentialOf redeemed +testMultiUseRepeatAfterExpiry :: HasCallStack => TestParams -> IO () +testMultiUseRepeatAfterExpiry ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueMultiUseCode cc BTSupporter 1 2 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + setClockAt bsClock $ dueAtOf rows + 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) + alice <## "badge already redeemed" + waitShownBadge (chatController alice) Nothing + codeUses cc code `shouldReturn` Just (2, 1) + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> redeemFirstBadge bob code + redeemAsNewPurchase env code >>= (`shouldAnswerError` BSECodeUsed) + +testMultiUseWithoutGroup :: HasCallStack => TestParams -> IO () +testMultiUseWithoutGroup ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + code <- issueMultiUseCode cc BTLegend 2 2 + forM_ [1 :: Int, 2] $ \claimed -> do + keys@(_, masterKey) <- newPurchaseKeys + redeemed <- redeemWithKeys env keys code + let (entries, _) = statementOf redeemed + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries `shouldBe` [(2, 2), (-1, 1)] + badgeTypeOf redeemed `shouldBe` Just BTLegend + expiryOf redeemed `shouldBe` Just (endOfMondayAfter (nextDue entries)) + masterKeyOf redeemed `shouldBe` Just masterKey + codeUses cc code `shouldReturn` Just (2, claimed) + redeemAsNewPurchase env code >>= (`shouldAnswerError` BSECodeUsed) + +testUnreadableCredentialIsInternal :: HasCallStack => TestParams -> IO () +testUnreadableCredentialIsInternal ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + code <- issueMultiUseCode cc BTSupporter 1 2 + let redeem keys = redeemWithKeys env keys code + holder <- newPurchaseKeys + redeem holder >>= (`shouldSatisfy` isJust) . credentialOf + withTransaction (chatStore cc) $ \db -> + DB.execute db "UPDATE sx_badge_service_badge_issuances SET credential = ?" (Only (Binary ("not a credential" :: ByteString))) + redeem holder >>= (`shouldAnswerError` BSEInternal) + codeUses cc code `shouldReturn` Just (2, 1) + newPurchaseKeys >>= redeem >>= (`shouldSatisfy` isJust) . credentialOf + codeUses cc code `shouldReturn` Just (2, 2) + redeem holder >>= (`shouldAnswerError` BSEInternal) + codeUses cc code `shouldReturn` Just (2, 2) + +-- Multi-use codes come from the group command, and this service has no group. +issueMultiUseCode :: HasCallStack => ChatController -> BadgeType -> Int -> Int -> IO BadgeCode +issueMultiUseCode cc badgeType months uses = + issueOneCode cc badgeType months CPSFree uses >>= \case + Right (code, _) -> pure code + Left e -> error $ "issuing a multi-use code failed: " <> e + testLapseWhileAway :: HasCallStack => TestParams -> IO () testLapseWhileAway ps = withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do code <- issueCode cc BTSupporter 6 - (purchaseKey, masterKey) <- newPurchaseKeys - redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + keys@(purchaseKey, _) <- newPurchaseKeys + redeemed <- redeemWithKeys env keys code let (entries, _) = statementOf redeemed -- The clock is moved from the anchor because adding months to an already clipped due date would miss the boundary. setClockAt bsClock (addMonths 4 (anchorOf (last entries))) @@ -528,8 +651,8 @@ testLastMonthExpiryRounds :: HasCallStack => TestParams -> IO () testLastMonthExpiryRounds ps = withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do code <- issueCode cc BTSupporter 2 - (purchaseKey, masterKey) <- newPurchaseKeys - redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + keys@(purchaseKey, _) <- newPurchaseKeys + redeemed <- redeemWithKeys env keys code let (entries, _) = statementOf redeemed setClockAt bsClock (nextDue entries) renewed <- assertBalance env purchaseKey (last entries) @@ -542,8 +665,8 @@ testRenewalSignsWithStoredMasterKey :: HasCallStack => TestParams -> IO () testRenewalSignsWithStoredMasterKey ps = withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do code <- issueCode cc BTSupporter 2 - (purchaseKey, masterKey) <- newPurchaseKeys - redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + keys@(purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- redeemWithKeys env keys code masterKeyOf redeemed `shouldBe` Just masterKey let (entries, _) = statementOf redeemed setClockAt bsClock (nextDue entries) @@ -553,14 +676,30 @@ testRenewalSignsWithStoredMasterKey ps = -- This type omits service_created_at and created_at because the client records when it stored a row, not when the service wrote it, so those columns never match. type ReplicatedRow = (Text, Int, Int, UTCTime, Text, Maybe Text) +replicatedColumns :: String +replicatedColumns = + "entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, " + <> "COALESCE(entry_credit_type, entry_debit_type)" + ledgerRows :: ChatController -> String -> IO [ReplicatedRow] ledgerRows ChatController {chatStore} table = withTransaction chatStore $ \db -> DB.query_ db . fromString $ - "SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, " - <> "COALESCE(entry_credit_type, entry_debit_type) FROM " - <> table - <> " ORDER BY entry_id" + "SELECT " <> replicatedColumns <> " FROM " <> table <> " ORDER BY entry_id" + +purchaseLedgerRows :: ChatController -> Text -> IO [ReplicatedRow] +purchaseLedgerRows ChatController {chatStore} entryUuid = + withTransaction chatStore $ \db -> + DB.query + db + ( fromString $ + "SELECT " + <> replicatedColumns + <> " FROM sx_badge_service_badge_ledger " + <> "WHERE badge_purchase_id = (SELECT badge_purchase_id FROM sx_badge_service_badge_ledger WHERE entry_uuid = ?) " + <> "ORDER BY entry_id" + ) + (Only entryUuid) -- the two dates the CLI prints for each row ledgerTimes :: ChatController -> IO [(UTCTime, UTCTime)] @@ -727,6 +866,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 = @@ -764,6 +917,78 @@ testWorkerRenews ps = alice <## "ok" waitShownIssued (chatController alice) +testRevokedMultiUseHolderRenews :: HasCallStack => TestParams -> IO () +testRevokedMultiUseHolderRenews ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueMultiUseCode cc BTSupporter 3 2 + redeemFirstBadge alice code + redeemed <- ledgerRows (chatController alice) "badge_ledger" + revokeCodeAs cc code `shouldReturn` Right "Revoked." + redeemAsNewPurchase env code >>= (`shouldAnswerError` BSECodeInvalid) + codeUses cc code `shouldReturn` Just (2, 1) + -- A holder whose reply was lost retries with the same key and still gets its badge. + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge already redeemed" + codeUses cc code `shouldReturn` Just (2, 1) + let (requestAt, presentAt) = renewalMoments redeemed + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 3 + alice <##. "1: supporter" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + ledgerRows cc "sx_badge_service_badge_ledger" `shouldReturn` renewed + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + +testMultiUseHoldersRenewApart :: HasCallStack => TestParams -> IO () +testMultiUseHoldersRenewApart ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueMultiUseCode cc BTSupporter 3 2 + redeemFirstBadge alice code + redeemed <- ledgerRows (chatController alice) "badge_ledger" + let requestAt = fst $ renewalMoments redeemed + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 3 + alice <##. "1: supporter" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + bobRows <- withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + redeemFirstBadge bob code + ledgerRows (chatController bob) "badge_ledger" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) bobRows `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge")] + let (bobFirstUuid, _, _, bobRedeemedAt, _, _) = head bobRows + bobRedeemedAt `shouldSatisfy` (>= requestAt) + purchaseLedgerRows cc bobFirstUuid `shouldReturn` bobRows + issued <- issuedExpiries (chatController alice) + length issued `shouldBe` 2 + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge already redeemed" + codeUses cc code `shouldReturn` Just (2, 2) + let (aliceFirstUuid, _, _, _, _, _) = head renewed + ledgerRows (chatController alice) "badge_ledger" `shouldReturn` renewed + purchaseLedgerRows cc aliceFirstUuid `shouldReturn` renewed + issuedExpiries (chatController alice) `shouldReturn` issued + alicePurchaseKey <- codeRedemptionKey (chatController alice) + (_, masterKey) <- newPurchaseKeys + repeated <- redeemWithKeys env (alicePurchaseKey, masterKey) code + expiryOf repeated `shouldBe` Just (last issued) + codeUses cc code `shouldReturn` Just (2, 2) + +codeRedemptionKey :: HasCallStack => ChatController -> IO C.PublicKeyEd25519 +codeRedemptionKey ChatController {chatStore} = do + rows :: [Only C.PublicKeyEd25519] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT purchase_key FROM badge_code_redemptions" + case rows of + [Only k] -> pure k + _ -> error $ "expected one code redemption, got " <> show (length rows) + testRequestWakeFires :: HasCallStack => TestParams -> IO () testRequestWakeFires ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> @@ -855,11 +1080,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)" @@ -901,11 +1129,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 " @@ -1054,9 +1285,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 @@ -1183,20 +1412,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 = @@ -1386,7 +1617,7 @@ testRevokeRedeemedCode ps = alice <## "supporter badge - active" alice <##. "expires " refused <- revokeCodeAs cc code - refused `shouldSatisfy` T.isInfixOf "redeemed already, so it cannot be revoked" + refused `shouldBe` Left "Fully redeemed. It cannot be revoked." alice ##> ("/_redeem_badge_code 1 " <> codeArg code) alice <## "badge already redeemed" @@ -1396,18 +1627,37 @@ testRevokeUnknownCode ps = g <- C.newRandom code <- randomBadgeCode g unknown <- revokeCodeAs cc code - unknown `shouldSatisfy` T.isInfixOf "no such code" + unknown `shouldBe` Left "No such code." testRevokedCode :: HasCallStack => TestParams -> IO () testRevokedCode ps = withBadgeService ps $ \clientCfg _ cc -> withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do paid <- issueCodeAs cc BTSupporter 1 "paid" - revokeCodeAs cc paid `shouldReturn` "revoked" + revokeCodeAs cc paid `shouldReturn` Right "Revoked." alice ##> ("/_redeem_badge_code 1 " <> codeArg paid) alice <## "cannot get badge: badge service error: code_invalid" - second <- revokeCodeAs cc paid - second `shouldSatisfy` T.isInfixOf "revoked already" + revokeCodeAs cc paid `shouldReturn` Right "Already revoked." + +testRevokedUnpaidCode :: HasCallStack => TestParams -> IO () +testRevokedUnpaidCode ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + code <- issueCodeAs cc BTSupporter 1 "unpaid" + revokeCodeAs cc code `shouldReturn` Right "Revoked." + redeemAsNewPurchase env code >>= (`shouldAnswerError` BSECodeInvalid) + +testRevokeRejectsTrailingInput :: HasCallStack => TestParams -> IO () +testRevokeRejectsTrailingInput ps = + withBadgeService ps $ \_ _ cc -> do + code <- issueCodeAs cc BTSupporter 1 "paid" + refused <- revokeRaw cc (formatBadgeCode code <> " junk") + -- If the refused call revoked the code anyway, this fails with "Already revoked.". + revokeCodeAs cc code `shouldReturn` Right "Revoked." + refused `shouldBe` Left badgeCmdUsage + +-- The text is spelled out rather than taken from the service, so a change there fails here. +badgeCmdUsage :: Text +badgeCmdUsage = "Usage: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], or //revoke " purchaseCmd :: BadgeMasterKey -> ServicePayment -> BadgeServiceCommand purchaseCmd masterKey payment = BSCPurchaseBadge {masterKey, payment, upgrade = Nothing} @@ -1423,9 +1673,6 @@ 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 @@ -1722,7 +1969,7 @@ testPurchaseStash ps = 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 <### [StartsWith (openPurchase ""), "store purchase resolved"] alice ##> refused alice <## "cannot get badge: badge service error: receipt_invalid" records `shouldReturn` 1 @@ -1749,7 +1996,7 @@ testPurchaseStashReceiptUsed ps = alice ##> purchase inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] bob ##> purchase - inAnyOrder bob [[openPurchase ""], ["store purchase resolved"]] + bob <### [StartsWith (openPurchase ""), "store purchase resolved"] bob ##> purchase bob <## "cannot get badge: badge service error: receipt_used" storeReceiptRows (chatController bob) `shouldReturn` [(1, Nothing, True)] @@ -1902,14 +2149,12 @@ testInvoiceAgedOut ps = 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 + <### [ ConsoleString ("store purchase open: invoice " <> unpaid), + ConsoleString ("store purchase open: invoice " <> presented), + StartsWith ("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 ") @@ -1998,7 +2243,7 @@ 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 - inAnyOrder alice [[openPurchase ""], ["store purchase resolved"]] + alice <### [StartsWith (openPurchase ""), "store purchase resolved"] alice ##> refused alice <## "cannot get badge: badge service error: receipt_invalid" alice ##> "/_badge state 1" @@ -2016,11 +2261,11 @@ testStoreSettledOtherProfile ps = 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 + <### [ StartsWith ("[user: alice] store purchase open: invoice " <> shown <> ", transaction "), + ConsoleString ("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)" @@ -2049,7 +2294,7 @@ 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")) - inAnyOrder alice [["store purchase open: invoice " <> unpaid, openPurchase ""], ["store purchase resolved"]] + alice <### [ConsoleString ("store purchase open: invoice " <> unpaid), StartsWith (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)) @@ -2061,7 +2306,13 @@ testStoreReceiptCleanup ps = 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 "]] + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "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 @@ -2102,8 +2353,9 @@ 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 (100 :: Int) +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 @@ -2122,13 +2374,13 @@ heldStoreReceipts ChatController {chatStore} = pure n waitHeldStoreReceipts :: HasCallStack => ChatController -> Int -> IO () -waitHeldStoreReceipts cc n = loop (100 :: Int) +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 (100 :: Int) +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) diff --git a/tests/Bots/BadgeService/ConfigTests.hs b/tests/Bots/BadgeService/ConfigTests.hs index 4e797e6335..f8ce3038f5 100644 --- a/tests/Bots/BadgeService/ConfigTests.hs +++ b/tests/Bots/BadgeService/ConfigTests.hs @@ -36,6 +36,7 @@ badgeConfigTests = describe "badge service config" $ do it "accepts a payment tolerance and refuses one that settles for a satoshi" testPaymentTolerance it "refuses a host that would send the api key in the clear" testHostMustBeHttps it "names a setting nothing reads, in every section it parses" testUnknownKeysAreNamed + it "ignores a section nothing reads rather than refusing the file" testUnknownSectionStillBoots it "refuses a file one malformed line would silently truncate" testMalformedLineRefused it "accepts a comment or blank line after the last setting" testTrailingCommentIsAccepted it "reports a missing file rather than throwing" testMissingFileIsReported @@ -46,20 +47,21 @@ badgeConfigTests = describe "badge service config" $ do it "refuses an index that is not a positive whole number" testIssuerIndexInvalid it "refuses a private key that is not a valid issuer secret" testIssuerBadSecret it "names the old default and key_ settings, then refuses the boot" testIssuerOldFormat - it "leaves chat redemption off when the dev section is absent" testDevRedeemAbsent - 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 + groupConfigTests fullIni :: T.Text fullIni = T.unlines [ "[listener]", "static_dir = /srv/badges", + -- [group] stays before [btcpay], since tests append btcpay keys to this fixture. + "[group]", + "display_name = SimpleX Badges", + "description = badge ops desk", "[btcpay]", "host = https://btcpay.example.org", "api_key = token-value", @@ -228,19 +230,25 @@ testExpiryMinutes = do testUnknownKeysAreNamed :: IO () testUnknownKeysAreNamed = - withIni (fullIni <> "speed_polcy = LowSpeed\ntrust_forwaded_for = on\n[poll]\nwaiting_secnds = 5\n[dev]\nchat_redem = on\n") $ \p -> do + withIni (fullIni <> "speed_polcy = LowSpeed\ntrust_forwaded_for = on\n[poll]\nwaiting_secnds = 5\n[stripe]\nsecret_ky = rk_test_x\n") $ \p -> do Right ini <- readIniFile p - unknownKeys ini `shouldMatchList` ["btcpay.speed_polcy", "btcpay.trust_forwaded_for", "poll.waiting_secnds", "dev.chat_redem"] + unknownKeys ini `shouldMatchList` ["btcpay.speed_polcy", "btcpay.trust_forwaded_for", "poll.waiting_secnds", "stripe.secret_ky"] withIni (T.replace "[btcpay]" "[btcpai]" fullIni) $ \wrongSection -> do Right sectionIni <- readIniFile wrongSection unknownKeys sectionIni `shouldContain` ["[btcpai]"] - withIni ("chat_redeem = on\n" <> fullIni) $ \stray -> do + withIni ("static_dir = /srv/badges\n" <> fullIni) $ \stray -> do Right strayIni <- readIniFile stray - unknownKeys strayIni `shouldContain` ["chat_redeem, written above the first section header"] + unknownKeys strayIni `shouldContain` ["static_dir, written above the first section header"] withIni fullIni $ \clean -> do Right cleanIni <- readIniFile clean unknownKeys cleanIni `shouldBe` [] +testUnknownSectionStillBoots :: IO () +testUnknownSectionStillBoots = + parseIni (fullIni <> "[legacy]\nsetting = on\n") >>= \r -> case r of + Right cfg -> lStaticDir (listener cfg) `shouldBe` "/srv/badges" + Left e -> expectationFailure ("a section nothing reads must be ignored, not refused: " <> e) + -- | The ini parser stops at the first line it cannot read and keeps what it has, so without this a -- missing `=` in [listener] would silently drop every section below it, and the provider with it. testMalformedLineRefused :: IO () @@ -256,7 +264,7 @@ testTrailingCommentIsAccepted :: IO () testTrailingCommentIsAccepted = do accepts (fullIni <> "; rotated the api key on 2026-09-01\n") accepts (fullIni <> "\n\n") - accepts (fullIni <> "[dev]\n; chat_redeem = on\n") + accepts (fullIni <> "[poll]\n; idle_seconds = 5\n") accepts (fullIni <> "# a hash comment, with no newline after it") where accepts t = @@ -349,26 +357,6 @@ testIssuerOldFormat = do unknownKeys ini `shouldMatchList` ["issuer.default", "issuer.key_1"] issuerRefusal old `shouldReturn` "issuer.index is required" -testDevRedeemAbsent :: IO () -testDevRedeemAbsent = withIni fullIni $ \p -> do - Right cfg <- readServiceConfig p - devChatRedeem cfg `shouldBe` False - -testDevRedeemOn :: IO () -testDevRedeemOn = withDev "chat_redeem = on\n" $ \r -> case r of - Right cfg -> devChatRedeem cfg `shouldBe` True - Left e -> expectationFailure ("[dev] chat_redeem = on is legal: " <> e) - -testDevRedeemOff :: IO () -testDevRedeemOff = withDev "chat_redeem = off\n" $ \r -> case r of - Right cfg -> devChatRedeem cfg `shouldBe` False - Left e -> expectationFailure ("[dev] chat_redeem = off is legal: " <> e) - -testDevRedeemNotBoolean :: IO () -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 @@ -391,3 +379,59 @@ testDevUnverifiedReceiptsNotBoolean = withDev "accept_unverified_store_receipts withDev :: T.Text -> (Either String ServiceConfig -> IO a) -> IO a withDev keys act = withIni (fullIni <> "[dev]\n" <> keys) $ \p -> readServiceConfig p >>= act + +parseIni :: T.Text -> IO (Either String ServiceConfig) +parseIni t = withIni t readServiceConfig + +groupConfigTests :: Spec +groupConfigTests = describe "group config" $ do + it "parses display_name and description" testGroupNameAndDescription + it "defaults description to Nothing" testGroupNoDescription + it "treats a blank description as absent" testGroupBlankDescription + it "refuses a display_name no group can be created under" testGroupInvalidName + it "suggests no name when no character of display_name is valid" testGroupNoValidName + it "requires display_name when the section is present" testGroupMissingName + it "leaves group Nothing when the section is absent" testGroupAbsent + +listenerIni :: T.Text +listenerIni = "[listener]\nstatic_dir = /srv/web\n" + +groupIni :: T.Text -> IO (Either String ServiceConfig) +groupIni body = parseIni (listenerIni <> "[group]\n" <> body) + +testGroupNameAndDescription :: IO () +testGroupNameAndDescription = do + r <- groupIni "display_name = SimpleX Badges\ndescription = Welcome\n" + fmap group r `shouldBe` Right (Just GroupConfig {gDisplayName = "SimpleX Badges", gDescription = Just "Welcome"}) + +testGroupNoDescription :: IO () +testGroupNoDescription = do + r <- groupIni "display_name = X\n" + fmap group r `shouldBe` Right (Just GroupConfig {gDisplayName = "X", gDescription = Nothing}) + +testGroupBlankDescription :: IO () +testGroupBlankDescription = do + r <- groupIni "display_name = X\ndescription = \n" + fmap group r `shouldBe` Right (Just GroupConfig {gDisplayName = "X", gDescription = Nothing}) + +testGroupInvalidName :: IO () +testGroupInvalidName = do + r <- groupIni "display_name = Значки (staging)\n" + case r of + Left e -> e `shouldBe` "group.display_name \"Значки (staging)\" is not a valid group name, the closest valid name is \"Значки staging\"" + Right cfg -> expectationFailure ("a name the core refuses must not boot, and this one kept " <> show (group cfg)) + +testGroupNoValidName :: IO () +testGroupNoValidName = do + r <- groupIni "display_name = !!!\n" + fmap group r `shouldBe` Left "group.display_name \"!!!\" is not a valid group name" + +testGroupMissingName :: IO () +testGroupMissingName = do + r <- groupIni "description = hi\n" + fmap group r `shouldBe` Left "group.display_name is required" + +testGroupAbsent :: IO () +testGroupAbsent = do + r <- parseIni listenerIni + fmap group r `shouldBe` Right Nothing diff --git a/tests/Bots/BadgeService/GroupIntegrationTests.hs b/tests/Bots/BadgeService/GroupIntegrationTests.hs new file mode 100644 index 0000000000..8b4cbc249a --- /dev/null +++ b/tests/Bots/BadgeService/GroupIntegrationTests.hs @@ -0,0 +1,1379 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- Members' messages reach a console over separate connections in no fixed order, so gating is asserted from the bot's store. +module Bots.BadgeService.GroupIntegrationTests (badgeGroupIntegrationTests) where + +import BadgeService.Config (BadgeIssuerKey (..), GroupConfig (..)) +import BadgeService.Group (GroupAction (..), GroupEvent (..), codeInTracker, ensureManagedGroup) +import BadgeService.Group.Command (maxUses) +import BadgeService.Options (BadgeServiceOpts (..)) +import BadgeService.Service (ServiceState (..), badgeService, newServiceState) +import BadgeService.Store (ManagedGroup (..), getManagedGroup) +import Bots.BadgeService.BotTests (badgeBotName, badgeProfile, badgeTypeOf, credentialOf, entryOf, issueCode, issueRaw, mkBadgeServiceOpts, newPurchaseKeys, redeemWithQueue, requestObject, revokeRaw, serviceDbPrefix, shouldAnswerError, statementOf, stopBadgeService, testIssuerKeyIdx) +import ChatClient +import ChatTests.DBUtils +import ChatTests.Utils +import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (Async, async, cancel, poll, waitCatchSTM) +import Control.Concurrent.STM (TQueue, atomically, newTQueueIO, orElse, readTMVar, readTQueue, readTVarIO, tryReadTMVar, writeTQueue) +import Control.Exception (IOException, bracket, catch, finally, throwIO, try) +import Control.Monad (forM_, guard, mfilter, replicateM_, unless, void, when) +import Data.Either (isRight) +import Data.Int (Int64) +import Data.List (isInfixOf) +import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe, isJust, listToMaybe) +import Data.String (fromString) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Calendar (fromGregorian) +import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime) +import Network.Socket (Family (..), SockAddr (..), SocketType (..), close, connect, defaultProtocol, socket, tupleToHostAddress) +import Network.Wai.Handler.Warp (openFreePort) +import Simplex.Chat.Badges (BadgeType (..)) +import Simplex.Chat.Badges.Code (formatBadgeCode, parseBadgeCode, randomBadgeCode) +import Simplex.Chat.Badges.Service +import Simplex.Chat.Controller (ChatCommand (APIDeleteChatItem), ChatConfig (..), ChatController (..), ChatResponse (CRChatItemsDeleted)) +import Simplex.Chat.Core (sendChatCmd, sendChatCmdStr) +import Simplex.Chat.Messages (ChatRef (ChatRef), ChatType (CTGroup)) +import Simplex.Chat.Messages.CIContent (CIDeleteMode (CIDMInternal)) +import Simplex.Chat.Types (AgentInvId (..)) +import Simplex.Chat.Types.Preferences (ChatBotCommand (..), FullDeleteGroupPreference (..), GroupFeatureEnabled (..), GroupFeatureI (..), GroupPreferences (..), HistoryGroupPreference (..), SGroupFeature (..), commands_, emptyGroupPrefs, getGroupPreference) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Agent (disposeAgentClient) +import Simplex.Messaging.Agent.Protocol (AConnShortLink (..), ConnShortLink (..), ContactConnType (..)) +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Crypto.BBS (bbsKeyGen) +import Simplex.Messaging.Encoding.String (strDecode) +import Simplex.Messaging.Util (tshow) +import System.Directory (createDirectoryIfMissing) +import System.Exit (ExitCode (..)) +import System.FilePath (()) +import System.Timeout (timeout) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (FromRow, Only (..)) +#else +import Database.SQLite.Simple (FromRow, Only (..)) +#endif +import Test.Hspec hiding (it) + +badgeGroupIntegrationTests :: SpecWith TestParams +badgeGroupIntegrationTests = do + it "creates the group and a join link on first start; on restart reuses it, re-advertises commands and keeps other preferences" testGroupCreateReuse + it "keeps the group's name and description when the configured ones change" testConfigNotApplied + it "promotes the first joiner to owner and leaves a later joiner a member" testFirstJoinerPromoted + it "promotes nobody after the first promotion failed" testFailedPromotionNotRetried + it "promotes the earliest member on start when their join was never handled" testMissedOwnerPromotedOnStart + it "promotes the earliest member, not a later joiner, when no owner was set up" testEarliestPromotedOnLaterJoin + it "skips a member still joining when promoting on start" testJoiningMemberSkippedOnStart + it "only records an owner made by hand on start, promoting nobody" testHandMadeOwnerKeptOnStart + it "promotes a member on start when the owner made by hand has left" testLeftOwnerReplacedOnStart + it "promotes nobody on start once the owner was set up, even with no owner left" testSetUpOwnerNotReplacedOnStart + it "does not serve a group its owner deleted" testDeletedGroupNotServed + it "does not serve a group the service itself deleted" testOwnDeletedGroupNotServed + it "drops the old group's trackers when it creates a new group" testReplacedGroupDropsTrackers + it "stops rather than create a second group when the group lookup fails" testGroupLookupFailureStops + it "stops the service's lanes when its runner is cancelled" testCancelStopsLanes + it "issues on a privileged member's command, ignores a plain member's" testRoleGatedIssue + it "ignores a command from a privileged member blocked for all" testBlockedMemberCommandIgnored + it "ignores a command sent in a member support scope" testSupportScopeIgnored + it "ignores a command sent as a live message" testLiveMessageIgnored + it "ignores an event for another group" testOtherGroupEventIgnored + it "issues a batch of redeemable single-use codes of the asked type and months on /bulk" testBulkIssue + it "issues an /issue code for the months the command asked for" testIssueMonths + it "answers a failed /issue or //issue without naming what failed in the store" testIssueFailureIsOpaque + it "lists the codes a /bulk issued before an insert failed" testBulkPartialFailureListsIssued + it "answers a failed /revoke without naming what failed in the store" testRevokeFailureIsOpaque + it "tracks a multi-use code in one message across redemptions and refuses it when used up" testMultiUseTracker + it "leaves a used-up code's tracker as it is on a stale refresh" testStaleRefreshIgnored + it "keeps each multi-use code's counter to its own claims" testCountersKeptApart + it "refreshes the tracker for a redemption served with no group lane" testLanelessRedeemRefreshesTracker + it "refreshes the tracker for a redemption from the service's request queue" testQueuedRequestRefreshesTracker + it "refreshes the tracker inline for a queued redemption after a restart without [group]" testQueuedRequestWithoutGroupConfig + it "reposts and re-anchors the tracker past the edit window" testTrackerRepost + it "reposts and re-anchors the tracker when the core refuses the edit" testRefusedEditReposted + it "keeps the tracker anchored when the repost cannot be sent" testFailedRepostDropped + it "never reposts a tracker deleted on the service's side, publishing the code no further" testDeletedTrackerNotReposted + it "never reposts a tracker an owner deleted for everyone" testModeratedTrackerNotReposted + it "revokes a code whose tracker was deleted on the service's side without reposting it" testDeletedTrackerNotRepostedOnRevoke + it "publishes nothing when a code whose tracker was deleted is used up" testDeletedTrackerUsedUp + it "revokes a code whose tracker an owner deleted for everyone without reposting it" testModeratedTrackerNotRepostedOnRevoke + it "revokes a code on /revoke, answers a repeat as already revoked and an unknown code as no such code" testGroupRevoke + it "retires the tracker message when a multi-use code is revoked" testRevokeRetiresTracker + it "retires the tracker message when the service command revokes the code" testServiceRevokeRetiresTracker + it "retires a tracker left behind when the code is revoked again" testGroupRevokeRepairsTracker + it "retires a tracker left behind when the service command revokes again" testServiceRevokeRepairsTracker + it "posts one retired tracker however often a revoke past the window repeats" testRevokeRepeatPastWindow + it "reconciles a used-up tracker whose refresh was lost, in place" testUsedUpTrackerReconciledOnRestart + it "reconciles a tracker with uses left whose refresh was lost" testStalledTrackerReconciledOnRestart + it "retires on restart a tracker whose revoke never reached it" testRevokedTrackerReconciledOnRestart + it "reconciles every stalled tracker on restart, not only the first" testEveryStalledTrackerReconciled + it "leaves a tracker it cannot edit alone rather than publishing the code again" testUneditableTrackerLeftAlone + +groupName :: String +groupName = "testbadges" + +groupDescr :: Text +groupDescr = "badge ops desk" + +testGroupConfig :: GroupConfig +testGroupConfig = GroupConfig {gDisplayName = T.pack groupName, gDescription = Just groupDescr} + +revokeReply :: Text -> Text -> Text +revokeReply code outcome = code <> ": " <> outcome + +revokeCmd :: Text -> String +revokeCmd code = "/revoke " <> T.unpack code + +revokeInGroup :: HasCallStack => ChatController -> TestCC -> Text -> Text -> IO () +revokeInGroup cc member code outcome = do + sendGroupCmd member (revokeCmd code) + waitStoredItem cc (revokeReply code outcome) + +-- The name is quoted because console output quotes a name with a space. +botName :: String +botName = "'" <> T.unpack badgeBotName <> "'" + +data GroupSvc = GroupSvc + { gsCfg :: ChatConfig, + gsKey :: BadgeIssuerKey, + gsStaticDir :: FilePath, + gsPs :: TestParams + } + +prepareGroupService :: HasCallStack => TestParams -> IO GroupSvc +prepareGroupService ps@TestParams {tmpPath} = + bbsKeyGen >>= \case + Left e -> error $ "bbsKeyGen failed: " <> e + Right (pk, sk) -> do + let gsKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk} + gsCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk} + gsStaticDir = tmpPath "badge_group_static" + createDirectoryIfMissing True gsStaticDir + withNewTestChatCfg ps gsCfg serviceDbPrefix badgeProfile $ \_ -> pure () + pure GroupSvc {gsCfg, gsKey, gsStaticDir, gsPs = ps} + +runGroupService :: HasCallStack => GroupSvc -> (ChatController -> ServiceState -> Int64 -> IO a) -> IO a +runGroupService svc = runGroupServiceAs svc (Just testGroupConfig) + +runGroupServiceAs :: HasCallStack => GroupSvc -> Maybe GroupConfig -> (ChatController -> ServiceState -> Int64 -> IO a) -> IO a +runGroupServiceAs svc groupCfg action = do + (t, cc, env, port) <- startGroupService svc groupCfg + -- The web listener starts after group start-up, so the stop cannot land inside start-up's database calls. + let started = pollUntilTrue (listening port) >> pollUntil (getStoredGroup cc) + -- A stop inside a lane's database call leaves a statement open, and closing the store then fails. + settle gid = when (isJust groupCfg) $ do + status <- memberStatus cc gid badgeBotName + forM_ status $ \s -> unless (s `elem` ["removed", "left", "deleted"]) $ settleLane cc env gid + (started >>= \ManagedGroup {mgGroupId} -> action cc env mgGroupId <* settle mgGroupId) `finally` stopGroupService t cc + +-- It returns once chat has started, while group start-up may still run; the Int is its web listener's port. +startGroupService :: HasCallStack => GroupSvc -> Maybe GroupConfig -> IO (Async (), ChatController, ServiceState, Int) +startGroupService svc@GroupSvc {gsCfg} groupCfg = do + (opts, port) <- groupServiceOpts svc groupCfg + env <- newServiceState + t <- async $ badgeService opts gsCfg env + -- A service that ends before it is ready fails the test with its own error, not the timeout. + cc <- + timeout waitLimit (atomically $ (Right <$> readTMVar (serviceCC env)) `orElse` (Left <$> waitCatchSTM t)) >>= \case + Just (Right cc) -> pure cc + Just (Left ended) -> either throwIO (\_ -> error "badge service ended before it started") ended + Nothing -> cancel t >> error "badge service did not start" + pure (t, cc, env, port) + +-- Cancelling ends the service lanes while chat still runs; stopping chat first would leave them running. +stopGroupService :: Async () -> ChatController -> IO () +stopGroupService t cc = do + -- cancel discards how the service ended, so a crash is read first and rethrown after cleanup. + ended <- poll t + cancel t + stopChat cc + forM_ ended $ either throwIO pure + +stopChat :: ChatController -> IO () +stopChat cc = stopBadgeService cc >> disposeAgentClient (smpAgent cc) + +-- It writes the service's ini on a free port; Nothing omits the [group] section. +groupServiceOpts :: GroupSvc -> Maybe GroupConfig -> IO (BadgeServiceOpts, Int) +groupServiceOpts GroupSvc {gsKey = BadgeIssuerKey {secretKey}, gsStaticDir, gsPs = ps@TestParams {tmpPath}} groupCfg = do + (port, sock) <- openFreePort + close sock + let iniPath = tmpPath "badge_group.ini" + writeFile iniPath $ + unlines $ + [ "[listener]", + "host = 127.0.0.1", + "port = " <> show port, + "static_dir = " <> gsStaticDir + ] + <> maybe [] groupSection groupCfg + pure ((mkBadgeServiceOpts ps secretKey) {serviceConfigFile = Just iniPath, noAddress = True}, port) + where + groupSection GroupConfig {gDisplayName, gDescription} = + ["", "[group]", "display_name = " <> T.unpack gDisplayName] + <> maybe [] (\d -> ["description = " <> T.unpack d]) gDescription + +withGroupOwner :: HasCallStack => TestParams -> (BadgeIssuerKey -> ChatController -> ServiceState -> TestCC -> IO ()) -> IO () +withGroupOwner ps action = do + svc@GroupSvc {gsKey} <- prepareGroupService ps + runWithOwner svc $ \cc env _ -> action gsKey cc env + +runWithOwner :: HasCallStack => GroupSvc -> (ChatController -> ServiceState -> Int64 -> TestCC -> IO a) -> IO a +runWithOwner svc@GroupSvc {gsPs} action = runGroupService svc $ \cc env gid -> withOwnerJoined gsPs cc gid $ action cc env gid + +withOwnerJoined :: HasCallStack => TestParams -> ChatController -> Int64 -> (TestCC -> IO a) -> IO a +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 + +-- Bob joins after the first member and is connected to her. +withMemberJoined :: HasCallStack => TestParams -> ChatController -> TestCC -> (TestCC -> IO a) -> IO a +withMemberJoined ps cc alice action = + withNewTestChat ps "bob" bobProfile $ \bob -> do + joinGroup cc bob + drainUntil alice ["#" <> groupName <> ": new member bob is connected"] + r <- action bob + drainConsole bob + pure r + +joinGroup :: HasCallStack => ChatController -> TestCC -> IO () +joinGroup cc member = groupLink cc >>= send member . ("/c " <>) + +sendGroupCmd :: TestCC -> String -> IO () +sendGroupCmd member cmd = send member ("#" <> groupName <> " " <> cmd) + +issueTracked :: HasCallStack => ChatController -> TestCC -> String -> Int -> IO (Int64, Text, Text) +issueTracked cc member badgeType uses = do + sendGroupCmd member ("/issue " <> badgeType <> " uses " <> show uses) + (citemId, body) <- waitTrackerItemOf cc uses + pure (citemId, body, extractCode body) + +testGroupCreateReuse :: HasCallStack => TestParams -> IO () +testGroupCreateReuse ps = do + svc <- prepareGroupService ps + runGroupService svc $ \cc _ gid -> do + ManagedGroup {mgGroupLink} <- storedGroup cc + shortLinkContactType mgGroupLink `shouldBe` Right CCTGroup + groupCount cc `shouldReturn` 1 + advertisedCommands cc gid `shouldReturn` Just expectedCommands + profileDescription cc gid `shouldReturn` Just groupDescr + profileFullNameShortDescr cc gid `shouldReturn` ("", Nothing) + groupFeaturePreference cc gid SGFHistory `shouldReturn` HistoryGroupPreference {enable = FEOff} + -- The first run never rewrites the profile, so only the restart can restore what is blanked here. + clearCommandsSettingFullDelete cc gid + advertisedAt <- runGroupService svc $ \cc _ gid -> do + -- Commands are advertised as the group is resolved, so once they are back this run has found the stored group. + waitAdvertisedCommands cc gid + groupCount cc `shouldReturn` 1 + profileDisplayName cc gid `shouldReturn` T.pack groupName + profileDescription cc gid `shouldReturn` Just groupDescr + profileFullNameShortDescr cc gid `shouldReturn` ("", Nothing) + groupFeaturePreference cc gid SGFFullDelete `shouldReturn` FullDeleteGroupPreference {enable = FEOn, role = Nothing} + profileUpdatedAt cc gid + -- The lane drains only after the group is resolved, so the promotion proves the start-up sync has run. + runWithOwner svc $ \cc _ gid _ -> profileUpdatedAt cc gid `shouldReturn` advertisedAt + +testConfigNotApplied :: HasCallStack => TestParams -> IO () +testConfigNotApplied ps = do + svc <- prepareGroupService ps + -- Clearing the commands makes the restart rewrite the profile, the only path that could apply the new config. + runGroupService svc $ \cc _ gid -> clearCommandsSettingFullDelete cc gid + runGroupServiceAs svc (Just GroupConfig {gDisplayName = "renamedbadges", gDescription = Just "renamed desk"}) $ \cc env gid -> do + waitAdvertisedCommands cc gid + awaitLane cc env + groupCount cc `shouldReturn` 1 + profileDisplayName cc gid `shouldReturn` T.pack groupName + profileDescription cc gid `shouldReturn` Just groupDescr + +testMissedOwnerPromotedOnStart :: HasCallStack => TestParams -> IO () +testMissedOwnerPromotedOnStart ps = do + svc <- prepareGroupService ps + -- A demoted owner with the flag cleared looks like a join the lane never handled. + withTwoMembersOwnerUnset svc $ \cc gid _ -> setMemberRole cc gid "alice" "member" + runGroupService svc $ \cc env gid -> do + waitMemberRole cc gid "alice" "owner" + awaitLane cc env + memberRoles cc gid `shouldReturn` [("alice", "owner"), ("bob", "member")] + +testEarliestPromotedOnLaterJoin :: HasCallStack => TestParams -> IO () +testEarliestPromotedOnLaterJoin ps = do + svc <- prepareGroupService ps + runWithOwner svc $ \cc env gid alice -> do + setMemberRole cc gid "alice" "member" + executeSql cc "UPDATE sx_badge_service_group SET owner_bootstrapped = 0" + withMemberJoined ps cc alice $ \_ -> do + waitMemberRole cc gid "alice" "owner" + awaitLane cc env + memberRoles cc gid `shouldReturn` [("alice", "owner"), ("bob", "member")] + +testJoiningMemberSkippedOnStart :: HasCallStack => TestParams -> IO () +testJoiningMemberSkippedOnStart ps = do + svc <- prepareGroupService ps + withTwoMembersOwnerUnset svc $ \cc gid _ -> do + setMemberRole cc gid "alice" "member" + withTransaction (chatStore cc) $ \db -> + DB.execute db "UPDATE group_members SET member_status = 'accepted' WHERE group_id = ? AND local_display_name = 'alice'" (Only gid) + runGroupService svc $ \cc env gid -> do + waitMemberRole cc gid "bob" "owner" + awaitLane cc env + memberRoles cc gid `shouldReturn` [("alice", "member"), ("bob", "owner")] + +testHandMadeOwnerKeptOnStart :: HasCallStack => TestParams -> IO () +testHandMadeOwnerKeptOnStart ps = do + svc <- prepareGroupService ps + withTwoMembersOwnerUnset svc $ \cc gid _ -> do + setMemberRole cc gid "bob" "owner" + setMemberRole cc gid "alice" "member" + runGroupService svc $ \cc env gid -> do + awaitLane cc env + memberRoles cc gid `shouldReturn` [("alice", "member"), ("bob", "owner")] + fmap mgOwnerBootstrapped <$> getStoredGroup cc `shouldReturn` Just True + +testDeletedGroupNotServed :: HasCallStack => TestParams -> IO () +testDeletedGroupNotServed ps = do + svc <- prepareGroupService ps + runWithOwner svc $ \cc _ gid alice -> do + drainConsole alice + alice ##> ("/d #" <> groupName) + alice <## ("#" <> groupName <> ": you deleted the group (signed)") + pollUntilTrue $ (== Just "deleted") <$> memberStatus cc gid badgeBotName + runGroupService svc $ \cc _ _ -> + ensureManagedGroup cc testGroupConfig `shouldReturn` Nothing + +testOwnDeletedGroupNotServed :: HasCallStack => TestParams -> IO () +testOwnDeletedGroupNotServed ps = do + svc <- prepareGroupService ps + runGroupService svc $ \cc env gid -> do + -- The harness cannot settle a deleted group, so the lane must be idle before the delete. + settleLane cc env gid + sendChatCmdStr cc ("/d #" <> groupName) >>= (`shouldSatisfy` isRight) + runGroupService svc $ \cc _ _ -> + ensureManagedGroup cc testGroupConfig `shouldReturn` Nothing + +testReplacedGroupDropsTrackers :: HasCallStack => TestParams -> IO () +testReplacedGroupDropsTrackers ps = do + svc <- prepareGroupService ps + runWithOwner svc $ \cc _ _ alice -> do + void $ issueTracked cc alice "supporter" 3 + executeSql cc "DELETE FROM sx_badge_service_group" + runGroupService svc $ \cc _ _ -> + countRows cc "sx_badge_service_badge_codes WHERE group_item_id IS NOT NULL OR group_item_sent_at IS NOT NULL" `shouldReturn` 0 + +testGroupLookupFailureStops :: HasCallStack => TestParams -> IO () +testGroupLookupFailureStops ps = do + svc@GroupSvc {gsCfg} <- prepareGroupService ps + runGroupService svc $ \cc _ _ -> + executeSql cc "ALTER TABLE sx_badge_service_group RENAME TO sx_badge_service_group_hidden" + (opts, _) <- groupServiceOpts svc (Just testGroupConfig) + env <- newServiceState + -- Falling through to create would keep the service running, so the timeout would fire instead. + ended <- timeout waitLimit (try $ badgeService opts gsCfg env) + atomically (tryReadTMVar (serviceCC env)) >>= mapM_ stopChat + ended `shouldBe` Just (Left (ExitFailure 1)) + +-- The web listener is one of the service's lanes and only the cancel precedes the check, +-- so its port closing shows the core ended the lanes. +testCancelStopsLanes :: HasCallStack => TestParams -> IO () +testCancelStopsLanes ps = do + svc <- prepareGroupService ps + (t, cc, _, port) <- startGroupService svc (Just testGroupConfig) + (pollUntilTrue (listening port) >> cancel t >> pollUntilTrue (not <$> listening port)) `finally` (cancel t >> stopChat cc) + +listening :: Int -> IO Bool +listening port = + bracket (socket AF_INET Stream defaultProtocol) close $ \s -> + (True <$ connect s (SockAddrInet (fromIntegral port) (tupleToHostAddress (127, 0, 0, 1)))) `catch` \(_ :: IOException) -> pure False + +testLeftOwnerReplacedOnStart :: HasCallStack => TestParams -> IO () +testLeftOwnerReplacedOnStart ps = do + svc <- prepareGroupService ps + withTwoMembersOwnerUnset svc $ \cc gid bob -> do + setMemberRole cc gid "bob" "owner" + setMemberRole cc gid "alice" "member" + send bob ("/l " <> groupName) + pollUntilTrue $ (== Just "left") <$> memberStatus cc gid "bob" + runGroupService svc $ \cc _ gid -> waitMemberRole cc gid "alice" "owner" + +testSetUpOwnerNotReplacedOnStart :: HasCallStack => TestParams -> IO () +testSetUpOwnerNotReplacedOnStart ps = do + svc <- prepareGroupService ps + runWithOwner svc $ \cc _ gid _ -> setMemberRole cc gid "alice" "member" + runGroupService svc $ \cc env gid -> do + awaitLane cc env + memberRoles cc gid `shouldReturn` [("alice", "member")] + +memberStatus :: ChatController -> Int64 -> Text -> IO (Maybe Text) +memberStatus cc gid name = + queryFirst cc $ + "SELECT member_status FROM group_members WHERE group_id = " + <> show gid + <> " AND local_display_name = '" + <> T.unpack name + <> "'" + +withTwoMembersOwnerUnset :: HasCallStack => GroupSvc -> (ChatController -> Int64 -> TestCC -> IO ()) -> IO () +withTwoMembersOwnerUnset svc@GroupSvc {gsPs = ps} action = + runWithOwner svc $ \cc env gid alice -> + withMemberJoined ps cc alice $ \bob -> do + -- The lane must handle bob's join before the flag is cleared, or it would act on the cleared flag in this run. + awaitLane cc env + action cc gid bob + executeSql cc "UPDATE sx_badge_service_group SET owner_bootstrapped = 0" + +testFirstJoinerPromoted :: HasCallStack => TestParams -> IO () +testFirstJoinerPromoted ps = do + svc <- prepareGroupService ps + runWithOwner svc $ \cc _ gid alice -> + withMemberJoined ps cc alice $ \bob -> do + waitMemberRole cc gid "bob" "member" + -- The bot queues bob's join before it introduces him, so alice's code proves the join was handled. + sendGroupCmd alice "/issue supporter" + waitCodeOfType cc "supporter" + memberRoles cc gid `shouldReturn` [("alice", "owner"), ("bob", "member")] + let codeReply = "#" <> groupName <> " " <> botName <> "> Code: SB-" + drainUntil alice [codeReply] + drainUntil bob [codeReply, "#" <> groupName <> ": member alice"] + +testFailedPromotionNotRetried :: HasCallStack => TestParams -> IO () +testFailedPromotionNotRetried ps = do + svc <- prepareGroupService ps + runGroupService svc $ \cc env gid -> do + -- An admin cannot make an owner, so the core refuses the promotion. + setBotRole cc "admin" + withNewTestChat ps "alice" aliceProfile $ \alice -> do + joinGroup cc alice + pollUntilTrue $ mgOwnerBootstrapped <$> storedGroup cc + awaitLane cc env + setBotRole cc "owner" + withMemberJoined ps cc alice $ \_ -> awaitLane cc env + memberRoles cc gid `shouldReturn` [("alice", "member"), ("bob", "member")] + drainConsole alice + +testRoleGatedIssue :: HasCallStack => TestParams -> IO () +testRoleGatedIssue ps = do + svc <- prepareGroupService ps + runWithOwner svc $ \cc _ gid alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + let announced name = "#" <> groupName <> ": " <> botName <> " added " <> name + preMember name = "#" <> groupName <> ": member " <> name + newMember name = "#" <> groupName <> ": new member " <> name <> " is connected" + usageReply = "#" <> groupName <> " " <> botName <> "> Usage: /issue " + joinGroup cc bob + waitMemberRole cc gid "bob" "member" + -- cath joins only after alice sees bob announced, or cath's console shows a different line. + drainUntil alice [announced "bob"] + joinGroup cc cath + waitMemberRole cc gid "cath" "member" + drainUntil cath [preMember "alice", preMember "bob"] + drainConsole cath + sendGroupCmd alice "/issue supporter" + getInAnyOrder + dropTime + cath + [ StartsWith ("#" <> groupName <> " alice> /issue supporter"), + StartsWith ("#" <> groupName <> " " <> botName <> "> Code: SB-") + ] + codeCount cc `shouldReturn` 1 + sendGroupCmd bob "/issue legend" + waitStoredItem cc "/issue legend" + ownerIssuesNext cc alice 2 + sendGroupCmd alice "/issue supporter uses 0" + waitStoredItem cc "Usage: /issue [months ] [uses ]" + codeCount cc `shouldReturn` 2 + drainUntil alice [usageReply, newMember "bob", newMember "cath"] + drainUntil bob [usageReply, preMember "alice", newMember "cath"] + drainUntil cath [usageReply] + drainConsole bob + drainConsole cath + +testBlockedMemberCommandIgnored :: HasCallStack => TestParams -> IO () +testBlockedMemberCommandIgnored ps = do + svc <- prepareGroupService ps + runWithOwner svc $ \cc _ gid alice -> + withMemberJoined ps cc alice $ \bob -> do + waitMemberRole cc gid "bob" "member" + -- bob is made moderator so that only the block can refuse his command. + send alice ("/mr #" <> groupName <> " bob moderator") + waitMemberRole cc gid "bob" "moderator" + -- The block and the command travel over different connections, so the block is awaited first. + send alice ("/block for all #" <> groupName <> " bob") + waitMemberBlocked cc gid "bob" + sendGroupCmd bob "/bulk legend months 255 count 100" + (deleted, content) <- waitBlockedItem cc "/bulk legend months 255 count 100" + deleted `shouldBe` blockedByAdminMark + content `shouldSatisfy` T.isInfixOf "/bulk legend months 255 count 100" + ownerIssuesNext cc alice 1 + +-- The lane is a single FIFO drainer, so the owner's next code proves every earlier command was handled. +ownerIssuesNext :: HasCallStack => ChatController -> TestCC -> Int -> IO () +ownerIssuesNext cc owner total = do + sendGroupCmd owner "/issue investor" + waitCodeOfType cc "investor" + codeCount cc `shouldReturn` total + +-- This is the item_deleted value the core writes for a message from a member blocked for all. +blockedByAdminMark :: Int +blockedByAdminMark = 3 + +testSupportScopeIgnored :: HasCallStack => TestParams -> IO () +testSupportScopeIgnored ps = + withGroupOwner ps $ \_ cc _ alice -> do + send alice "/_send #1(_support) text /issue legend" + waitStoredItem cc "/issue legend" + ownerIssuesNext cc alice 1 + +testLiveMessageIgnored :: HasCallStack => TestParams -> IO () +testLiveMessageIgnored ps = + withGroupOwner ps $ \_ cc _ alice -> do + send alice ("/live #" <> groupName <> " /issue legend") + waitStoredItem cc "/issue legend" + ownerIssuesNext cc alice 1 + +testOtherGroupEventIgnored :: HasCallStack => TestParams -> IO () +testOtherGroupEventIgnored ps = do + svc <- prepareGroupService ps + runGroupService svc $ \cc env gid -> do + atomically $ writeTQueue (groupEventQ env) (GEInGroup (gid + 1) (GACommand GROwner "/issue legend")) + awaitLane cc env + queryColumn cc "SELECT badge_type FROM sx_badge_service_badge_codes" `shouldReturn` ["supporter" :: Text] + +testBulkIssue :: HasCallStack => TestParams -> IO () +testBulkIssue ps = + withGroupOwner ps $ \gsKey cc env alice -> do + codes <- map extractCode . T.lines <$> replyTo cc alice "/bulk legend months 2 count 3" + length codes `shouldBe` 3 + codeCount cc `shouldReturn` 3 + singleUseCodeCount cc `shouldReturn` 3 + freeCodeCount cc `shouldReturn` 3 + forM_ codes $ \code -> do + r <- redeemViaService gsKey cc env code + badgeTypeOf r `shouldBe` Just BTLegend + map (\e -> let (c, m, _) = entryOf e in (c, m)) (fst $ statementOf r) `shouldBe` [(2, 2), (-1, 1)] + forM_ codes $ \code -> redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeUsed) + countRows cc "sx_badge_service_badge_codes WHERE group_item_id IS NOT NULL" `shouldReturn` 0 + +testIssueMonths :: HasCallStack => TestParams -> IO () +testIssueMonths ps = + withGroupOwner ps $ \_ cc _ alice -> do + sendGroupCmd alice "/issue legend months 12" + waitCodeMonths cc "legend" `shouldReturn` 12 + freeCodeCount cc `shouldReturn` 1 + +testBulkPartialFailureListsIssued :: HasCallStack => TestParams -> IO () +testBulkPartialFailureListsIssued ps = + withGroupOwner ps $ \gsKey cc env alice -> do + codeLines <- withCodeTableCapped cc 2 $ do + (codeLines, summary) <- splitAt 2 . T.lines <$> replyTo cc alice "/bulk supporter count 3" + summary `shouldBe` ["Issued 2 of 3 codes. The remaining codes could not be issued."] + pure codeLines + codeCount cc `shouldReturn` 2 + forM_ codeLines $ redeemOk gsKey cc env . extractCode + +testIssueFailureIsOpaque :: HasCallStack => TestParams -> IO () +testIssueFailureIsOpaque ps = + withGroupOwner ps $ \_ cc _ alice -> do + withCodeTableHidden cc $ do + replyTo cc alice "/issue supporter" `shouldReturn` "The code could not be issued." + issueRaw cc "supporter" `shouldReturn` Left "The code could not be issued." + void $ replyTo cc alice "/issue supporter" + codeCount cc `shouldReturn` 1 + +testRevokeFailureIsOpaque :: HasCallStack => TestParams -> IO () +testRevokeFailureIsOpaque ps = + withGroupOwner ps $ \_ cc _ alice -> do + code <- extractCode <$> replyTo cc alice "/issue supporter" + withCodeTableHidden cc $ + replyTo cc alice (revokeCmd code) `shouldReturn` revokeReply code "The code could not be revoked." + revokeInGroup cc alice code "Revoked." + +withCodeTableHidden :: ChatController -> IO a -> IO a +withCodeTableHidden cc action = rename codeTable hidden >> (action `finally` rename hidden codeTable) + where + codeTable = "sx_badge_service_badge_codes" + hidden = codeTable <> "_hidden" + rename from to = executeSql cc $ "ALTER TABLE " <> from <> " RENAME TO " <> to + +-- A test-only trigger fails every insert once the code table holds cap rows. +withCodeTableCapped :: ChatController -> Int -> IO a -> IO a +withCodeTableCapped cc cap action = mapM_ (executeSql cc) createCap >> (action `finally` mapM_ (executeSql cc) dropCap) + where +#if defined(dbPostgres) + createCap = + [ "CREATE FUNCTION sx_badge_service_test_cap() RETURNS trigger AS $$ BEGIN IF (SELECT COUNT(*) FROM sx_badge_service_badge_codes) >= " + <> show cap + <> " THEN RAISE EXCEPTION 'code table full'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql", + "CREATE TRIGGER sx_badge_service_test_cap BEFORE INSERT ON sx_badge_service_badge_codes FOR EACH ROW EXECUTE FUNCTION sx_badge_service_test_cap()" + ] + dropCap = ["DROP FUNCTION sx_badge_service_test_cap() CASCADE"] +#else + createCap = + [ "CREATE TRIGGER sx_badge_service_test_cap BEFORE INSERT ON sx_badge_service_badge_codes WHEN (SELECT COUNT(*) FROM sx_badge_service_badge_codes) >= " + <> show cap + <> " BEGIN SELECT RAISE(ABORT, 'code table full'); END" + ] + dropCap = ["DROP TRIGGER sx_badge_service_test_cap"] +#endif + +testMultiUseTracker :: HasCallStack => TestParams -> IO () +testMultiUseTracker ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2 + tracker0 `shouldSatisfy` T.isInfixOf "2 of 2 uses remaining" + redeemOk gsKey cc env code + t1 <- waitItemText cc trackerItemId "1 of 2 uses remaining" + t1 `shouldSatisfy` T.isInfixOf "last used" + redeemOk gsKey cc env code + usedUp <- waitItemText cc trackerItemId "All 2 uses redeemed" + sentItemsWithCode cc code `shouldReturn` [usedUp] + redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeUsed) + +testStaleRefreshIgnored :: HasCallStack => TestParams -> IO () +testStaleRefreshIgnored ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (trackerItemId, _, code) <- issueTracked cc alice "supporter" 2 + redeemOk gsKey cc env code + redeemOk gsKey cc env code + usedUp <- waitItemText cc trackerItemId "All 2 uses redeemed" + replayRefresh cc env code 2 + awaitLane cc env + sentItemsWithCode cc code `shouldReturn` [usedUp] + +testCountersKeptApart :: HasCallStack => TestParams -> IO () +testCountersKeptApart ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (firstItemId, _, firstCode) <- issueTracked cc alice "supporter" 5 + replicateM_ 3 (redeemOk gsKey cc env firstCode) + void $ waitItemText cc firstItemId "2 of 5 uses remaining" + (secondItemId, _, secondCode) <- issueTracked cc alice "legend" 3 + replicateM_ 3 (redeemOk gsKey cc env secondCode) + void $ waitItemText cc secondItemId "All 3 uses redeemed" + readItemText cc firstItemId >>= (`shouldSatisfy` T.isInfixOf "2 of 5 uses remaining") + +testLanelessRedeemRefreshesTracker :: HasCallStack => TestParams -> IO () +testLanelessRedeemRefreshesTracker ps = + withGroupOwner ps $ \gsKey cc _ alice -> do + offsetCodeIds cc + (trackerItemId, _, code) <- issueTracked cc alice "supporter" 2 + redeemOkWith gsKey cc Nothing code + -- The refresh runs before the response, so the counter is already written here. + t1 <- readItemText cc trackerItemId + t1 `shouldSatisfy` T.isInfixOf "1 of 2 uses remaining" + t1 `shouldSatisfy` T.isInfixOf "last used" + redeemOkWith gsKey cc Nothing code + readItemText cc trackerItemId >>= (`shouldSatisfy` T.isInfixOf "All 2 uses redeemed") + redeemedTrackerCount cc `shouldReturn` 1 + +testQueuedRequestRefreshesTracker :: HasCallStack => TestParams -> IO () +testQueuedRequestRefreshesTracker ps = + withGroupOwner ps $ \_ cc env alice -> do + (trackerItemId, _, code) <- issueTracked cc alice "supporter" 2 + queueRedemption cc env code + void $ waitItemText cc trackerItemId "1 of 2 uses remaining" + +testQueuedRequestWithoutGroupConfig :: HasCallStack => TestParams -> IO () +testQueuedRequestWithoutGroupConfig ps = do + svc <- prepareGroupService ps + (trackerItemId, code) <- runWithOwner svc $ \cc _ _ alice -> + (\(i, _, c) -> (i, c)) <$> issueTracked cc alice "supporter" 2 + runGroupServiceAs svc Nothing $ \cc env _ -> do + queueRedemption cc env code + void $ waitItemText cc trackerItemId "1 of 2 uses remaining" + +testTrackerRepost :: HasCallStack => TestParams -> IO () +testTrackerRepost ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2 + backdated <- backdateTracker cc 2 + redeemOk gsKey cc env code + (_, tracker1) <- waitTrackerRepost cc 2 itemId0 + tracker1 `shouldSatisfy` T.isInfixOf "1 of 2 uses remaining" + readItemText cc itemId0 `shouldReturn` tracker0 + sentAt <- trackerSentAt cc 2 + sentAt `shouldSatisfy` (> backdated) + +testRefusedEditReposted :: HasCallStack => TestParams -> IO () +testRefusedEditReposted ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2 + backdateTrackerItem cc itemId0 + redeemOk gsKey cc env code + (itemId1, tracker1) <- waitTrackerRepost cc 2 itemId0 + tracker1 `shouldSatisfy` T.isInfixOf "1 of 2 uses remaining" + redeemOk gsKey cc env code + usedUp <- waitItemText cc itemId1 "All 2 uses redeemed" + sentItemsWithCode cc code `shouldReturn` [tracker0, usedUp] + +testFailedRepostDropped :: HasCallStack => TestParams -> IO () +testFailedRepostDropped ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2 + void $ backdateTracker cc 2 + setBotRole cc "observer" + redeemOk gsKey cc env code + awaitLane cc env + sentItemsWithCode cc code `shouldReturn` [tracker0] + readItemText cc itemId0 `shouldReturn` tracker0 + trackerAnchor cc 2 `shouldReturn` Just itemId0 + setBotRole cc "owner" + redeemOk gsKey cc env code + (_, tracker1) <- waitTrackerRepost cc 2 itemId0 + tracker1 `shouldSatisfy` T.isInfixOf "All 2 uses redeemed" + +testDeletedTrackerNotReposted :: HasCallStack => TestParams -> IO () +testDeletedTrackerNotReposted ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (itemId0, _, code) <- issueTracked cc alice "supporter" 2 + deleteItem cc itemId0 + readItemText cc itemId0 `shouldReturn` "" + trackerNeverReposted gsKey cc env code itemId0 [] + +testDeletedTrackerNotRepostedOnRevoke :: HasCallStack => TestParams -> IO () +testDeletedTrackerNotRepostedOnRevoke ps = + withGroupOwner ps $ \_ cc _ alice -> do + (itemId0, _, code) <- issueTracked cc alice "supporter" 2 + deleteItem cc itemId0 + -- Past the edit window the revoke goes straight to the repost path and its guard. + void $ backdateTracker cc 2 + revokeNeverReposts cc alice code [] + +-- Both claims fall inside the edit window, so each edit fails because the message is gone. +testDeletedTrackerUsedUp :: HasCallStack => TestParams -> IO () +testDeletedTrackerUsedUp ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (itemId0, _, code) <- issueTracked cc alice "supporter" 2 + deleteItem cc itemId0 + replicateM_ 2 $ redeemOk gsKey cc env code + awaitLane cc env + sentItemsWithCode cc code `shouldReturn` [] + +testModeratedTrackerNotRepostedOnRevoke :: HasCallStack => TestParams -> IO () +testModeratedTrackerNotRepostedOnRevoke ps = + withGroupOwner ps $ \_ cc _ alice -> do + (itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2 + moderateBotItem alice tracker0 + void $ waitModeratedItem cc itemId0 + revokeNeverReposts cc alice code [tracker0] + +testModeratedTrackerNotReposted :: HasCallStack => TestParams -> IO () +testModeratedTrackerNotReposted ps = + withGroupOwner ps $ \gsKey cc env alice -> do + (itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2 + moderateBotItem alice tracker0 + (deleted, byMember, content) <- waitModeratedItem cc itemId0 + deleted `shouldBe` moderatedMark + byMember `shouldSatisfy` isJust + content `shouldSatisfy` T.isInfixOf code + readItemText cc itemId0 `shouldReturn` tracker0 + trackerNeverReposted gsKey cc env code itemId0 [tracker0] + +-- This is the item_deleted value the core writes for a message a member deleted for everyone. +moderatedMark :: Int +moderatedMark = 1 + +-- The code must be the 2-use tracked one: two claims, the second past the edit window and using it up, +-- leave the code's bot items as they were. +trackerNeverReposted :: HasCallStack => BadgeIssuerKey -> ChatController -> ServiceState -> Text -> Int64 -> [Text] -> IO () +trackerNeverReposted key cc env code itemId0 codeItems = do + redeemOk key cc env code + awaitLane cc env + sentItemsWithCode cc code `shouldReturn` codeItems + trackerAnchor cc 2 `shouldReturn` Just itemId0 + void $ backdateTracker cc 2 + redeemOk key cc env code + awaitLane cc env + sentItemsWithCode cc code `shouldReturn` codeItems + trackerAnchor cc 2 `shouldReturn` Just itemId0 + +revokeNeverReposts :: HasCallStack => ChatController -> TestCC -> Text -> [Text] -> IO () +revokeNeverReposts cc member code codeItems = do + revokeInGroup cc member code "Revoked." + sentItemsWithCode cc code `shouldReturn` codeItems <> [revokeReply code "Revoked."] + revokedTrackerCount cc `shouldReturn` 0 + +testGroupRevoke :: HasCallStack => TestParams -> IO () +testGroupRevoke ps = + withGroupOwner ps $ \gsKey cc env alice -> do + code <- extractCode <$> replyTo cc alice "/issue supporter" + other <- extractCode <$> replyTo cc alice "/issue legend" + revokeInGroup cc alice code "Revoked." + redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeInvalid) + redeemOk gsKey cc env other + revokeInGroup cc alice code "Already revoked." + -- The reply follows the tracker step, so the revoke has already passed it here. + revokedTrackerCount cc `shouldReturn` 0 + unissued <- formatBadgeCode <$> randomBadgeCode (random cc) + revokeInGroup cc alice unissued "No such code." + +testGroupRevokeRepairsTracker :: HasCallStack => TestParams -> IO () +testGroupRevokeRepairsTracker ps = + withGroupOwner ps $ \_ cc _ alice -> do + offsetCodeIds cc + (trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2 + revokeInStore cc 2 + readItemText cc trackerItemId `shouldReturn` tracker0 + sendGroupCmd alice (revokeCmd code) + retired <- waitItemText cc trackerItemId "Revoked" + retired `shouldBe` retiredText code + waitStoredItem cc (revokeReply code "Already revoked.") + revokedTrackerCount cc `shouldReturn` 1 + +testRevokeRepeatPastWindow :: HasCallStack => TestParams -> IO () +testRevokeRepeatPastWindow ps = + withGroupOwner ps $ \_ cc _ alice -> do + offsetCodeIds cc + (itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2 + void $ backdateTracker cc 2 + revokeInGroup cc alice code "Revoked." + (itemId1, retired) <- waitTrackerRepost cc 2 itemId0 + retired `shouldBe` retiredText code + revokedTrackerCount cc `shouldReturn` 1 + void $ backdateTracker cc 2 + revokeInGroup cc alice code "Already revoked." + revokedTrackerCount cc `shouldReturn` 1 + trackerAnchor cc 2 `shouldReturn` Just itemId1 + readItemText cc itemId1 `shouldReturn` retiredText code + readItemText cc itemId0 `shouldReturn` tracker0 + +testUsedUpTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO () +testUsedUpTrackerReconciledOnRestart ps = do + svc@GroupSvc {gsKey} <- prepareGroupService ps + (lostItemId, lostCode) <- runWithOwner svc $ \cc env _ alice -> do + (doneItemId, _, doneCode) <- issueTracked cc alice "supporter" 2 + redeemOk gsKey cc env doneCode + redeemOk gsKey cc env doneCode + void $ waitItemText cc doneItemId "All 2 uses redeemed" + (lostItemId, lost0, lostCode) <- issueTracked cc alice "legend" 3 + replicateM_ 3 (redeemLosingRefresh gsKey cc lostCode) + readItemText cc lostItemId `shouldReturn` lost0 + trackerAnchor cc 2 `shouldReturn` Just doneItemId + dateRedemption cc 3 lostRedeemedAt + pure (lostItemId, lostCode) + runGroupService svc $ \cc env _ -> do + corrected <- waitItemText cc lostItemId "All 3 uses redeemed" + corrected `shouldBe` (lostCode <> "\nAll 3 uses redeemed, last used 2026-02-28 23:50 UTC") + awaitLane cc env + sentItemsWithCode cc lostCode `shouldReturn` [corrected] + redeemedTrackerCount cc `shouldReturn` 2 + +-- This date is far from any day the test runs on, so a body dated now cannot match it. +lostRedeemedAt :: UTCTime +lostRedeemedAt = UTCTime (fromGregorian 2026 2 28) (23 * 3600 + 50 * 60) + +testStalledTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO () +testStalledTrackerReconciledOnRestart ps = do + svc@GroupSvc {gsKey} <- prepareGroupService ps + (stalledItemId, code) <- runWithOwner svc $ \cc env _ alice -> do + (stalledItemId, _, code) <- issueTracked cc alice "supporter" 5 + redeemOk gsKey cc env code + redeemOk gsKey cc env code + awaitLane cc env + stalled <- waitItemText cc stalledItemId "3 of 5 uses remaining" + redeemLosingRefresh gsKey cc code + readItemText cc stalledItemId `shouldReturn` stalled + dateRedemption cc 5 lostRedeemedAt + pure (stalledItemId, code) + runGroupService svc $ \cc env _ -> do + corrected <- waitItemText cc stalledItemId "2 of 5 uses remaining" + corrected `shouldBe` ("!2 " <> code <> "!\n2 of 5 uses remaining, last used 2026-02-28 23:50 UTC") + awaitLane cc env + sentItemsWithCode cc code `shouldReturn` [corrected] + +testRevokedTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO () +testRevokedTrackerReconciledOnRestart ps = do + svc <- prepareGroupService ps + (trackerItemId, code) <- runWithOwner svc $ \cc _ _ alice -> do + (trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2 + revokeInStore cc 2 + readItemText cc trackerItemId `shouldReturn` tracker0 + pure (trackerItemId, code) + runGroupService svc $ \cc env _ -> do + retired <- waitItemText cc trackerItemId "Revoked" + retired `shouldBe` retiredText code + awaitLane cc env + revokedTrackerCount cc `shouldReturn` 1 + +testEveryStalledTrackerReconciled :: HasCallStack => TestParams -> IO () +testEveryStalledTrackerReconciled ps = do + svc@GroupSvc {gsKey} <- prepareGroupService ps + (firstItemId, secondItemId) <- runWithOwner svc $ \cc _ _ alice -> do + (firstItemId, first0, firstCode) <- issueTracked cc alice "supporter" 5 + replicateM_ 2 (redeemLosingRefresh gsKey cc firstCode) + (secondItemId, second0, secondCode) <- issueTracked cc alice "legend" 3 + redeemLosingRefresh gsKey cc secondCode + readItemText cc firstItemId `shouldReturn` first0 + readItemText cc secondItemId `shouldReturn` second0 + pure (firstItemId, secondItemId) + runGroupService svc $ \cc env _ -> do + first1 <- waitItemText cc firstItemId "3 of 5 uses remaining" + first1 `shouldSatisfy` T.isInfixOf "last used " + second1 <- waitItemText cc secondItemId "2 of 3 uses remaining" + second1 `shouldSatisfy` T.isInfixOf "last used " + awaitLane cc env + redeemedTrackerCount cc `shouldReturn` 2 + +testUneditableTrackerLeftAlone :: HasCallStack => TestParams -> IO () +testUneditableTrackerLeftAlone ps = do + svc@GroupSvc {gsKey} <- prepareGroupService ps + (staleItemId, stale0, staleCode, liveItemId, liveCode) <- runWithOwner svc $ \cc _ _ alice -> do + (staleItemId, stale0, staleCode) <- issueTracked cc alice "supporter" 2 + redeemLosingRefresh gsKey cc staleCode + backdateTrackerItem cc staleItemId + readItemText cc staleItemId `shouldReturn` stale0 + -- The lane drains only after the pass returns, so this code's update marks the pass as done. + (liveItemId, _, liveCode) <- issueTracked cc alice "legend" 3 + pure (staleItemId, stale0, staleCode, liveItemId, liveCode) + runGroupService svc $ \cc env _ -> do + redeemOk gsKey cc env liveCode + void $ waitItemText cc liveItemId "2 of 3 uses remaining" + readItemText cc staleItemId `shouldReturn` stale0 + sentItemsWithCode cc staleCode `shouldReturn` [stale0] + trackerAnchor cc 2 `shouldReturn` Just staleItemId + +testRevokeRetiresTracker :: HasCallStack => TestParams -> IO () +testRevokeRetiresTracker ps = + withGroupOwner ps $ \gsKey cc env alice -> do + offsetCodeIds cc + (trackerItemId, _, code) <- issueTracked cc alice "supporter" 2 + redeemOk gsKey cc env code + void $ waitItemText cc trackerItemId "1 of 2 uses remaining" + revokeInGroup cc alice code "Revoked." + retired <- waitItemText cc trackerItemId "Revoked" + retired `shouldBe` retiredText code + revokedTrackerCount cc `shouldReturn` 1 + replayRefresh cc env code 2 + awaitLane cc env + readItemText cc trackerItemId `shouldReturn` retired + revokedTrackerCount cc `shouldReturn` 1 + redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeInvalid) + +testServiceRevokeRetiresTracker :: HasCallStack => TestParams -> IO () +testServiceRevokeRetiresTracker ps = + withGroupOwner ps $ \gsKey cc env alice -> do + offsetCodeIds cc + (trackerItemId, _, code) <- issueTracked cc alice "supporter" 2 + redeemOk gsKey cc env code + void $ waitItemText cc trackerItemId "1 of 2 uses remaining" + revokeRaw cc code `shouldReturn` Right "Revoked." + readItemText cc trackerItemId `shouldReturn` retiredText code + revokedTrackerCount cc `shouldReturn` 1 + revokeRaw cc code `shouldReturn` Right "Already revoked." + revokedTrackerCount cc `shouldReturn` 1 + single <- extractCode <$> replyTo cc alice "/issue legend" + revokeRaw cc single `shouldReturn` Right "Revoked." + revokedTrackerCount cc `shouldReturn` 1 + +testServiceRevokeRepairsTracker :: HasCallStack => TestParams -> IO () +testServiceRevokeRepairsTracker ps = + withGroupOwner ps $ \_ cc _ alice -> do + offsetCodeIds cc + (trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2 + revokeInStore cc 2 + readItemText cc trackerItemId `shouldReturn` tracker0 + revokeRaw cc code `shouldReturn` Right "Already revoked." + readItemText cc trackerItemId `shouldReturn` retiredText code + revokedTrackerCount cc `shouldReturn` 1 + +-- A discarded code makes later code ids differ from the group id, so swapped arguments fail. +offsetCodeIds :: HasCallStack => ChatController -> IO () +offsetCodeIds cc = void $ issueCode cc BTSupporter 1 + +redeemOk :: HasCallStack => BadgeIssuerKey -> ChatController -> ServiceState -> Text -> IO () +redeemOk key cc env = redeemOkWith key cc (Just (groupEventQ env)) + +redeemOkWith :: HasCallStack => BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> Text -> IO () +redeemOkWith key cc trackerQ_ code = do + r <- redeemWithQueue key cc trackerQ_ code + credentialOf r `shouldSatisfy` isJust + +redeemViaService :: HasCallStack => BadgeIssuerKey -> ChatController -> ServiceState -> Text -> IO BadgeServiceResponse +redeemViaService key cc env = redeemWithQueue key cc (Just (groupEventQ env)) + +-- The reply to the made-up request id fails after the redemption, and the service only logs it. +queueRedemption :: HasCallStack => ChatController -> ServiceState -> Text -> IO () +queueRedemption cc env codeText = do + user <- readTVarIO (currentUser cc) >>= maybe (error "no current user") pure + (purchaseKey, masterKey) <- newPurchaseKeys + let request = requestObject purchaseKey BSCRedeemBadgeCode {masterKey, code = codeText} + atomically $ writeTQueue (serviceRequestQ env) (user, AgentInvId "test-request", Just purchaseKey, request) + +redeemLosingRefresh :: HasCallStack => BadgeIssuerKey -> ChatController -> Text -> IO () +redeemLosingRefresh key cc codeText = newTQueueIO >>= \q -> redeemOkWith key cc (Just q) codeText + +replayRefresh :: HasCallStack => ChatController -> ServiceState -> Text -> Int -> IO () +replayRefresh cc env codeText uses = case parseBadgeCode codeText of + Nothing -> error $ "not a badge code: " <> T.unpack codeText + Just code -> do + badgeCodeId <- trackedCodeId cc uses + atomically $ writeTQueue (groupEventQ env) (GETracker badgeCodeId code) + +getStoredGroup :: ChatController -> IO (Maybe ManagedGroup) +getStoredGroup cc = withTransaction (chatStore cc) getManagedGroup + +shortLinkContactType :: Text -> Either String ContactConnType +shortLinkContactType linkText = case strDecode (encodeUtf8 linkText) of + Right (ACSL _ (CSLContact _ ct _ _)) -> Right ct + Right (ACSL _ CSLInvitation {}) -> Left "invitation short link" + Left e -> Left e + +groupLink :: HasCallStack => ChatController -> IO String +groupLink cc = T.unpack . mgGroupLink <$> storedGroup cc + +storedGroup :: HasCallStack => ChatController -> IO ManagedGroup +storedGroup cc = getStoredGroup cc >>= maybe (error "no managed group recorded") pure + +groupCount :: ChatController -> IO Int +groupCount cc = countRows cc "groups" + +-- The lane runs events in order, so once this command's code exists every event queued before it is done. +awaitLane :: HasCallStack => ChatController -> ServiceState -> IO () +awaitLane cc env = do + ManagedGroup {mgGroupId} <- storedGroup cc + issued <- codeCount cc + atomically $ writeTQueue (groupEventQ env) (GEInGroup mgGroupId (GACommand GROwner "/issue supporter")) + pollUntilTrue $ (> issued) <$> codeCount cc + +-- Saving a tracker's item id is the lane's last write for its command, so the lane is idle after it. +settleLane :: HasCallStack => ChatController -> ServiceState -> Int64 -> IO () +settleLane cc env gid = do + anchoredBefore <- anchored + atomically $ writeTQueue (groupEventQ env) (GEInGroup gid (GACommand GROwner ("/issue supporter uses " <> tshow settleUses))) + pollUntilTrue $ (> anchoredBefore) <$> anchored + where + anchored = countRows cc ("sx_badge_service_badge_codes WHERE redeem_limit = " <> show settleUses <> " AND group_item_id IS NOT NULL") + +-- No test tracks a code with this many uses, so the settling codes never match a test's lookup. +settleUses :: Int +settleUses = maxUses + +codeCount :: ChatController -> IO Int +codeCount cc = countRows cc "sx_badge_service_badge_codes" + +freeCodeCount :: ChatController -> IO Int +freeCodeCount cc = countRows cc "sx_badge_service_badge_codes WHERE code_payment_status = 'free'" + +singleUseCodeCount :: ChatController -> IO Int +singleUseCodeCount cc = countRows cc "sx_badge_service_badge_codes WHERE redeem_limit = 1" + +countRows :: ChatController -> String -> IO Int +countRows cc fromWhere = fromMaybe 0 <$> queryFirst cc ("SELECT COUNT(*) FROM " <> fromWhere) + +-- A test finds each tracked code by its number of uses, so no two of its codes may share one. +trackedCodeColumn :: (HasCallStack, DB.FromField a) => ChatController -> Int -> String -> IO a +trackedCodeColumn cc uses column = + queryOne cc ("no code issued with " <> show uses <> " uses") $ + "SELECT " <> column <> " FROM sx_badge_service_badge_codes WHERE redeem_limit = " <> show uses + +trackedCodeId :: HasCallStack => ChatController -> Int -> IO Int64 +trackedCodeId cc uses = trackedCodeColumn cc uses "badge_code_id" + +queryRows :: FromRow r => ChatController -> String -> IO [r] +queryRows cc sql = withTransaction (chatStore cc) (\db -> DB.query_ db (fromString sql)) + +executeSql :: ChatController -> String -> IO () +executeSql cc sql = withTransaction (chatStore cc) (\db -> DB.execute_ db (fromString sql)) + +queryColumn :: DB.FromField a => ChatController -> String -> IO [a] +queryColumn cc sql = map fromOnly <$> queryRows cc sql + +queryFirst :: DB.FromField a => ChatController -> String -> IO (Maybe a) +queryFirst cc sql = listToMaybe <$> queryColumn cc sql + +queryOne :: (HasCallStack, DB.FromField a) => ChatController -> String -> String -> IO a +queryOne cc err sql = queryColumn cc sql >>= maybe (error err) pure . singleRow sql + +singleRow :: HasCallStack => String -> [a] -> Maybe a +singleRow sql = \case + [] -> Nothing + [r] -> Just r + _ -> error $ "more than one row from: " <> sql + +-- The list is spelled out rather than taken from the service, so a change there fails here. +expectedCommands :: [ChatBotCommand] +expectedCommands = + [ CBCCommand "issue" "Generate a badge code" (Just " [months ] [uses ]"), + CBCCommand "bulk" "Generate many single-use codes" (Just " [months ] count "), + CBCCommand "revoke" "Revoke a code" (Just "") + ] + +advertisedCommands :: HasCallStack => ChatController -> Int64 -> IO (Maybe [ChatBotCommand]) +advertisedCommands cc gid = + (>>= commands_) + <$> (queryOne cc "no group profile to read commands from" (groupProfileSql "p.preferences" gid) :: IO (Maybe GroupPreferences)) + +profileDisplayName :: HasCallStack => ChatController -> Int64 -> IO Text +profileDisplayName cc gid = + queryOne cc "no group profile to read a display name from" (groupProfileSql "p.display_name" gid) + +profileFullNameShortDescr :: HasCallStack => ChatController -> Int64 -> IO (Text, Maybe Text) +profileFullNameShortDescr cc gid = + queryRows cc sql >>= maybe (error "no group profile to read a full name from") pure . singleRow sql + where + sql = groupProfileSql "p.full_name, p.short_descr" gid + +profileDescription :: HasCallStack => ChatController -> Int64 -> IO (Maybe Text) +profileDescription cc gid = queryOne cc "no group profile to read a description from" (groupProfileSql "p.description" gid) + +profileUpdatedAt :: HasCallStack => ChatController -> Int64 -> IO UTCTime +profileUpdatedAt cc gid = + queryOne cc "no group profile to read an update time from" (groupProfileSql "p.updated_at" gid) + +groupFeaturePreference :: HasCallStack => ChatController -> Int64 -> SGroupFeature f -> IO (GroupFeaturePreference f) +groupFeaturePreference cc gid feature = + getGroupPreference feature + <$> (queryOne cc "no group profile to read preferences from" (groupProfileSql "p.preferences" gid) :: IO (Maybe GroupPreferences)) + +groupProfileSql :: String -> Int64 -> String +groupProfileSql columns gid = + "SELECT " + <> columns + <> " FROM group_profiles p JOIN groups g ON g.group_profile_id = p.group_profile_id WHERE g.group_id = " + <> show gid + +clearCommandsSettingFullDelete :: ChatController -> Int64 -> IO () +clearCommandsSettingFullDelete cc gid = + withTransaction (chatStore cc) $ \db -> + DB.execute + db + "UPDATE group_profiles SET preferences = ? WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE group_id = ?)" + (fullDeleteOnly, gid) + where + fullDeleteOnly = + emptyGroupPrefs {fullDelete = Just FullDeleteGroupPreference {enable = FEOn, role = Nothing}} :: GroupPreferences + +revokeInStore :: HasCallStack => ChatController -> Int -> IO () +revokeInStore cc uses = getCurrentTime >>= setTrackedCodeTime cc uses "revoked_at" + +backdateTracker :: HasCallStack => ChatController -> Int -> IO UTCTime +backdateTracker cc uses = do + past <- addUTCTime pastEditWindow <$> getCurrentTime + setTrackedCodeTime cc uses "group_item_sent_at" past + pure past + +-- This is an hour past the core's 24-hour edit window. +pastEditWindow :: NominalDiffTime +pastEditWindow = -25 * 3600 + +-- The code row keeps its sent time, so the service still tries an edit the core refuses. +backdateTrackerItem :: ChatController -> Int64 -> IO () +backdateTrackerItem cc citemId = do + past <- addUTCTime pastEditWindow <$> getCurrentTime + withTransaction (chatStore cc) $ \db -> + DB.execute db "UPDATE chat_items SET item_ts = ? WHERE chat_item_id = ?" (past, citemId) + +-- Below author the core refuses every send from the bot. +setBotRole :: ChatController -> Text -> IO () +setBotRole cc role = + withTransaction (chatStore cc) $ \db -> + DB.execute db "UPDATE group_members SET member_role = ? WHERE member_category = ?" (role, "user" :: Text) + +dateRedemption :: HasCallStack => ChatController -> Int -> UTCTime -> IO () +dateRedemption cc uses = setTrackedCodeTime cc uses "redeemed_at" + +setTrackedCodeTime :: HasCallStack => ChatController -> Int -> String -> UTCTime -> IO () +setTrackedCodeTime cc uses column ts = do + badgeCodeId <- trackedCodeId cc uses + withTransaction (chatStore cc) $ \db -> + DB.execute db (fromString $ "UPDATE sx_badge_service_badge_codes SET " <> column <> " = ? WHERE badge_code_id = ?") (ts, badgeCodeId) + +-- Member messages are excluded because a /revoke names the code too. +sentItemsWithCode :: ChatController -> Text -> IO [Text] +sentItemsWithCode cc code = + queryColumn cc $ + "SELECT item_text FROM chat_items WHERE item_sent = 1 AND item_text LIKE '%" + <> T.unpack code + <> "%' ORDER BY chat_item_id" + +-- It reads the code row, not a join, so it still answers after the message is deleted. +trackerAnchor :: HasCallStack => ChatController -> Int -> IO (Maybe Int64) +trackerAnchor cc uses = trackedCodeColumn cc uses "group_item_id" + +trackerSentAt :: HasCallStack => ChatController -> Int -> IO UTCTime +trackerSentAt cc uses = trackedCodeColumn cc uses "group_item_sent_at" + +deleteItem :: HasCallStack => ChatController -> Int64 -> IO () +deleteItem cc citemId = do + ManagedGroup {mgGroupId} <- storedGroup cc + sendChatCmd cc (APIDeleteChatItem (ChatRef CTGroup mgGroupId Nothing) (citemId :| []) CIDMInternal) >>= \case + Right CRChatItemsDeleted {} -> pure () + r -> error $ "deleting chat item " <> show citemId <> " failed: " <> show r + +-- The member must have received the message before the command can find it. +moderateBotItem :: HasCallStack => TestCC -> Text -> IO () +moderateBotItem member body = do + void (pollUntil received :: IO Int64) + send member ("\\\\ #" <> groupName <> " @" <> botName <> " " <> T.unpack header) + where + header = T.takeWhile (/= '\n') body + 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 = + pollUntil $ + mfilter (\(deleted, _, _) -> deleted /= 0) . listToMaybe + <$> queryRows cc ("SELECT item_deleted, item_deleted_by_group_member_id, item_content FROM chat_items WHERE chat_item_id = " <> show citemId) + +memberRoles :: ChatController -> Int64 -> IO [(Text, Text)] +memberRoles cc gid = + filter ((/= badgeBotName) . fst) + <$> queryRows cc ("SELECT local_display_name, member_role FROM group_members WHERE group_id = " <> show gid <> " ORDER BY group_member_id") + +readItemText :: ChatController -> Int64 -> IO Text +readItemText cc citemId = fromMaybe "" <$> firstText cc ("chat_item_id = " <> show citemId) + +-- A line left unread when the test ends fails the per-core teardown check. +drainUntil :: HasCallStack => TestCC -> [String] -> IO () +drainUntil cc markers = + timeout waitLimit (go markers) >>= \case + Just () -> pure () + Nothing -> error $ "drainUntil: console never showed " <> show markers + where + go [] = pure () + go unseen = do + l <- atomically $ readTQueue (termQ cc) + go $ filter (not . (`isInfixOf` l)) unseen + +drainConsole :: TestCC -> IO () +drainConsole cc = + timeout quietPeriod (atomically (readTQueue (termQ cc))) >>= \case + Just _ -> drainConsole cc + Nothing -> pure () + +-- A console silent this long is taken as drained. +quietPeriod :: Int +quietPeriod = 500000 + +pollInterval :: Int +pollInterval = 200000 + +maxPolls :: Int +maxPolls = 150 + +waitLimit :: Int +waitLimit = maxPolls * pollInterval + +pollUntil :: HasCallStack => IO (Maybe a) -> IO a +pollUntil act = go maxPolls + where + go n = + act >>= \case + Just a -> pure a + Nothing + | n <= (0 :: Int) -> error "pollUntil: timed out waiting for chat state" + | otherwise -> threadDelay pollInterval >> go (n - 1) + +pollUntilTrue :: HasCallStack => IO Bool -> IO () +pollUntilTrue cond = pollUntil (guard <$> cond) + +waitMemberRole :: HasCallStack => ChatController -> Int64 -> Text -> Text -> IO () +waitMemberRole cc gid name role = pollUntilTrue $ elem (name, role) <$> memberRoles cc gid + +setMemberRole :: HasCallStack => ChatController -> Int64 -> Text -> Text -> IO () +setMemberRole cc gid name role = do + sendChatCmdStr cc ("/mr #" <> groupName <> " " <> T.unpack name <> " " <> T.unpack role) >>= (`shouldSatisfy` isRight) + waitMemberRole cc gid name role + +waitMemberBlocked :: HasCallStack => ChatController -> Int64 -> Text -> IO () +waitMemberBlocked cc gid name = + pollUntilTrue $ (== Just (Just ("blocked" :: Text))) <$> queryFirst cc restrictionSql + where + restrictionSql = + "SELECT member_restriction FROM group_members WHERE group_id = " + <> show gid + <> " AND local_display_name = '" + <> T.unpack name + <> "'" + +waitBlockedItem :: HasCallStack => ChatController -> Text -> IO (Int, Text) +waitBlockedItem cc txt = + pollUntil $ + mfilter ((/= 0) . fst) . listToMaybe + <$> queryRows cc ("SELECT item_deleted, item_content FROM chat_items WHERE item_sent = 0 AND item_text = '" <> T.unpack txt <> "'") + +waitAdvertisedCommands :: HasCallStack => ChatController -> Int64 -> IO () +waitAdvertisedCommands cc gid = pollUntilTrue $ (== Just expectedCommands) <$> advertisedCommands cc gid + +waitStoredItem :: HasCallStack => ChatController -> Text -> IO () +waitStoredItem cc txt = void . pollUntil $ firstText cc ("item_text = '" <> T.unpack txt <> "'") + +-- The reply is the first item the service sends after the command arrives. +replyTo :: HasCallStack => ChatController -> TestCC -> String -> IO Text +replyTo cc member cmd = do + lastId <- maxChatItemId cc + sendGroupCmd member cmd + waitReceivedItemId cc lastId (T.pack cmd) >>= waitReplyAfter cc + +maxChatItemId :: ChatController -> IO Int64 +maxChatItemId cc = fromMaybe 0 <$> queryFirst cc "SELECT COALESCE(MAX(chat_item_id), 0) FROM chat_items" + +waitReceivedItemId :: HasCallStack => ChatController -> Int64 -> Text -> IO Int64 +waitReceivedItemId cc afterId txt = + pollUntil . queryFirst cc $ + "SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND chat_item_id > " <> show afterId <> " AND item_text = '" <> T.unpack txt <> "'" + +waitReplyAfter :: HasCallStack => ChatController -> Int64 -> IO Text +waitReplyAfter cc afterId = + pollUntil . firstText cc $ "item_sent = 1 AND chat_item_id > " <> show afterId <> " ORDER BY chat_item_id LIMIT 1" + +waitItemText :: HasCallStack => ChatController -> Int64 -> Text -> IO Text +waitItemText cc citemId marker = + pollUntil $ mfilter (marker `T.isInfixOf`) . Just <$> readItemText cc citemId + +waitCodeOfType :: HasCallStack => ChatController -> Text -> IO () +waitCodeOfType cc badgeType = + pollUntilTrue $ (> 0) <$> countRows cc ("sx_badge_service_badge_codes WHERE badge_type = '" <> T.unpack badgeType <> "'") + +waitCodeMonths :: HasCallStack => ChatController -> Text -> IO Int +waitCodeMonths cc badgeType = + pollUntil $ do + months <- queryColumn cc $ "SELECT months FROM sx_badge_service_badge_codes WHERE badge_type = '" <> T.unpack badgeType <> "'" + pure $ case months of + [m] -> Just m + _ -> Nothing + +firstText :: ChatController -> String -> IO (Maybe Text) +firstText cc cond = queryFirst cc ("SELECT item_text FROM chat_items WHERE " <> cond) + +trackerItem :: HasCallStack => ChatController -> Int -> IO (Maybe (Int64, Text)) +trackerItem cc uses = singleRow sql <$> queryRows cc sql + where + sql = + "SELECT c.group_item_id, ci.item_text " + <> "FROM sx_badge_service_badge_codes c " + <> "JOIN chat_items ci ON ci.chat_item_id = c.group_item_id " + <> "WHERE c.redeem_limit = " + <> show uses + +waitTrackerItemOf :: HasCallStack => ChatController -> Int -> IO (Int64, Text) +waitTrackerItemOf cc = pollUntil . trackerItem cc + +waitTrackerRepost :: HasCallStack => ChatController -> Int -> Int64 -> IO (Int64, Text) +waitTrackerRepost cc uses itemId0 = pollUntil $ mfilter ((/= itemId0) . fst) <$> trackerItem cc uses + +redeemedTrackerCount :: ChatController -> IO Int +redeemedTrackerCount cc = countRows cc "chat_items WHERE item_text LIKE '%last used%'" + +revokedTrackerCount :: ChatController -> IO Int +revokedTrackerCount cc = countRows cc ("chat_items WHERE item_text LIKE '" <> T.unpack (retiredText "SB-%") <> "'") + +retiredText :: Text -> Text +retiredText code = code <> "\nRevoked, can no longer be redeemed" + +extractCode :: HasCallStack => Text -> Text +extractCode t = maybe (error ("no badge code in: " <> T.unpack t)) formatBadgeCode (codeInTracker t) diff --git a/tests/Bots/BadgeService/GroupTests.hs b/tests/Bots/BadgeService/GroupTests.hs new file mode 100644 index 0000000000..c592178a91 --- /dev/null +++ b/tests/Bots/BadgeService/GroupTests.hs @@ -0,0 +1,175 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Bots.BadgeService.GroupTests where + +import BadgeService.Config (GroupConfig (..)) +import BadgeService.Group (GroupAction (..), GroupEvent (..), TrackerAction (..), coalesceTrackerRefreshes, inertGroupConfig, noOwnerHint, orphanHint, trackerDecision) +import BadgeService.Group.Command +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Calendar (fromGregorian) +import Data.Time.Clock (UTCTime (..), addUTCTime) +import Simplex.Chat.Badges (BadgeType (..)) +import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeText, randomBadgeCode) +import Simplex.Chat.Controller (ChatCommand (DeleteGroup, MemberRole)) +import Simplex.Chat.Library.Commands (parseChatCommand) +import Simplex.Chat.Types (GroupProfile (..)) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Util (tshow) +import Test.Hspec + +badgeGroupTests :: Spec +badgeGroupTests = describe "badge group" $ do + let p = groupCmdAction GROwner + issueUsage = ReplyText "Usage: /issue [months ] [uses ]" + bulkUsage = ReplyText "Usage: /bulk [months ] count " + revokeUsage = ReplyText "Usage: /revoke " + it "issue defaults" $ p "/issue supporter" `shouldBe` RunCmd (GCIssue BTSupporter 1 1) + it "issue with months and uses" $ p "/issue legend months 6 uses 50" `shouldBe` RunCmd (GCIssue BTLegend 6 50) + it "bulk with count" $ p "/bulk supporter count 20" `shouldBe` RunCmd (GCBulk BTSupporter 1 20) + it "rejects values past the upper bounds" $ do + p "/issue supporter uses 1001" `shouldBe` issueUsage + p "/bulk supporter count 101" `shouldBe` bulkUsage + p "/issue supporter months 256" `shouldBe` issueUsage + it "rejects zero values" $ do + p "/bulk supporter count 0" `shouldBe` bulkUsage + p "/issue supporter months 0" `shouldBe` issueUsage + it "rejects values past the machine word" $ do + let past64 = tshow (2 ^ (64 :: Int) + 1 :: Integer) + p ("/issue supporter uses " <> past64) `shouldBe` issueUsage + p ("/bulk supporter count " <> past64) `shouldBe` bulkUsage + p ("/issue supporter months " <> past64) `shouldBe` issueUsage + it "accepts the upper bounds" $ do + p "/issue supporter uses 1000" `shouldBe` RunCmd (GCIssue BTSupporter 1 1000) + p "/bulk supporter count 100" `shouldBe` RunCmd (GCBulk BTSupporter 1 100) + p "/issue supporter months 255" `shouldBe` RunCmd (GCIssue BTSupporter 255 1) + it "revoke" $ do + code <- newCode + p ("/revoke " <> badgeCodeText code) `shouldBe` RunCmd (GCRevoke code) + it "authorization, per role, as (issue, bulk, revoke)" $ do + someCode <- newCode + let runs role t = case groupCmdAction role t of + RunCmd _ -> True + _ -> False + allowed role = + ( runs role "/issue supporter", + runs role "/bulk supporter count 1", + runs role ("/revoke " <> badgeCodeText someCode) + ) + roles = [GRUnknown "future", GRRelay, GRObserver, GRAuthor, GRMember, GRModerator, GRAdmin, GROwner] + map (\role -> (role, allowed role)) roles + `shouldBe` [ (GRUnknown "future", (False, False, False)), + (GRRelay, (False, False, False)), + (GRObserver, (False, False, False)), + (GRAuthor, (False, False, False)), + (GRMember, (False, False, False)), + (GRModerator, (True, True, False)), + (GRAdmin, (True, True, True)), + (GROwner, (True, True, True)) + ] + describe "log hints give commands --run-cli parses for a group name with a space" $ do + let hintCommand = fst . T.breakOn " in --run-cli" . snd . T.breakOnEnd " with " + parsedHint hint = let cmd = hintCommand hint in (cmd, parseChatCommand (encodeUtf8 cmd)) + it "owner" $ + case parsedHint (T.replace "" "alice" $ noOwnerHint "SimpleX Badges_1") of + (_, Right (MemberRole g m GROwner)) -> (g, m) `shouldBe` ("SimpleX Badges_1", "alice") + (cmd, r) -> expectationFailure $ "owner command " <> show cmd <> " parsed as " <> show r + it "orphan group" $ + case parsedHint (orphanHint "SimpleX Badges_1") of + (_, Right (DeleteGroup g)) -> g `shouldBe` "SimpleX Badges_1" + (cmd, r) -> expectationFailure $ "delete command " <> show cmd <> " parsed as " <> show r + describe "classifying a group message" $ do + it "answers an advertised command that does not parse" $ do + map p + [ "/issue", + "/issue supporter uses 0", + "/issue supporter 3", + "/issue suporter", + "/issue supporter uses 5", + "/bulk supporter", + "/revoke", + "/revoke not-a-badge-code" + ] + `shouldBe` replicate 5 issueUsage <> [bulkUsage, revokeUsage, revokeUsage] + it "answers a mistyped command only to a sender who may run it" $ do + map (`groupCmdAction` "/issue supporter uses 0") [GRMember, GRModerator, GRAdmin] + `shouldBe` [IgnoreMsg, issueUsage, issueUsage] + map (`groupCmdAction` "/bulk supporter") [GRMember, GRModerator, GRAdmin] + `shouldBe` [IgnoreMsg, bulkUsage, bulkUsage] + map (`groupCmdAction` "/revoke not-a-badge-code") [GRMember, GRModerator, GRAdmin] + `shouldBe` [IgnoreMsg, IgnoreMsg, revokeUsage] + it "accepts a command padded with whitespace" $ do + p " /issue supporter " `shouldBe` RunCmd (GCIssue BTSupporter 1 1) + p "\t/bulk supporter count 1" `shouldBe` RunCmd (GCBulk BTSupporter 1 1) + it "says nothing to anything that is not an advertised command" $ + map p ["", "hello", "issue supporter", "/issued supporter", "/help", "see /issue above"] + `shouldBe` replicate 6 IgnoreMsg + it "says nothing to a command the sender may not run" $ do + map (`groupCmdAction` "/issue supporter") [GRMember, GRAuthor] `shouldBe` [IgnoreMsg, IgnoreMsg] + groupCmdAction GRMember "/bulk supporter count 1" `shouldBe` IgnoreMsg + it "tells a sender below admin that their revoke did not happen" $ do + code <- newCode + map (`groupCmdAction` ("/revoke " <> badgeCodeText code)) [GRMember, GRModerator] + `shouldBe` replicate 2 (ReplyText "Only admins can revoke codes. This code is now visible to all members.") + describe "configured group name and description" $ do + let cfg name descr = GroupConfig {gDisplayName = name, gDescription = descr} + profile name descr = + GroupProfile + { displayName = name, + fullName = "", + shortDescr = Nothing, + description = descr, + image = Nothing, + publicGroup = Nothing, + groupPreferences = Nothing, + memberAdmission = Nothing + } + it "says nothing when the config matches the group profile" $ do + inertGroupConfig (cfg "SimpleX Badges" (Just "badge ops desk")) (profile "SimpleX Badges" (Just "badge ops desk")) + `shouldBe` Nothing + inertGroupConfig (cfg "SimpleX Badges" Nothing) (profile "SimpleX Badges" Nothing) `shouldBe` Nothing + it "says nothing when an omitted description meets an empty one" $ + inertGroupConfig (cfg "SimpleX Badges" Nothing) (profile "SimpleX Badges" (Just "")) `shouldBe` Nothing + it "reports a name the config would change" $ + inertGroupConfig (cfg "SimpleX Badges 2026" (Just "badge ops desk")) (profile "SimpleX Badges" (Just "badge ops desk")) + `shouldBe` Just "badge group config is not applied to an existing group: display_name \"SimpleX Badges 2026\", group has \"SimpleX Badges\"" + it "shows a non-ASCII name as written" $ + inertGroupConfig (cfg "Значки" Nothing) (profile "SimpleX Badges" Nothing) + `shouldBe` Just "badge group config is not applied to an existing group: display_name \"Значки\", group has \"SimpleX Badges\"" + it "reports a description the config would change or remove" $ do + inertGroupConfig (cfg "SimpleX Badges" (Just "new desk")) (profile "SimpleX Badges" (Just "badge ops desk")) + `shouldBe` Just "badge group config is not applied to an existing group: description \"new desk\", group has \"badge ops desk\"" + inertGroupConfig (cfg "SimpleX Badges" Nothing) (profile "SimpleX Badges" (Just "badge ops desk")) + `shouldBe` Just "badge group config is not applied to an existing group: description \"\", group has \"badge ops desk\"" + it "reports both fields when both would change" $ + inertGroupConfig (cfg "SimpleX Badges 2026" (Just "new desk")) (profile "SimpleX Badges" Nothing) + `shouldBe` Just + "badge group config is not applied to an existing group: \ + \display_name \"SimpleX Badges 2026\", group has \"SimpleX Badges\"; \ + \description \"new desk\", group has \"\"" + describe "tracker" $ + it "edits within 24h, reposts after" $ do + let t0 = UTCTime (fromGregorian 2026 1 1) 0 + within = addUTCTime (23 * 3600) t0 + past = addUTCTime (25 * 3600) t0 + trackerDecision within t0 `shouldBe` Edit + trackerDecision past t0 `shouldBe` Repost + describe "coalescing tracker refreshes" $ do + it "keeps the last refresh of each code" $ do + code1 <- newCode + code2 <- newCode + coalesceTrackerRefreshes [GETracker 1 code1, GETracker 2 code2, GETracker 1 code1] + `shouldBe` [GETracker 2 code2, GETracker 1 code1] + it "keeps every other event, in order" $ do + code <- newCode + coalesceTrackerRefreshes + [ GEInGroup 7 (GACommand GRAdmin "a"), + GETracker 1 code, + GEInGroup 7 GAJoined, + GETracker 1 code + ] + `shouldBe` [GEInGroup 7 (GACommand GRAdmin "a"), GEInGroup 7 GAJoined, GETracker 1 code] + +newCode :: IO BadgeCode +newCode = C.newRandom >>= randomBadgeCode diff --git a/tests/Bots/BadgeService/WebTests.hs b/tests/Bots/BadgeService/WebTests.hs index bd8b166094..8365d80b6e 100644 --- a/tests/Bots/BadgeService/WebTests.hs +++ b/tests/Bots/BadgeService/WebTests.hs @@ -14,21 +14,21 @@ import BadgeService.Poller import BadgeService.Providers import BadgeService.Providers.BTCPay (btcpayProvider, listPageSize, maxListPages) import BadgeService.Providers.Stripe (stripeProvider) -import BadgeService.Store (FundingCredit (..), IssuedCode (..), NewCodePurchase (..), RevokeResult (..), createCodePurchase, getBadgeCode, insertBadgeCode, revokeCode) +import BadgeService.Store (KeyPurchase (..), KeyRedemption (..), ManagedGroup (..), NewCodePurchase (..), RevokeResult (..), createCodePurchase, getCodePurchaseForKey, getManagedGroup, insertBadgeCode, insertManagedGroup, markOwnerBootstrapped, revokeCode) import BadgeService.Store.Invoices import BadgeService.Waiters (awaitStatus, newWaiters, publish, waitingCount) import BadgeService.Web.Server -import Bots.BadgeService.BotTests (newPurchaseKeys) +import Bots.BadgeService.BotTests (codeCounts, newPurchaseKeys) import Bots.BadgeService.CatalogTests (WebOffer (..), WebPrice (..), parseCatalogSource) import Bots.BadgeService.FakeBTCPay import Bots.BadgeService.FakeStripe (FakeStripe (..), fakeIntentStatus, setIntentState, stripeEvent, stripeSigHeader, withFakeStripe) 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 (join, replicateM, replicateM_, void, when) +import Control.Monad (forM_, join, replicateM, replicateM_, void, when) import Data.Aeson ((.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Key as K @@ -41,9 +41,10 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (toLower) import Data.Either (isLeft) import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) -import Data.List (sort, sortOn) +import Data.Int (Int64) +import Data.List (isInfixOf, sort, sortOn) import qualified Data.Map.Strict as Map -import Data.Maybe (isJust, isNothing, fromMaybe) +import Data.Maybe (catMaybes, isJust, isNothing, fromMaybe, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -57,15 +58,16 @@ import Network.HTTP.Client (Manager, Request (..), RequestBody (..), Response, d import Network.HTTP.Types (Header, HeaderName, hCacheControl, hContentType) import Network.HTTP.Types.Status (statusCode) import qualified Network.Wai.Handler.Warp as Warp -import Simplex.Chat.Badges (BadgeType (..)) +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey (..), BadgeType (..)) import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..)) import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..), BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..)) import Simplex.Chat.PaymentService.Types (CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), PaymentStatus (..), ServicePaymentDestination (..), ServicePaymentMethod (..)) import Simplex.Messaging.Agent.Store.Common (DBStore (..), withConnection, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface -import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfig (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSSignature (..)) import Simplex.Messaging.Encoding.String (textDecode, textEncode) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import System.Directory (createDirectoryIfMissing, createFileLink, doesFileExist, listDirectory) @@ -79,34 +81,37 @@ 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 Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropDatabaseAndUser) +import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations #else import BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations) import Data.String (fromString) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations #endif #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 @@ -134,6 +139,10 @@ badgeWebTests = do describe "badge service schema" $ do it "carries the five service-only columns" testServiceColumns it "refuses a duplicate provider_ref" testProviderRefUnique + it "M20260918 adds the multi-use columns and the group table" testGroupOpsColumns + it "M20260918 defaults a fresh code to one use with none spent" testGroupOpsRedeemCounts + it "migrates all the way down and up again" testSchemaDownUpCycle + it "rolls back only the group migration and re-applies it, keeping a code redeemed twice spent" testGroupOpsDownUp describe "badge service store" $ do it "writes the invoice, its code and their link atomically" testCreationIsAtomic it "newInvoiceId is 128 CSPRNG bits, base64url, and two calls differ" testNewInvoiceIdRandom @@ -144,7 +153,15 @@ badgeWebTests = do it "expireOverdue spares an invoice funded by dust, or by the verdict alone" testExpireOverdueSparesAZeroAmount it "readCatalogRows drops every disabled row" testReadCatalogRowsDropsDisabled it "a revoked code cannot be redeemed, and a redeemed code cannot be revoked" testRevokeAndRedeemExcludeEachOther + it "a multi-use code can be revoked while it has uses left, and not once they are gone" testRevokeMultiUseCode it "every timestamp round-trips to the second" testTimestampRoundTrip + describe "managed group" $ do + it "round-trips and bootstraps once" testManagedGroupRoundTripsAndBootstrapsOnce + it "refuses a second row and burns the flag per group" testManagedGroupIsSingleRow + describe "multi-use" $ do + it "finds a key's own purchase with its newest credential, and none for another key" testKeyPurchaseLookup + it "gives concurrent claims exactly the code's uses" testMultiUseConcurrentClaimsUpToLimit + it "refuses a second claim by the same key without using up a use" testSameKeyClaimsOnce describe "badge service catalog seed" $ do it "writes the compiled-in catalog into an empty database" testSeedWritesTheCatalog it "leaves exactly one row per id when the service starts twice" testSeedIsIdempotent @@ -237,6 +254,7 @@ badgeWebTests = do it "reports a skipped invoice once, and again only after the interval" testSkipWarningsAreRateLimited it "warns once for a provider that stays down, not once a pass" testOutageWarnsOnceNotEveryPass it "holds the skip log under its cap when every reason is fresh" testSkipReasonsStayBounded + it "warns once for all the invoices it did not sell, not once per invoice" testStrangerSkipsShareOneWarning it "raises a skip naming an invoice this service sold" testSkipNamingOurInvoiceIsRaised it "holds the sweep back until a pass has accounted for every invoice" testSweepWaitsForAPassThatSawEverything it "settles the rest of the pass around an invoice that throws" testOneBadInvoiceDoesNotStopThePass @@ -301,6 +319,59 @@ testServiceColumns = withServiceStore $ \st -> do columnsOf st "sx_badge_service_badge_codes" >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["expires_at", "revoked_at"]) +testGroupOpsColumns :: IO () +testGroupOpsColumns = withServiceStore assertGroupOpsColumns + +assertGroupOpsColumns :: HasCallStack => DBStore -> IO () +assertGroupOpsColumns st = do + columnsOf st "sx_badge_service_badge_codes" + >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["redeem_limit", "redeem_count", "group_item_id", "group_item_sent_at"]) + columnsOf st "sx_badge_service_group" + >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["group_id", "group_link", "owner_bootstrapped", "created_at"]) + +runMigrations :: DBStore -> MigrationsToRun -> IO () +#if defined(dbPostgres) +runMigrations st = Migrations.run st Nothing +#else +runMigrations st = Migrations.run st Nothing True +#endif + +testSchemaDownUpCycle :: IO () +testSchemaDownUpCycle = withServiceStore $ \st -> do + let downMigrations = mapMaybe toDownMigration badgeServiceSchemaMigrations + length downMigrations `shouldBe` length badgeServiceSchemaMigrations + runMigrations st $ MTRDown downMigrations + columnsOf st "sx_badge_service_badge_codes" `shouldReturn` [] + columnsOf st "sx_badge_service_group" `shouldReturn` [] + runMigrations st $ MTRUp badgeServiceSchemaMigrations + assertGroupOpsColumns st + +testGroupOpsDownUp :: IO () +testGroupOpsDownUp = withServiceStore $ \st -> do + now <- truncateToSecond <$> getCurrentTime + let redeemedHash = digestFixture 44 + unredeemedHash = digestFixture 45 + groupOps = [m | m@Migration {name = "20260918_badge_group_ops"} <- badgeServiceSchemaMigrations] + redeemed <- insertCode st redeemedHash CPSPaid 2 now + void $ insertCode st unredeemedHash CPSPaid 1 now + -- Two purchases of one code would break a down migration that restored the unique index. + replicateM_ 2 $ newPurchaseKeys >>= claimUse st redeemed now >>= (`shouldSatisfy` isJust) + runMigrations st $ MTRDown (mapMaybe toDownMigration groupOps) + columnsOf st "sx_badge_service_badge_codes" >>= (`shouldNotSatisfy` elem "redeem_count") + runMigrations st $ MTRUp groupOps + codeCounts st redeemedHash `shouldReturn` Just (1, 1) + codeCounts st unredeemedHash `shouldReturn` Just (1, 0) + +testGroupOpsRedeemCounts :: IO () +testGroupOpsRedeemCounts = withServiceStore $ \st -> do + let codeHash = digestFixture 43 + withConnection st $ \db -> + DB.execute + db + "INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) VALUES (?,?,?,?,?)" + (DB.Binary codeHash, "supporter" :: Text, 1 :: Int, "free" :: Text, someCreated) + codeCounts st codeHash `shouldReturn` Just (1, 0) + testProviderRefUnique :: IO () testProviderRefUnique = withServiceStore $ \st -> do seedBadgePrice st "price1" @@ -463,25 +534,35 @@ expireAllOverdue st now = overdueInvoices st now >>= expireOverdue st now . map testRevokeAndRedeemExcludeEachOther :: IO () testRevokeAndRedeemExcludeEachOther = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime - (purchaseKey, masterKey) <- newPurchaseKeys - let newCode codeHash = withTransaction st $ \db -> do - insertBadgeCode db codeHash BTSupporter 1 CPSPaid now - maybe (error "the code was not written") (\IssuedCode {badgeCodeId} -> badgeCodeId) <$> getBadgeCode db codeHash - redeem badgeCodeId = withTransaction st $ \db -> createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType = BTSupporter} now + keys <- newPurchaseKeys + let newCode codeHash = insertCode st codeHash CPSPaid 1 now + redeem badgeCodeId = claimUse st badgeCodeId now keys revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now - unredeemed codeHash = - withTransaction st (`getBadgeCode` codeHash) >>= \case - Just IssuedCode {redemption = Uncredited} -> pure True - _ -> pure False revokedFirst <- newCode "revoked-first" - revoke "revoked-first" `shouldReturn` Revoked + revoke "revoked-first" `shouldReturn` Revoked revokedFirst redeem revokedFirst `shouldReturn` Nothing - unredeemed "revoked-first" `shouldReturn` True + codeCounts st "revoked-first" `shouldReturn` Just (1, 0) redeemedFirst <- newCode "redeemed-first" isJust <$> redeem redeemedFirst `shouldReturn` True revoke "redeemed-first" `shouldReturn` AlreadyRedeemed redeem redeemedFirst `shouldReturn` Nothing +testRevokeMultiUseCode :: IO () +testRevokeMultiUseCode = withServiceStore $ \st -> do + now <- truncateToSecond <$> getCurrentTime + let newCode codeHash = insertCode st codeHash CPSPaid 2 now + -- A purchase key redeems once, so every redemption here brings its own. + redeem badgeCodeId = newPurchaseKeys >>= claimUse st badgeCodeId now + revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now + partlyUsed <- newCode "partly-used" + isJust <$> redeem partlyUsed `shouldReturn` True + revoke "partly-used" `shouldReturn` Revoked partlyUsed + redeem partlyUsed `shouldReturn` Nothing + usedUp <- newCode "used-up" + isJust <$> redeem usedUp `shouldReturn` True + isJust <$> redeem usedUp `shouldReturn` True + revoke "used-up" `shouldReturn` AlreadyRedeemed + testReadCatalogRowsDropsDisabled :: IO () testReadCatalogRowsDropsDisabled = withServiceStore $ \st -> do insertPrice st "price-active" "supporter" 500 "active" @@ -615,6 +696,103 @@ testTimestampRoundTrip = withServiceStore $ \st -> do irExpiresAt row `shouldBe` truncated irCreatedAt row `shouldBe` truncated +testManagedGroupRoundTripsAndBootstrapsOnce :: IO () +testManagedGroupRoundTripsAndBootstrapsOnce = withServiceStore $ \st -> do + now <- truncateToSecond <$> getCurrentTime + beforeInsert <- withTransaction st getManagedGroup + beforeInsert `shouldBe` Nothing + withTransaction st $ \db -> insertManagedGroup db 42 "https://link" now + afterInsert <- withTransaction st getManagedGroup + afterInsert `shouldBe` Just ManagedGroup {mgGroupId = 42, mgGroupLink = "https://link", mgOwnerBootstrapped = False} + show afterInsert `shouldNotSatisfy` ("https://link" `isInfixOf`) + firstMark <- withTransaction st (`markOwnerBootstrapped` 42) + secondMark <- withTransaction st (`markOwnerBootstrapped` 42) + (firstMark, secondMark) `shouldBe` (True, False) + +testManagedGroupIsSingleRow :: IO () +testManagedGroupIsSingleRow = withServiceStore $ \st -> do + now <- truncateToSecond <$> getCurrentTime + withTransaction st $ \db -> insertManagedGroup db 42 "https://link" now + withTransaction st $ \db -> insertManagedGroup db 43 "https://other" now + withTransaction st $ \db -> insertManagedGroup db 42 "https://again" now + managedGroupRows st `shouldReturn` [(42, "https://link")] + withTransaction st (`markOwnerBootstrapped` 43) `shouldReturn` False + (fmap mgOwnerBootstrapped <$> withTransaction st getManagedGroup) `shouldReturn` Just False + withTransaction st (`markOwnerBootstrapped` 42) `shouldReturn` True + (fmap mgOwnerBootstrapped <$> withTransaction st getManagedGroup) `shouldReturn` Just True + +managedGroupRows :: DBStore -> IO [(Int64, Text)] +managedGroupRows st = + withConnection st $ \db -> DB.query_ db "SELECT group_id, group_link FROM sx_badge_service_group ORDER BY group_id" + +insertCode :: DBStore -> ByteString -> BadgeCodePaymentStatus -> Int -> UTCTime -> IO Int64 +insertCode st codeHash paymentStatus redeemLimit now = + withTransaction st $ \db -> insertBadgeCode db codeHash BTSupporter 1 paymentStatus redeemLimit now + +claimUse :: DBStore -> Int64 -> UTCTime -> (C.PublicKeyEd25519, BadgeMasterKey) -> IO (Maybe Int64) +claimUse st badgeCodeId now (purchaseKey, masterKey) = + withTransaction st $ \db -> + createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType = BTSupporter} now + +-- The signature is dummy bytes, since only the JSON round-trip is under test; 80 is the length its decoder accepts. +seededCredential :: BadgeMasterKey -> UTCTime -> BadgeCredential +seededCredential masterKey expiry = + BadgeCredential {badgeKeyIdx = 1, masterKey, signature = BBSSignature (BS.replicate 80 7), badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}} + +seedIssuance :: DBStore -> Int64 -> Text -> BadgeMasterKey -> UTCTime -> UTCTime -> IO () +seedIssuance st purchaseId issuanceId masterKey at periodEnd = + withConnection st $ \db -> + DB.execute + db + "INSERT INTO sx_badge_service_badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) VALUES (?,?,?,?,?,?,?,?)" + (issuanceId, purchaseId, "supporter" :: Text, at, periodEnd, periodEnd, DB.Binary (LB.toStrict (J.encode (seededCredential masterKey periodEnd))), at) + +testKeyPurchaseLookup :: IO () +testKeyPurchaseLookup = withServiceStore $ \st -> do + now <- getCurrentTime + let codeHash = digestFixture 41 + badgeCodeId <- insertCode st codeHash CPSFree 2 now + keys@(k1, mk1) <- newPurchaseKeys + Just purchaseId <- claimUse st badgeCodeId now keys + withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId k1) >>= \case + KeyRedeemedUnreadable -> pure () + _ -> expectationFailure "expected a purchase with no issuance to be unreadable" + let firstPeriodEnd = someExpiry + renewedPeriodEnd = addUTCTime 86400 someExpiry + seedIssuance st purchaseId "iss-first" mk1 now firstPeriodEnd + seedIssuance st purchaseId "iss-renewed" mk1 now renewedPeriodEnd + (unusedKey, _) <- newPurchaseKeys + withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId unusedKey) >>= \case + KeyUnredeemed -> pure () + _ -> expectationFailure "expected no purchase for a key that never redeemed" + replayed <- withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId k1) + case replayed of + KeyRedeemed KeyPurchase {badgePurchaseId, credential} -> do + badgePurchaseId `shouldBe` purchaseId + credential `shouldBe` seededCredential mk1 renewedPeriodEnd + _ -> expectationFailure "expected the redeemed key's purchase" + +testSameKeyClaimsOnce :: IO () +testSameKeyClaimsOnce = withServiceStore $ \st -> do + now <- getCurrentTime + let codeHash = digestFixture 46 + badgeCodeId <- insertCode st codeHash CPSFree 3 now + keys <- newPurchaseKeys + claimUse st badgeCodeId now keys >>= (`shouldSatisfy` isJust) + -- The key is unique across purchases, so the second insert fails and its transaction returns the use. + claimUse st badgeCodeId now keys `shouldThrow` anyException + codeCounts st codeHash `shouldReturn` Just (3, 1) + +testMultiUseConcurrentClaimsUpToLimit :: IO () +testMultiUseConcurrentClaimsUpToLimit = withServiceStore $ \st -> do + now <- getCurrentTime + let codeHash = digestFixture 42 + badgeCodeId <- insertCode st codeHash CPSFree 3 now + contenders <- replicateM 6 newPurchaseKeys + results <- Async.mapConcurrently (claimUse st badgeCodeId now) contenders + length (catMaybes results) `shouldBe` 3 + codeCounts st codeHash `shouldReturn` Just (3, 3) + data StubCall = StubCreate ServicePaymentMethod OrderDraft | StubRead Text @@ -801,7 +979,7 @@ testServiceConfig staticDir trustForwarded = stripe = Nothing, poll = PollConfig {pWaitingSeconds = 3, pIdleSeconds = 60}, issuer = Nothing, - devChatRedeem = False, + group = Nothing, devAcceptUnverifiedStoreReceipts = False } @@ -1290,11 +1468,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 @@ -2356,7 +2540,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 @@ -2485,9 +2669,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" @@ -2498,7 +2686,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")}, ()) @@ -2666,13 +2854,19 @@ testSkipNamingOurInvoiceIsRaised = bounded "skip ownership" $ withStubPoller rac invoiceStatus (weStore env) iid `shouldReturn` ISPaid testSkipReasonsStayBounded :: IO () -testSkipReasonsStayBounded = bounded "skip reasons bounded" $ withStubPoller raceHold $ \ref poller _ _ -> do - let reasons n = [(Just ("p-" <> tshow i), "btcpay invoice p-" <> tshow i <> ": unknown method") | i <- [1 .. n :: Int]] - setSkipped ref (reasons (maxSkipReasons + 500)) +testSkipReasonsStayBounded = bounded "skip reasons bounded" $ withStubPoller raceHold $ \_ poller _ _ -> do + now <- getCurrentTime + forM_ [1 .. maxSkipReasons + 500] $ \i -> dueToWarn poller now ("btcpay invoice p-" <> tshow i <> ": unknown method") runOnePass poller held <- Map.size <$> readTVarIO (peSkipped poller) held `shouldSatisfy` (<= maxSkipReasons) +testStrangerSkipsShareOneWarning :: IO () +testStrangerSkipsShareOneWarning = bounded "stranger skips" $ withStubPoller raceHold $ \ref poller _ _ -> do + setSkipped ref [(Just ("stranger-" <> tshow i), "btcpay invoice stranger-" <> tshow i <> ": unknown method") | i <- [1 .. 3 :: Int]] + runOnePass poller + Map.size <$> readTVarIO (peSkipped poller) `shouldReturn` 1 + -- | The failure text carries the whole request, whose window moves with the clock, so the two -- messages differ only in startDate to prove the limiter key ignores it rather than warning -- every pass. @@ -3285,7 +3479,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 a30546ed91..baf8d10d16 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -19,9 +19,10 @@ import qualified Data.Text as T import ProtocolTests (testGroupProfile) import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) -import Simplex.Chat.Types (GroupProfile (..)) +import Simplex.Chat.Types (B64UrlByteString (..), GroupProfile (..)) import Simplex.Chat.Controller (CorsOrigin (..)) -import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, removeStaleFiles, writeCorsConfig) +import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, publicGroupIdFileName, removeStaleFiles, writeCorsConfig) +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsKeyGen) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Util (decodeJSON) @@ -547,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 = @@ -569,8 +568,9 @@ testWebPreviewChannelDeleted ps = testWebPreviewStaleCleanup :: HasCallStack => TestParams -> IO () testWebPreviewStaleCleanup ps = do let webDir = tmpPath ps "web_stale_unit" - activeFile = "abc123.json" - staleFile = "AAAA_stale.json" + previewFileName s = publicGroupIdFileName (B64UrlByteString $ C.sha256Hash s) <> ".json" + activeFile = previewFileName "active" + staleFile = previewFileName "stale" safeFile = "my.config.json" createDirectoryIfMissing True webDir writeFile (webDir activeFile) "{}" 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 6a85f6f463..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 @@ -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) @@ -272,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 } @@ -307,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 @@ -336,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 } @@ -1135,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" @@ -1160,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" @@ -1201,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 () @@ -1270,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 @@ -1288,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}} @@ -1337,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" @@ -1346,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" @@ -1362,13 +1361,73 @@ 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 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 + (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) + 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 -> @@ -1376,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 @@ -1399,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 @@ -1426,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" @@ -1441,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 @@ -1484,26 +1548,35 @@ 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 + 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\"}" + 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 "" alice ##> "/_db delete" alice <## "ok" -- cannot start chat after delete alice ##> "/_start" alice <## "error: chat store changed, please restart chat" - doesDirectoryExist "./tests/tmp/alice_files" `shouldReturn` False - alice ##> "/_db import {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}" + doesDirectoryExist aliceFiles `shouldReturn` False + forM_ exportedDBs $ \f -> doesFileExist f `shouldReturn` False + 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 () @@ -1529,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" @@ -1545,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" @@ -1686,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')" @@ -1712,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 @@ -1722,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 @@ -1735,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_ @@ -1965,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}") @@ -1990,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 @@ -2276,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" @@ -2298,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" @@ -2307,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} @@ -2331,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 @@ -2369,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" @@ -2381,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" @@ -2390,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} @@ -2491,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 @@ -2503,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" @@ -2512,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" @@ -2531,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} @@ -2543,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" @@ -2570,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)" @@ -2580,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"] @@ -2593,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"] @@ -2633,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"] @@ -2646,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"] @@ -2812,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" @@ -2828,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") @@ -2862,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")]) @@ -2949,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 () @@ -2969,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" @@ -2978,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 () @@ -2993,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" @@ -3016,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" @@ -3025,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" @@ -3148,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" @@ -3219,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 2a4eeccb74..31ca1bae73 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -20,7 +20,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) -import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds) import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.Map.Strict as M import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgePurchase (..), BadgeRequest (..), BadgeType (..), generateMasterKey, issueBadge, verifyPayment) @@ -284,11 +284,13 @@ futureDate = posixSecondsToUTCTime 4102444800 -- 2100-01-01 issueTestBadge :: BBSSecretKey -> UTCTime -> IO BadgeCredential issueTestBadge sk = issueTestBadgeType sk BTSupporter +-- The expiry is signed as written but PostgreSQL stores it to the microsecond, so it is cut to whole seconds, as the service issues it. issueTestBadgeType :: BBSSecretKey -> BadgeType -> UTCTime -> IO BadgeCredential -issueTestBadgeType sk badgeType badgeExpiry = do +issueTestBadgeType sk badgeType expiry = do drg <- C.newRandom mk <- generateMasterKey drg - let info = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""} + let badgeExpiry = posixSecondsToUTCTime $ fromInteger $ truncate $ utcTimeToPOSIXSeconds expiry + info = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""} Just vreq <- verifyPayment (BPRedeemCode "TEST") BadgeRequest {masterKey = mk, badgeInfo = info} Right cred <- issueBadge 1 sk vreq pure cred @@ -738,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 () @@ -801,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 } @@ -812,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' = @@ -857,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 @@ -1260,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" ] @@ -1282,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" @@ -1320,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 @@ -1402,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...)" @@ -1415,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 @@ -1425,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:" @@ -1500,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" @@ -1552,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 @@ -1932,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"] @@ -2512,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 @@ -2539,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 } @@ -2579,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:" @@ -2704,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" @@ -2715,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" @@ -2724,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 @@ -2778,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")]) @@ -2847,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))" @@ -2891,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:" @@ -2919,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:" @@ -2942,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 $ @@ -3055,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" @@ -3090,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" @@ -3353,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" @@ -3395,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" @@ -3425,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" @@ -3457,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' = @@ -3639,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' = @@ -3673,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") @@ -3702,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' = @@ -3733,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" @@ -3958,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" @@ -3984,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' = @@ -4082,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 @@ -4134,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" @@ -4338,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" @@ -4385,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") @@ -4430,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 () @@ -4571,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/MarkdownTests.hs b/tests/MarkdownTests.hs index 1f9936044c..903e3b3773 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -406,13 +406,20 @@ textWithCommands = describe "text with commands" do "/filter 1" <==> command "filter" "/filter" <> " 1" -- this is parsed as full command by parseMaybeMarkdownList "send /'filter 1'." <==> "send " <> command "filter 1" "/'filter 1'" <> "." "send /'filter 1.'!" <==> "send " <> command "filter 1." "/'filter 1.'" <> "!" + "send /he?lp" <==> "send " <> command "he?lp" "/he?lp" + "/-" <==> command "-" "/-" + "send /+." <==> "send " <> command "+" "/+" <> "." + "/'+'" <==> command "+" "/'+'" + it "calculator keys" do + "/C /± /% /÷" <==> command "C" "/C" <> " " <> command "±" "/±" <> " " <> command "%" "/%" <> " " <> command "÷" "/÷" + "/√ /0 /. /=" <==> command "√" "/√" <> " " <> command "0" "/0" <> " " <> command "." "/." <> " " <> command "=" "/=" + "/C `\160\160\160\160`/neg `\160\160`" <==> command "C" "/C" <> " " <> markdown Snippet "\160\160\160\160" <> command "neg" "/neg" <> " " <> markdown Snippet "\160\160" it "ignored as markdown" $ do "send /'filter 1" <==> "send /'filter 1" "send /help /'filter 1" <==> "send " <> command "help" "/help" <> " /'filter 1" "send / help!" <==> "send / help!" "send /help / filter" <==> "send " <> command "help" "/help" <> " / filter" "send /help /" <==> "send " <> command "help" "/help" <> " /" - "send /he?lp" <==> "send /he?lp" uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri @@ -471,6 +478,7 @@ multilineMarkdownList = describe "multiline markdown" do it "command markdown" do "/link 1" <<==>> [command' "link 1" "/link 1"] " /link 1" <<==>> [command' "link 1" " /link 1"] + "*0*\n/7 /+" <<==>> [FormattedText (Just Bold) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] testSanitizeUri :: Spec testSanitizeUri = describe "sanitizeUri" $ do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 554398520e..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 @@ -35,6 +36,7 @@ protocolTests = do decodeChatMessageTest shortLinkDataTests batchLimitTests + forwardDepthTests preferencesJSONTests preferencesJSONTests :: Spec @@ -86,6 +88,19 @@ batchLimitTests = describe "Chat message batch limits" $ do [Left e] -> e rs -> "expected a single error, got " <> show (length rs) <> " results" +forwardDepthTests :: Spec +forwardDepthTests = describe "Chat message forward depth limit" $ do + it "parses x.grp.msg.forward at the depth limit" $ + chatMsgToBody (nestedFwd maxFwdDepth) ==## nestedFwd maxFwdDepth + it "rejects x.grp.msg.forward above the depth limit" $ + case parseChatMessages $ chatMsgToBody $ nestedFwd $ maxFwdDepth + 1 of + [Left e] -> e `shouldSatisfy` isInfixOf "forward depth exceeds limit" + _ -> expectationFailure "single parse error expected" + where + nestedFwd :: Int -> ChatMessage 'Json + nestedFwd 0 = ChatMessage chatInitialVRange Nothing $ XMsgNew $ mcSimple $ MCText "hello" + nestedFwd n = ChatMessage chatInitialVRange Nothing $ XGrpMsgForward (GrpMsgForward FwdChannel $ systemToUTCTime $ MkSystemTime 1 1) (nestedFwd $ n - 1) + srv :: SMPServer srv = SMPServer "smp.simplex.im" "5223" (C.KeyHash "\215m\248\251") @@ -225,7 +240,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"9-20\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"9-21\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -342,13 +357,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"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\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"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\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"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} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"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\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"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\"}}}}}}" @@ -363,7 +378,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"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" $ - "{\"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-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"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} 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\"}}}}}" @@ -389,12 +404,11 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.grp.direct.inv without content" $ "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"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\"}}" #==# XGrpDirectInv testConnReq Nothing Nothing - -- it "x.grp.msg.forward" - -- $ "{\"v\":\"9\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}" - -- #==# XGrpMsgForward - -- (MemberId "\1\2\3\4") - -- (ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello")))) - -- (systemToUTCTime $ MkSystemTime 1 1) + it "x.grp.msg.forward" $ + "{\"v\":\"9\",\"event\":\"x.grp.msg.forward\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberName\":\"alice\",\"msg\":{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}},\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}" + #==# XGrpMsgForward + (GrpMsgForward (FwdMember (MemberId "\1\2\3\4") "alice") (systemToUTCTime $ MkSystemTime 1 1)) + (ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello")))) it "x.info.probe" $ "{\"v\":\"9\",\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbe (Probe "\1\2\3\4") @@ -407,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 = "[]"} diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 1bb6ac34eb..c191402e9f 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -14,19 +14,26 @@ import Control.Monad import Control.Monad.Except (runExceptT) import qualified Data.Aeson as J import qualified Data.ByteString as B +import Data.ByteString.Builder (toLazyByteString) import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (find, isPrefixOf) import qualified Data.Map.Strict as M +import Data.Word (Word32) import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), versionNumber) import Simplex.Chat.Files (safeFileNameStr) import Simplex.Chat.Library.Commands (parseChatCommand) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File import Simplex.Chat.Remote (remoteFilesFolder, validRemoteFileName) -import Simplex.Chat.Remote.Protocol (remoteStoreFile) +import Simplex.Chat.Remote.Protocol (encryptEncodeHTTP2Body, parseDecryptHTTP2Body, remoteStoreFile) import Simplex.Chat.Remote.Types +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) +import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Encoding.String (strEncode) +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Transport (TSbChainKeys (..)) +import Simplex.Messaging.Transport.HTTP2 (HTTP2BodyChunk (..), getHTTP2Body) import Simplex.Messaging.Util import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.FilePath (takeFileName, ()) @@ -54,6 +61,41 @@ remoteTests = describe "Remote" $ do filter (not . sanitized) fileNames `shouldBe` [] it "sanitizes to a name with no directory components" $ \_ -> filter (not . bareName) fileNames `shouldBe` [] + describe "skipped keys" $ do + it "keeps the most recent skipped keys" $ \_ -> do + (_, pk) <- atomically . C.generateKeyPair =<< C.newRandom + let (ck, _) = C.sbcInit "" ("secret" :: B.ByteString) + sndCounter <- newTVarIO 0 + rcvCounter <- newTVarIO 0 + sndKey <- newTVarIO ck + rcvKey <- newTVarIO ck + skippedKeys <- newTVarIO M.empty + let rc = RemoteCrypto {sessionCode = "", sndCounter, rcvCounter, chainKeys = TSbChainKeys {sndKey, rcvKey}, skippedKeys, signatures = RSSign pk pk, compression = False} + receive corrId = eitherToMaybe <$> atomically (getRemoteRcvKeys rc corrId) + sent <- replicateM 1280 $ atomically $ getRemoteSndKeys rc + let sentKeys = M.fromList [(corrId, (cmdKN, fileKN)) | (corrId, cmdKN, fileKN) <- sent] + forM_ [256, 512 .. 1280] $ \corrId -> receive corrId `shouldReturn` M.lookup corrId sentKeys + M.size <$> readTVarIO skippedKeys `shouldReturn` 1024 + receive 251 `shouldReturn` Nothing + receive 252 `shouldReturn` M.lookup 252 sentKeys + describe "body size limit" $ do + it "rejects encrypted body above limit without reading it" $ \_ -> do + rc <- testRemoteCrypto + chunks <- newIORef [smpEncode (1 :: Word32, 1025 :: Word32), "body"] + r <- parseDecryptChunks 1024 rc chunks + r `shouldSatisfy` \case + Left RPEInvalidSize -> True + _ -> False + readIORef chunks `shouldReturn` ["body"] + it "rejects decompressed body above limit" $ \_ -> do + rc <- testRemoteCrypto + (corrId, cmdKN, _) <- atomically $ getRemoteSndKeys rc + Right encBody <- runExceptT $ encryptEncodeHTTP2Body corrId cmdKN rc $ LB.replicate 1025 'a' + chunks <- newIORef $ LB.toChunks $ toLazyByteString encBody + r <- parseDecryptChunks 1024 rc chunks + r `shouldSatisfy` \case + Left (RPEInvalidBody _) -> True + _ -> False xdescribe "No compression" $ aroundWith (. ((False, False),)) runRemoteTests xdescribe "Mobile offers compression" $ aroundWith (. ((True, False),)) runRemoteTests xdescribe "Desktop offers compression" $ aroundWith (. ((False, True),)) runRemoteTests @@ -190,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 @@ -244,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" @@ -286,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" @@ -311,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 @@ -339,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 @@ -356,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 @@ -384,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" @@ -414,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" ] @@ -440,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" ] @@ -665,3 +717,28 @@ eventually retries action = Left err | retries == 0 -> throwIO err Left _ -> eventually (retries - 1) action Right r -> pure r + +newtype TestBody = TestBody (IORef [B.ByteString]) + +instance HTTP2BodyChunk TestBody where + getBodyChunk (TestBody chunks) = atomicModifyIORef' chunks $ \case + c : cs -> (cs, c) + [] -> ([], "") + getBodySize _ = Nothing + +testRemoteCrypto :: IO RemoteCrypto +testRemoteCrypto = do + drg <- C.newRandom + (_, idPrivKey) <- atomically $ C.generateKeyPair drg + (_, sessPrivKey) <- atomically $ C.generateKeyPair drg + let (chainKey, _) = C.sbcInit "" ("secret" :: B.ByteString) + chainKeys <- TSbChainKeys <$> newTVarIO chainKey <*> newTVarIO chainKey + sndCounter <- newTVarIO 0 + rcvCounter <- newTVarIO 0 + skippedKeys <- TM.emptyIO + pure RemoteCrypto {sessionCode = "", sndCounter, rcvCounter, chainKeys, skippedKeys, signatures = RSSign {idPrivKey, sessPrivKey}, compression = True} + +parseDecryptChunks :: Int -> RemoteCrypto -> IORef [B.ByteString] -> IO (Either RemoteProtocolError ()) +parseDecryptChunks maxSize rc chunks = do + body <- getHTTP2Body (TestBody chunks) 0 + runExceptT . void $ parseDecryptHTTP2Body maxSize rc (TestBody chunks) body 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 dd2a7b8d59..4517a8edb2 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -7,6 +7,8 @@ import Bots.BadgeService.BTCPayTests import Bots.BadgeService.BotTests import Bots.BadgeService.CatalogTests import Bots.BadgeService.ConfigTests +import Bots.BadgeService.GroupIntegrationTests +import Bots.BadgeService.GroupTests import Bots.BadgeService.StripeTests import Bots.BadgeService.WaitersTests import Bots.BadgeService.WebTests @@ -29,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 @@ -53,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 @@ -74,6 +79,7 @@ main = do badgeConfigTests badgeWebTests badgeCatalogTests + badgeGroupTests badgeWaitersTests badgeBTCPayTests badgeStripeTests @@ -87,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) @@ -101,28 +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" badgeServiceTests + 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} diff --git a/website/src/_data/languages.json b/website/src/_data/languages.json index a3fbc62792..65ffa53fd5 100644 --- a/website/src/_data/languages.json +++ b/website/src/_data/languages.json @@ -23,21 +23,24 @@ "name": "Čeština", "flag": "/img/flags/cs.svg", "enabled": true, - "home": true + "home": true, + "why": true }, { "label": "de", "name": "Deutsch", "flag": "/img/flags/de.svg", "enabled": true, - "home": true + "home": true, + "why": true }, { "label": "es", "name": "Español", "flag": "/img/flags/es.svg", "enabled": true, - "home": true + "home": true, + "why": true }, { "label": "fi", @@ -50,7 +53,8 @@ "name": "Français", "flag": "/img/flags/fr.svg", "enabled": true, - "home": true + "home": true, + "why": true }, { "label": "he", @@ -64,21 +68,24 @@ "name": "Magyar", "flag": "/img/flags/hu.svg", "enabled": true, - "home": true + "home": true, + "why": true }, { "label": "id", "name": "Indonesia", "flag": "/img/flags/id.svg", "enabled": true, - "home": true + "home": true, + "why": true }, { "label": "it", "name": "Italiano", "flag": "/img/flags/it.svg", "enabled": true, - "home": true + "home": true, + "why": true }, { "label": "ja", @@ -98,7 +105,8 @@ "name": "Polski", "flag": "/img/flags/pl.svg", "enabled": true, - "home": true + "home": true, + "why": true }, { "label": "pt_BR", @@ -117,7 +125,8 @@ "label": "zh_Hans", "name": "简体中文", "flag": "/img/flags/cn.svg", - "enabled": true + "enabled": true, + "why": true }, { "label": "nb_NO", 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 c35540061c..62ceb5ed48 100644 Binary files a/website/src/img/crowdfunding/simplex-chat-pitch-deck.pdf and b/website/src/img/crowdfunding/simplex-chat-pitch-deck.pdf differ diff --git a/website/src/img/crowdfunding/simplex-growth-2.png b/website/src/img/crowdfunding/simplex-growth-2.png index 671865cbe6..901532dca3 100644 Binary files a/website/src/img/crowdfunding/simplex-growth-2.png and b/website/src/img/crowdfunding/simplex-growth-2.png differ 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() {