diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8dc857319d..18346d97de 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/README.md b/README.md index fa4247caf8..cb792e84f5 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ SimpleX Chat founder - [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations) - [For developers](#for-developers) - [Develop a chat bot](#develop-a-chat-bot) +- [Agents](#agents) - [Roadmap](#roadmap) - [Disclaimers, Security contact, License](#disclaimers) @@ -331,6 +332,18 @@ See [our new bot API reference](./bots/README.md). Most of it is automatically g Also see [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js). +## Agents + +- For AI assistants: [simplex.chat/llms.txt](https://simplex.chat/llms.txt), an index of the documentation in Markdown, and [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md). +- Every page of simplex.chat is also available as Markdown: replace `.html` with `.md` ([simplex.chat/docs/simplex.md](https://simplex.chat/docs/simplex.md)), or add `.md` to the path ([simplex.chat/why.md](https://simplex.chat/why.md)). +- Libraries: [Node.js](./packages/simplex-chat-nodejs/) ([npm](https://www.npmjs.com/package/simplex-chat)) and [Python](./packages/simplex-chat-python/) ([PyPI](https://pypi.org/project/simplex-chat/)). +- [Bot API guide](./bots/README.md) and [API reference](./bots/api/README.md): commands, events and types. +- Example bots: [support bot](./apps/simplex-support-bot/) and [calculator](./apps/simplex-calculator-bot/) in TypeScript, [support bot light](./apps/simplex-support-bot-light/) in Python, [directory service](./apps/simplex-directory-service/) and [broadcast bot](./apps/simplex-broadcast-bot/) in Haskell. +- [Terminal client](./docs/CLI.md): installation, building from source and usage. +- Servers: [SMP](./docs/SERVER.md), [XFTP](./docs/XFTP-SERVER.md) and [chat relay](./docs/CHAT-RELAY.md). +- Protocols: [SimpleX network](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md), [chat](./docs/protocol/simplex-chat.md), [channels](./docs/protocol/channels-overview.md) and [public names](./docs/protocol/names-overview.md). +- For coding agents working in this repository: [project structure](./docs/contributing/PROJECT.md) and [coding and building](./docs/contributing/CODE.md). + ## Roadmap - ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies. diff --git a/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/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index eadca9f71e..26438bc60e 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1931,10 +1931,13 @@ enum UserServersError: Decodable { case storageMissing(protocol: ServerProtocol, user: UserRef?) case proxyMissing(protocol: ServerProtocol, user: UserRef?) case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + case tooManyHosts(protocol: ServerProtocol, tooManyHostsServer: String) case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String) var globalError: String? { switch self { + case .tooManyHosts: + return globalSMPError case let .noServers(`protocol`, _): switch `protocol` { case .smp: return globalSMPError @@ -1977,6 +1980,8 @@ enum UserServersError: Decodable { } else { return text } + case let .tooManyHosts(.smp, server): + return String.localizedStringWithFormat(NSLocalizedString("Server %@ has more than two host names.", comment: "servers error"), serverHostname(server)) default: return nil } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 6a5b1de353..7c1e8e65ab 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -935,6 +935,8 @@ final class ChatModel: ObservableObject { func updateCurrentUser(_ newProfile: Profile, _ preferences: FullPreferences? = nil) { if let current = currentUser { currentUser?.profile = toLocalProfile(current.profile.profileId, newProfile, "") + currentUser?.profile.localBadge = current.profile.localBadge + currentUser?.profile.contactDomainVerified = current.profile.contactDomainVerified if let preferences = preferences { currentUser?.fullPreferences = preferences } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 742ef20688..21335cee64 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1359,8 +1359,12 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? { switch r { case .result(.userProfileNoChange): return (profile, []) case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts) - case .error(.errorStore(.duplicateName)): return nil; - default: throw r.unexpected + case .error(.errorStore(.duplicateName)), .error(.error(.userExists)): return nil; + default: + await MainActor.run { + showErrorAlert(r.unexpected, NSLocalizedString("Error saving profile", comment: "alert title")) + } + throw r.unexpected } } @@ -2361,6 +2365,10 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws ChatReceiver.shared.start() m.chatRunning = true chatLastStartGroupDefault.set(Date.now) + if shouldDeleteDatabaseBackupsDefault.get() { + deleteDatabaseBackups() + shouldDeleteDatabaseBackupsDefault.set(false) + } } func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? { @@ -2910,23 +2918,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 @@ -3063,6 +3067,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/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a1570ba4a1..fbac41cf40 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -767,6 +767,7 @@ struct ComposeView: View { let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count + guard total > 0 || chatModel.membersLoaded else { return nil } let noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total return (hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays) } 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/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 9711f3d6f8..61c808dda1 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -139,7 +139,7 @@ struct UserAddressView: View { title: Text("Share address with SimpleX contacts?"), message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."), primaryButton: .default(Text("Share")) { - setProfileAddress($progressIndicator, true) + setProfileAddress($progressIndicator, true) { shareViaProfile = false } shareViaProfile = true }, secondaryButton: .cancel() ) @@ -318,7 +318,7 @@ struct UserAddressView: View { alert = .shareOnCreate progressIndicator = false } else { - setProfileAddress($progressIndicator, true) + setProfileAddress($progressIndicator, true) { shareViaProfile = false } shareViaProfile = true } } else { @@ -601,7 +601,7 @@ struct AddressSettingsState: Equatable { } } -private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool) { +private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool, revert: @escaping () -> Void) { progressIndicator.wrappedValue = true Task { do { @@ -613,7 +613,11 @@ private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool) { await MainActor.run { progressIndicator.wrappedValue = false } } catch let error { logger.error("apiSetProfileAddress: \(responseError(error))") - await MainActor.run { progressIndicator.wrappedValue = false } + await MainActor.run { + progressIndicator.wrappedValue = false + revert() + showErrorAlert(error, NSLocalizedString("Error saving profile", comment: "alert title")) + } } } } @@ -709,7 +713,10 @@ struct UserAddressSettingsView: View { title: NSLocalizedString("Share", comment: "alert action"), style: .default, handler: { _ in - setProfileAddress($progressIndicator, on) + setProfileAddress($progressIndicator, on) { + ignoreShareViaProfileChange = true + shareViaProfile = !on + } } ) ]} @@ -731,7 +738,10 @@ struct UserAddressSettingsView: View { title: NSLocalizedString("Stop sharing", comment: "alert action"), style: .default, handler: { _ in - setProfileAddress($progressIndicator, on) + setProfileAddress($progressIndicator, on) { + ignoreShareViaProfileChange = true + shareViaProfile = !on + } } ) ]} diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index a2a62b557c..8fe8a1c45a 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -36,7 +36,7 @@ struct UserProfile: View { HStack { TextField("Enter your name…", text: $profile.displayName) .focused($focusDisplayName) - if !validDisplayName(profile.displayName) { + if !validNewProfileName { Button { alert = .invalidNameError(validName: mkValidName(profile.displayName)) } label: { @@ -147,10 +147,15 @@ struct UserProfile: View { (chatModel.currentUser?.profile.description ?? "") != description.trimmingCharacters(in: .whitespacesAndNewlines) ) && profile.displayName.trimmingCharacters(in: .whitespaces) != "" && - validDisplayName(profile.displayName) && + validNewProfileName && bioFitsLimit() } + private var validNewProfileName: Bool { + profile.displayName == chatModel.currentUser?.profile.displayName + || validDisplayName(profile.displayName) + } + private func saveProfile() { focusDisplayName = false Task { diff --git a/apps/ios/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 e0a634a713..ccbdaadc5c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -193,8 +193,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.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 */; }; @@ -586,8 +586,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.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 = ""; }; @@ -760,8 +760,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.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; @@ -848,8 +848,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 = ""; @@ -2139,7 +2139,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; @@ -2189,7 +2189,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; @@ -2231,7 +2231,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; @@ -2251,7 +2251,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; @@ -2276,7 +2276,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; @@ -2313,7 +2313,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; @@ -2350,7 +2350,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; @@ -2401,7 +2401,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; @@ -2455,7 +2455,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 357; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2489,7 +2489,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 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/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 75d66ebb6f..ecf389563e 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -598,6 +598,7 @@ public enum RcvSwitchStatus: String, Decodable, Hashable { case sendingQADD = "sending_qadd" case sendingQUSE = "sending_quse" case receivedMessage = "received_message" + case receivedQEND = "received_qend" } public struct SndQueueInfo: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index cf520a65c7..a8c5de3672 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -133,6 +133,7 @@ public struct Profile: Codable, NamedChat, Hashable { self.image = image self.contactLink = contactLink self.preferences = preferences + self.peerType = peerType self.contactDomain = contactDomain } @@ -583,7 +584,8 @@ public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias contactLink: profile.contactLink, preferences: profile.preferences, peerType: profile.peerType, - localAlias: localAlias + localAlias: localAlias, + contactDomain: profile.contactDomain ) } @@ -3894,6 +3896,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 @@ -6065,6 +6068,7 @@ public enum RcvGroupEvent: Decodable, Hashable { case memberCreatedContact case memberProfileUpdated(fromProfile: Profile, toProfile: Profile) case newMemberPendingReview + case msgBadSignature var text: String { text(isChannel: false) } @@ -6100,6 +6104,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/README.md b/apps/multiplatform/README.md index 0d3c5b24f4..8fc0f701f3 100644 --- a/apps/multiplatform/README.md +++ b/apps/multiplatform/README.md @@ -101,7 +101,7 @@ Tests are in: ## Resources & Localization -- String resources: `common/src/commonMain/resources/MR/base/strings.xml` + 21 language variants +- String resources: `common/src/commonMain/moko-resources/base/strings.xml` + 21 language variants - Uses Moko Resources (`dev.icerock.moko:resources`) for cross-platform resource management - The `adjustFormatting` gradle task validates string resources during build diff --git a/apps/multiplatform/android/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 9e059afa14..5fa9c7fefa 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -35,6 +35,8 @@ + + android:enableOnBackInvokedCallback="false" + android:theme="@style/Theme.SimpleX" + tools:ignore="UnusedAttribute"> @@ -100,12 +104,18 @@ - - - - - - + + + + + + + + + + + + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt index 6c3d96bebc..96403588ac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt @@ -16,7 +16,7 @@ import chat.simplex.app.views.call.CallActivity import chat.simplex.common.model.NotificationPreviewMode import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant class CallService: Service() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 7415b6015d..d1022818ac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index 289ecc0a31..7a6d9db47a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -29,7 +29,7 @@ import chat.simplex.common.model.NotificationsMode import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import kotlinx.coroutines.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlin.system.exitProcess diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt index 5d8371708c..96ebd6179a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt @@ -22,7 +22,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.call.RcvCallInvitation import kotlinx.datetime.Clock -import chat.simplex.res.MR +import chat.simplex.res.* object NtfManager { const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION" @@ -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..e1edc5ebe3 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -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 @@ -44,7 +45,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.launch @@ -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/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 64dd34afbf..ee64060206 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -48,7 +48,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}") classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String)) - classpath("dev.icerock.moko:resources-generator:0.23.0") + classpath("dev.icerock.moko:resources-generator:0.27.1") // Workaround gradle sync issue: https://github.com/gmazzo/gradle-buildconfig-plugin/issues/137#issuecomment-1935739759 classpath("com.squareup:kotlinpoet:1.16.0") diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 413667c968..0ff63fa8de 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -56,11 +56,6 @@ kotlin { } val commonMain by getting { - if (hasSimplexAssets) { - resources.srcDir(simplexAssetsLocal) - } else { - resources.srcDir("src/commonMain/resources/assets/default") - } dependencies { api(compose.runtime) api(compose.foundation) @@ -78,8 +73,8 @@ kotlin { // Link Previews implementation("org.jsoup:jsoup:1.17.2") // Resources - api("dev.icerock.moko:resources:0.23.0") - api("dev.icerock.moko:resources-compose:0.23.0") + api("dev.icerock.moko:resources:0.27.1") + api("dev.icerock.moko:resources-compose:0.27.1") // Markdown implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0") @@ -95,7 +90,6 @@ kotlin { } } val androidMain by getting { - kotlin.srcDir("build/generated/moko/androidMain/src") dependencies { implementation("androidx.activity:activity-compose:1.9.1") val workVersion = "2.9.1" @@ -143,6 +137,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,27 +152,20 @@ 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 - val isAndroid = gradle.startParameter.taskNames.find { - val lower = it.lowercase() - lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install") - } != null - if (isAndroid) { - // This is not needed on Android but can't be moved to desktopMain because MR lib don't support this. - // No other ways to exclude a file work, but it's large and should be excluded - kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf") - } + testOptions.targetSdk = 36 + lint.targetSdk = 36 } multiplatformResources { - multiplatformResourcesPackage = "chat.simplex.res" - // multiplatformResourcesClassName = "MR" + resourcesPackage.set("chat.simplex.res") + resourcesSourceSets { + getByName("commonMain").srcDirs(if (hasSimplexAssets) simplexAssetsLocal.resolve("MR") else file("src/commonMain/resources/assets/default/MR")) + } } buildConfig { @@ -285,8 +273,8 @@ afterEvaluate { } return this } - val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$") - val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree + val fileRegex = Regex("moko-resources/../strings.xml$|moko-resources/..-.../strings.xml$|moko-resources/..-../strings.xml$|moko-resources/base/strings.xml$") + val tree = fileTree("src/commonMain/moko-resources").filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found") val treeList = ArrayList(tree.toList()) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt index bfb45d7413..74c9cf58a1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt @@ -6,7 +6,7 @@ import android.provider.Settings import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* fun Context.openAppSettingsInSystem() { Intent().apply { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/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..73be3a203c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt @@ -1,11 +1,14 @@ 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 +import chat.simplex.res.* import java.security.KeyStore import javax.crypto.* import javax.crypto.spec.GCMParameterSpec @@ -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/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 4f48ccca52..71d7ff3cb4 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -39,7 +39,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index e5dda23f0f..90fae139ce 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -10,7 +10,7 @@ import androidx.compose.runtime.* import chat.simplex.common.model.* import chat.simplex.common.platform.AudioPlayer.duration import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import java.io.* diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index 79f0a8636b..6592557a7a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -15,7 +15,7 @@ import androidx.core.graphics.drawable.toBitmap import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.io.BufferedOutputStream import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index f6066d1624..9dbcb6d263 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -17,7 +17,7 @@ import chat.simplex.common.AppScreen import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt index 61b404f7d6..9949a21f6f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.ImageBitmap import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.android.exoplayer2.* import com.google.android.exoplayer2.C.* import com.google.android.exoplayer2.audio.AudioAttributes diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt index 2e7404f241..9e44957a2a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt @@ -1,15 +1,15 @@ package chat.simplex.common.ui.theme import androidx.compose.ui.text.font.* -import chat.simplex.res.MR +import chat.simplex.res.* actual val Inter: FontFamily = FontFamily( - Font(MR.fonts.Inter.regular.fontResourceId), - Font(MR.fonts.Inter.italic.fontResourceId, style = FontStyle.Italic), - Font(MR.fonts.Inter.bold.fontResourceId, FontWeight.Bold), - Font(MR.fonts.Inter.semibold.fontResourceId, FontWeight.SemiBold), - Font(MR.fonts.Inter.medium.fontResourceId, FontWeight.Medium), - Font(MR.fonts.Inter.light.fontResourceId, FontWeight.Light) + Font(MR.fonts.inter_regular.fontResourceId), + Font(MR.fonts.inter_italic.fontResourceId, style = FontStyle.Italic), + Font(MR.fonts.inter_bold.fontResourceId, FontWeight.Bold), + Font(MR.fonts.inter_semibold.fontResourceId, FontWeight.SemiBold), + Font(MR.fonts.inter_medium.fontResourceId, FontWeight.Medium), + Font(MR.fonts.inter_light.fontResourceId, FontWeight.Light) ) actual val EmojiFont: FontFamily = FontFamily.Default diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt index 0c23e95285..ff3bf11db1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt @@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi import androidx.compose.runtime.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.ImageResource -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import java.util.concurrent.Executors diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 011619bab0..dfe38f197a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -46,7 +46,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource @@ -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/chat/ComposeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt index 719dfeea55..95438d3d0e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt @@ -11,7 +11,7 @@ import androidx.core.content.ContextCompat import chat.simplex.common.helpers.toURI import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.net.URI @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt index e81827cb9a..b09ef7c918 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.* import chat.simplex.common.model.CryptoFile import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.DefaultDropdownMenu -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index b0bc6dd970..e16f99bf56 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.FileChooserLauncher import chat.simplex.common.platform.saveImage import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import dev.icerock.moko.resources.compose.painterResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index dad8872012..61d2f54318 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible import chat.simplex.common.platform.VideoPlayer import chat.simplex.common.platform.androidAppContext -import chat.simplex.res.MR +import chat.simplex.res.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt index 8c3b161a5c..256d24a48c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -18,7 +18,7 @@ import chat.simplex.common.model.durationText import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.delay import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt index ab6d375d75..a4be67473e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt index 1826b2114e..a81d9cd1ee 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.SimplexGreen import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -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/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt index 3dace5f9bc..4c980acf91 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt index 1c7ba1dcf0..d499b7ab3f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt @@ -33,7 +33,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.json import chat.simplex.common.platform.* import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.builtins.* import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index de6834ba41..371bd7676b 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -24,7 +24,7 @@ import androidx.core.text.HtmlCompat import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import java.io.* import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index 6cf432a15f..416bd359c0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -27,7 +27,7 @@ import boofcv.struct.image.GrayU8 import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.google.common.util.concurrent.ListenableFuture diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt index a4fc74f6d4..0e1cf9472a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import chat.simplex.common.model.SharedPreference import chat.simplex.common.model.User -import chat.simplex.res.MR +import chat.simplex.res.* @Composable actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt index c16d1ea90d..e5ae2b8a9b 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -32,7 +32,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.helpers.APPLICATION_ID import chat.simplex.common.helpers.saveAppLocale import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt index 3c1771c8b3..230dee8851 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity import chat.simplex.common.model.ChatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt index 5e9706d713..4da9bf03b0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.jakewharton.processphoenix.ProcessPhoenix import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 338e602a45..656911443a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -37,7 +37,7 @@ import chat.simplex.common.views.localauth.VerticalDivider import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* @@ -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..9b42792e8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -10,7 +10,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.localauth.SetAppPasscodeView import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* // Spec: spec/client/navigation.md#AppLock @@ -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 9d65606a9c..c33a077ae9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState import chat.simplex.common.views.migration.MigrationToState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -1115,7 +1115,7 @@ object ChatModel { fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return val updated = current.copy( - profile = newProfile.toLocalProfile(current.profile.profileId), + profile = newProfile.toLocalProfile(current.profile.profileId).copy(localBadge = current.profile.localBadge, contactDomainVerified = current.profile.contactDomainVerified), fullPreferences = preferences ?: current.fullPreferences ) val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId } @@ -3555,6 +3555,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 @@ -5461,6 +5462,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) @@ -5485,6 +5487,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 9100b5ecea..5f1603423a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -32,6 +32,7 @@ import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat +import chat.simplex.common.views.database.deleteDatabaseBackups import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* @@ -39,7 +40,7 @@ import chat.simplex.common.views.usersettings.networkAndServers.defaultCondition import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration -import chat.simplex.res.MR +import chat.simplex.res.* import com.russhwolf.settings.Settings import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -215,6 +216,7 @@ class AppPreferences { val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null) val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null) val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null) + val shouldDeleteDatabaseBackups = mkBoolPreference(SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS, false) val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false) val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false) val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null) @@ -487,6 +489,7 @@ class AppPreferences { private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase" private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase" private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt" + private const val SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS = "ShouldDeleteDatabaseBackups" private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized" private const val SHARED_PREFS_SHOULD_IMPORT_APP_SETTINGS = "ShouldImportAppSettings" private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" @@ -686,6 +689,10 @@ object ChatController { } apiStartChat() appPrefs.chatStopped.set(false) + if (appPrefs.shouldDeleteDatabaseBackups.get()) { + deleteDatabaseBackups() + appPrefs.shouldDeleteDatabaseBackups.set(false) + } } catch (e: Throwable) { Log.e(TAG, "failed starting chat $e") throw e @@ -941,7 +948,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)) @@ -958,7 +965,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 } } @@ -1920,8 +1927,10 @@ object ChatController { val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile)) if (r is API.Result && r.res is CR.UserProfileNoChange) return profile to emptyList() if (r is API.Result && r.res is CR.UserProfileUpdated) return r.res.toProfile to r.res.updateSummary.changedContacts - if (r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName) { + if (r is API.Error && (r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName || r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.UserExists)) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) + } else if (!(networkErrorAlert(r))) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_profile), "${r.responseType}: ${r.details}") } Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") return null @@ -1933,7 +1942,12 @@ object ChatController { return when { r is API.Result && r.res is CR.UserProfileNoChange -> null r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh) - else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}") + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiSetProfileAddress", generalGetString(MR.strings.error_saving_profile), r) + } + throw Exception("failed to set profile address: ${r.responseType} ${r.details}") + } } } @@ -3364,21 +3378,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 -> { @@ -3634,6 +3640,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) { @@ -4854,6 +4893,7 @@ sealed class UserServersError { @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + @Serializable @SerialName("tooManyHosts") data class TooManyHosts(val protocol: ServerProtocol, val tooManyHostsServer: String): UserServersError() @Serializable @SerialName("duplicateChatRelayAddress") data class DuplicateChatRelayAddress(val duplicateChatRelay: String, val duplicateAddress: String): UserServersError() val globalError: String? @@ -4869,6 +4909,7 @@ sealed class UserServersError { is StorageMissing -> this.protocol is ProxyMissing -> this.protocol is DuplicateServer -> this.protocol + is TooManyHosts -> this.protocol is DuplicateChatRelayAddress -> null } @@ -4884,6 +4925,8 @@ sealed class UserServersError { is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" } ?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing) + is TooManyHosts -> String.format(generalGetString(MR.strings.server_has_too_many_hosts), ServerAddress.parseServerAddress(this.tooManyHostsServer)?.hostnames?.firstOrNull() ?: this.tooManyHostsServer) + else -> null } } else { @@ -7215,9 +7258,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}") @@ -7537,7 +7580,8 @@ enum class RcvSwitchStatus { @SerialName("switch_started") SwitchStarted, @SerialName("sending_qadd") SendingQADD, @SerialName("sending_quse") SendingQUSE, - @SerialName("received_message") ReceivedMessage + @SerialName("received_message") ReceivedMessage, + @SerialName("received_qend") ReceivedQEND } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index 140c1951ee..a6ede20e41 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -6,7 +6,7 @@ import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import java.util.* enum class AppPlatform { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/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/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index ad0e9aaa5a..9d1c0b8769 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -8,7 +8,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import java.io.File import java.nio.ByteBuffer diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/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/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index cdd4140e3f..6dfbeceff8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.charleskorn.kaml.* import kotlinx.serialization.encodeToString import java.io.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/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..ee47403cd3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -1,12 +1,13 @@ 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 import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay enum class NotificationAction { @@ -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/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 20e0280acd..b6d6a4d771 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.Transient import java.util.UUID diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 843bacecec..e4a187783a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -45,7 +45,7 @@ import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.DeleteImageButton import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt index 03fabf817e..0eda3cd751 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.NameWithBadge import chat.simplex.common.views.helpers.ProfileImage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt index cb300b183d..28cf4c40b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesHowItWorksView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt index 2783727621..9420f323ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt @@ -39,7 +39,7 @@ import chat.simplex.common.views.helpers.ModalView import chat.simplex.common.views.helpers.ShareButton import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.withBGApi -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.math.abs @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt index d7a10f38f7..f8fbd3a416 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton -import chat.simplex.res.MR +import chat.simplex.res.* // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgePeriod { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index 9ebaf9bcdc..62bdc2962e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -32,7 +32,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* private const val badgeCodePrefix = "SB" private const val badgeCodeBodyLength = 20 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index 4f1cc2ed66..a58ab552f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -29,7 +29,7 @@ import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.onboarding.HowItWorks import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesSupportSimplexView(modalManager: ModalManager) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt index c2ca7d7f10..f6f6ecb12d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.helpers.badgeTypeName import chat.simplex.common.views.helpers.openVerifiedSimplexUri import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.views.usersettings.simplexTeamUri -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt index 161702704b..44b5de021a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgeLevel { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt index 47a4d3b152..2a7f1034df 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -20,7 +20,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.BannerDismissButton import chat.simplex.common.views.chatlist.bannerCard import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SupportSimpleXBanner( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/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/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 563f4c3b83..70565fedb4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.ProfileImage import chat.simplex.common.views.usersettings.ProfilePreview -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Clock // Spec: spec/services/calls.md#IncomingCallAlertView diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 6fa99283d8..b761218fdc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -3,7 +3,7 @@ package chat.simplex.common.views.call import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -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..9c019ad535 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -43,7 +43,7 @@ import chat.simplex.common.views.chat.group.ChatTTLOption import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.newchat.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -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/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 50f5e35462..e4f93a0b8a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.common.views.usersettings.networkAndServers.serverHostname -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.serialization.encodeToString diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 7e43db2e98..c1b3c20b71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -44,7 +44,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.platform.AudioPlayer import chat.simplex.common.views.newchat.ContactConnectionInfoView import chat.simplex.common.views.newchat.alertProfileImageSize -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -119,6 +119,20 @@ fun ChatView( } chat } } + if (chatsCtx.secondaryContextFilter == null) { + // cleared when the view is gone rather than on back: Android keeps it on screen while it slides out, + // and by then a channel being created may already be using this state + DisposableEffect(Unit) { + onDispose { + if (chatModel.chatId.value == null && chatModel.creatingChannelId.value == null) { + chatModel.groupMembers.value = emptyList() + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false + ChannelRelaysModel.reset() + } + } + } + } val user = chatModel.currentUser.value val chatInfo = activeChat.value?.chatInfo if (chat == null || chatInfo == null || user == null) { @@ -382,10 +396,6 @@ fun ChatView( hideKeyboard(view) AudioPlayer.stop() chatModel.chatId.value = null - chatModel.groupMembers.value = emptyList() - chatModel.groupMembersIndexes.value = emptyMap() - chatModel.membersLoaded.value = false - ChannelRelaysModel.reset() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -499,6 +509,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 +532,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 +2480,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/CommandsMenuView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt index 26b1fce741..5f7aec3dd6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt @@ -25,7 +25,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.sendCommandMsg import chat.simplex.common.views.helpers.commandMenuAnimSpec -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt index 14edea3ed6..06a8d529a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.model.MsgChatLink import chat.simplex.common.ui.theme.appColors import chat.simplex.common.views.helpers.ProfileImage import dev.icerock.moko.resources.compose.painterResource -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ComposeChatLinkView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt index 106c975226..c10e28ce11 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.views.chatlist.acceptContactRequest import chat.simplex.common.views.chatlist.rejectContactRequest import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt index 21799ff820..015308fe22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index be82a5d2d3..8cda1226fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.views.chat.group.removeMember import chat.simplex.common.views.chat.group.removeMemberDialog import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt index b7be49055e..80940bf631 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.IncognitoOptionImage import chat.simplex.common.views.usersettings.IncognitoView -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt index 26e069c739..c5a21dfa37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt @@ -12,7 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt index 97b6f9afda..c6ce37a9ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt @@ -14,7 +14,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.platform.base64ToBitmap -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.UploadContent diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c81149fcb1..92ccd92f93 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -42,7 +42,7 @@ import chat.simplex.common.views.newchat.RelayProgressIndicator import chat.simplex.common.views.newchat.RelayStatusIndicator import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.common.views.newchat.relayDisplayName -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged @@ -2153,7 +2153,8 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState? if (!gInfo.useRelays || gInfo.membership.memberRole != GroupMemberRole.Owner || gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) ) return null - val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList() + if (ChannelRelaysModel.groupId.value != gInfo.groupId) return null + val relays = ChannelRelaysModel.groupRelays.toList() if (relays.isEmpty()) return OwnerRelayState(emptyList(), 0, 0, 0, true) val relayMembers = relays.map { relay -> relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId } @@ -2186,6 +2187,7 @@ private fun subscriberRelayState(chat: Chat, chatModel: ChatModel): SubscriberRe val connectedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connStatus == ConnStatus.Ready && it.activeConn?.connFailedErr == null } val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null } val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size + if (total == 0 && !chatModel.membersLoaded.value) return null val noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total return SubscriberRelayState(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt index b070dce1d1..29c171b00f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.model.durationText import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.AudioPlayer -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.flow.distinctUntilChanged @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 0276727ccc..b7adec1add 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -21,7 +21,7 @@ import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index e681c4fed7..b477f56118 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.views.chat.item.* import chat.simplex.common.model.* import chat.simplex.common.platform.getLoadedFilePath import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 428d4b1b8f..ea0ef11aef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -9,7 +9,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 03d0b99854..3f3278ee58 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -17,7 +17,7 @@ import chat.simplex.common.platform.BackHandler import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.* import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index b116201d87..8205071225 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatItem import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt index d3e3ffbe61..7915aedca5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index db31c05dce..20f5acd3e0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -39,7 +39,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.itemPrefixText import chat.simplex.common.views.chat.item.itemSegmentDisplayText import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index 880c15216e..3610147804 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun VerifyCodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 7223f69f98..731b125955 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt index 95ce003caa..f67efc0202 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.chatRelayDisplayName import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index 128747c4fa..ea841acc86 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.views.chat.ownersContributorsCountStr import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ModalData.ChannelMembersView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt index 60cc19bb1b..951b7b2c35 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -20,7 +20,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt index 18a944f671..7f40458f42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 49e7ac6db7..973b684d38 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -49,7 +49,7 @@ import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.TtlOptions import chat.simplex.common.views.newchat.SimpleXLinkQRCode -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -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/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index f6f19e9d72..9f02d0fefb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun GroupLinkView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 3e5f64659b..ac1c6915b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -36,7 +36,7 @@ import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.openDirectChat -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.datetime.Clock import kotlinx.coroutines.* @@ -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/GroupMembersToolbar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt index 81e7d80da3..6dcc6e9a4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt index 2a5f9df8c1..5f0fed040e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch import kotlin.text.CharCategory.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index f04d63e2d2..bd3af10c37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -21,7 +21,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 6e91ad92d6..c93179edd7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt index 2cc2402c0a..0c272ae98d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -9,7 +9,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt index 544af8ed7e..ef333a8826 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 1ac6d6f048..8e0b3321b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -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/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index b685d20fb9..b05d425c46 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 927e9940b5..ec8b545647 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* private const val maxByteCount = 1200 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt index d49f8526d5..1d50ba17e4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIBrokenComposableView(alignment: Alignment) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index 744bcf7b66..d3cc76fb7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.SimpleButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CICallItemView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt index 3c3e4baf49..23fce97a6f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.ProfileImage -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt index 1a23dfc49a..27e605df4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIFeaturePreferenceView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index fb4a2cdaea..a3c2b63f8f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -25,7 +25,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 9b8393f66a..649188502d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 61b6e450c5..d5ea44126b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.common.views.chat.chatViewScrollState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlin.math.roundToInt @@ -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/CIInvalidJSONView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt index d297d5ef76..391ee88e2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt @@ -14,7 +14,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.shareText import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIInvalidJSONView(json: String) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt index 2ade49b3fc..bbd7788f29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIMemberCreatedContactView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index 92f89ccad9..e318f406de 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.appPreferences import chat.simplex.common.ui.theme.isInDarkTheme -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Clock @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index d58fd7553f..589b2a6fa6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.appColors import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 556b386900..21f406db18 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* @@ -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/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index c3e1c92732..3b3c835e0a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.flow.* import kotlin.math.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index c38bc1fe08..cb3a07b7f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -34,7 +34,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 68aec09456..fd422b649a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -25,7 +25,7 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.newchat.planAndConnect -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.math.ceil @@ -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/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index 08b6520dfa..9b378ea9ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.model.RcvMsgError import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 84bc14fee3..c488b4172b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt index f36978cd3c..050843059c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.annotatedStringResource import chat.simplex.common.views.onboarding.ReadableTextWithLink import chat.simplex.common.views.usersettings.MarkdownHelpView import chat.simplex.common.views.usersettings.simplexTeamUri -import chat.simplex.res.MR +import chat.simplex.res.* val bold = SpanStyle(fontWeight = FontWeight.Bold) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index ca1528a3ce..489ebb8c39 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.contacts.onRequestAccepted import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.datetime.Clock @@ -265,10 +265,13 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo newMember } } - chatModel.groupMembersIndexes.value = emptyMap() - chatModel.groupMembers.value = newMembers - chatModel.membersLoaded.value = true - chatModel.populateGroupMembersIndexes() + withContext(Dispatchers.Main) { + if (chatModel.chatId.value != groupInfo.id) return@withContext + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.groupMembers.value = newMembers + chatModel.membersLoaded.value = true + chatModel.populateGroupMembersIndexes() + } } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index fae1b7c155..17101c35d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -46,7 +46,7 @@ import chat.simplex.common.views.badges.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -779,7 +779,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..7e11fdfb97 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.common.views.chat.item.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource // Spec: spec/client/chat-list.md#ChatPreviewView @@ -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/chatlist/ContactConnectionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt index 349f1f12d4..ddfc4b74ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.model.getTimestampText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactConnectionView(contactConnection: PendingContactConnection) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 96e7fbacd0..fb575498c7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.model.ChatInfo import chat.simplex.common.model.getTimestampText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt index d383366dd4..c740e53a17 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.onboarding.GetStakeView -import chat.simplex.res.MR +import chat.simplex.res.* // Spec: spec/client/chat-list.md#GetStakeBanner @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 1c2f34e88d..31941e1f77 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -50,7 +50,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsPreferenceItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index 07058b5787..b1fe42cdfc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 382d126db7..023aea530f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.themedBackground import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.newchat.ActiveProfilePicker -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ShareListView(chatModel: ChatModel, stopped: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index fe61859937..17e288ff00 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -37,7 +37,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.ReactionIcon import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 81a6b31323..652d072c9c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.localauth.VerticalDivider import chat.simplex.common.views.remote.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 371e9072ea..8c77d7560f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.ContactType import chat.simplex.common.views.newchat.chatContactType -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay fun onRequestAccepted(chat: Chat) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 524fbedb1c..a430b521c2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.newchat.ContactType import chat.simplex.common.views.newchat.chatContactType -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactPreviewView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index b656b8b8da..ac64c93c73 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -32,7 +32,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock @@ -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/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 4aeb929624..4b59dd81c9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.AppVersionText import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 241826ac41..6abbf83226 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.* import java.io.* import java.net.URI @@ -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/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 62ba2c10f3..9c64e7ce85 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -24,7 +24,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index cf2ceaf2d6..2c6eed78f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlin.math.absoluteValue diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/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/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 91919ff0cf..036bcab68d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -38,7 +38,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.newchat.noShownBadge -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlin.math.max import kotlin.math.roundToInt diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt index c1a76d7bf8..50341761c5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.serialization.SerialName diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/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/CustomTimePicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt index 3c44cbb4dd..ed10c0f86f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.CustomTimeUnit import chat.simplex.common.model.timeText -import chat.simplex.res.MR +import chat.simplex.res.* @Composable expect fun CustomTimePicker( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index e584fcc11c..41556236b3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -2,7 +2,7 @@ package chat.simplex.common.views.helpers import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.* import java.io.File import java.security.SecureRandom @@ -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/DefaultBasicTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt index b0366cceb3..e5feb49107 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.text.input.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.views.database.PassphraseStrength -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 81fac40a40..1c2ef93a09 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -17,7 +17,7 @@ import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.CenteredRowLayout -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.math.absoluteValue @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index 893ff5a467..2867f6d8a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import dev.icerock.moko.resources.ImageResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index d2a98ae101..258fede5b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID import chat.simplex.common.views.chat.item.imageViewFullWidth -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -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/helpers/LocalAuthentication.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt index 1f2b5485f2..5e040042dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt @@ -8,7 +8,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.BackHandler import chat.simplex.common.views.localauth.LocalAuthView import chat.simplex.common.views.usersettings.LAMode -import chat.simplex.res.MR +import chat.simplex.res.* sealed class LAResult { object Success: LAResult() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt index 3d21cfaf0f..27064b21d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt @@ -5,7 +5,7 @@ import chat.simplex.common.platform.Log import chat.simplex.common.platform.TAG import chat.simplex.common.platform.ntfManager import chat.simplex.common.views.database.restartChatOrApp -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Job import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index cc6b1c40a4..790174eaf6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.text.input.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 5196a144b1..32370ce290 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SelectableCard import chat.simplex.common.views.usersettings.SettingsActionItemWithContent -import chat.simplex.res.MR +import chat.simplex.res.* val SectionCardShape = RoundedCornerShape(16.dp) val CARD_PADDING = 18.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt index 1c4879dedd..d59fc1fe13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import chat.simplex.common.platform.appPlatform import chat.simplex.common.ui.theme.SimpleXTheme -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SimpleButton(text: String, icon: Painter, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt index e0e61b598e..7f8b902b94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt @@ -5,7 +5,7 @@ import androidx.compose.material.Icon import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt index c8c24d918a..98ea245886 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.AppearanceScope.WallpaperPresetSelector import chat.simplex.common.views.usersettings.AppearanceScope.editColor -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.serialization.encodeToString diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 2d6e70eae9..dfe33e3595 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -13,7 +13,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.ThemeModeOverrides import chat.simplex.common.ui.theme.ThemeOverrides import chat.simplex.common.views.chatlist.connectIfOpenedViaUri -import chat.simplex.res.MR +import chat.simplex.res.* import com.charleskorn.kaml.decodeFromStream import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index f86edb0388..04c1239d84 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -11,7 +11,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.platform.* import chat.simplex.common.views.database.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt index 669847c3a9..957eb099d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.chat.group.ProgressIndicator import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun PasscodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt index d16f0a2a38..9305e21cc4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun PasscodeEntry( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt index 1d620c915f..a42949493b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt @@ -7,7 +7,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword import chat.simplex.common.views.helpers.DatabaseUtils.ksSelfDestructPassword import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SetAppPasscodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 87784f009e..05c22155b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.LinkTextView import chat.simplex.common.views.newchat.SimpleXLinkQRCode import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index f92a5e0ce4..150be7ea6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -29,7 +29,7 @@ import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* @@ -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/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index 639a5cc78e..7ad3076640 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -32,7 +32,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView import chat.simplex.common.views.chat.group.hostFromRelayLink -import chat.simplex.res.MR +import chat.simplex.res.* import java.net.URI import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt index 077abd1b98..9198ec819a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt @@ -11,7 +11,7 @@ import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.KeyChangeEffect import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun AddContactLearnMore(close: () -> Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 1d8da1690c..e2c89940ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -29,7 +29,7 @@ import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.usersettings.* import androidx.compose.ui.layout.ContentScale import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 5e5769891a..61cb73ca0b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -14,7 +14,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* enum class ConnectionLinkType { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index f1bd732d87..f6109778dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 4d97e3a054..bdad9d19c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.contacts.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index f3006d221b..29819f21cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -42,7 +42,7 @@ import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* enum class NewChatOption { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index 661e752751..cf708c72d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -38,7 +38,7 @@ import chat.simplex.common.views.chatlist.openGetStake import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.UserAddressView -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch import kotlin.math.cos import kotlin.math.sin diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 74c9a55ecf..7171c7475c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index d11e396388..30be129aa8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.usersettings.networkAndServers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 9d82395740..a205a11145 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt index 97dfcd34b9..5aca7a20c9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt @@ -17,7 +17,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.remote.AddingMobileDevice import chat.simplex.common.views.remote.DeviceNameField import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt index 684bfb0053..c5361452ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt @@ -26,7 +26,7 @@ import chat.simplex.common.views.helpers.mixWith import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index adcfb8b194..4dfd6445ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -18,7 +18,7 @@ import chat.simplex.common.model.NotificationsMode import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 9ef72a7f12..0d00c20591 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.database.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index d97d709953..41229037b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -35,7 +35,7 @@ import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.migration.MigrateToDeviceView import chat.simplex.common.views.migration.MigrationToState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlin.math.ceil import kotlin.math.floor diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 0fa53ea6de..81ea810acb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -59,7 +59,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.common.views.usersettings.showAddShortLinkAlert -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlin.math.absoluteValue @@ -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/onboarding/YourNetwork.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt index b20cfe3096..45f7927c8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.usersettings.changeNotificationsMode -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 2d2de2881f..01b054fb28 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -35,7 +35,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 8caf038481..57700aeba3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -36,7 +36,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.validPort -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index 93c5eb7756..a661568a0d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -40,7 +40,7 @@ import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.ui.theme.ThemeManager.toReadableHex import chat.simplex.common.views.chat.item.PreviewChatItemView import chat.simplex.common.views.chat.item.msgTailWidthDp -import chat.simplex.res.MR +import chat.simplex.res.* import com.godaddy.android.colorpicker.ClassicColorPicker import com.godaddy.android.colorpicker.HsvColor import dev.icerock.moko.resources.StringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt index 43863d5794..ca9c66df27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CallSettingsView(m: ChatModel, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index 2c729149d0..cfdf003bc6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -15,7 +15,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> Unit diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt index aaaef31583..74afa93f06 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.chatlist.ChatHelpView import chat.simplex.common.views.helpers.AppBarTitle -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun HelpView(userDisplayName: String) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 4a3806ab89..a14dab3f1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.UserProfileRow import chat.simplex.common.views.database.PassphraseField import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun HiddenProfileView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt index 6098f66941..7f1081ff3d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun IncognitoView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt index ab2df2014c..2810cb02ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.Format import chat.simplex.common.model.FormatColor import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.ui.theme.SimpleXTheme -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun MarkdownHelpView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 91324bb39a..083e5a07ae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.text.style.TextOverflow import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.collections.ArrayList @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 63f3491d80..6a06432c6d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 314537f579..1696e5f1fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt index 0c31b062dd..57c77df844 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt @@ -23,7 +23,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.parseRTCIceServers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun RTCServersView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index ef4acdeac6..003300a033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt index f2c7b02b42..fc120fb7ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.chat.item.openBrowserAlert import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c49caf7cfa..bd929bc2f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -35,7 +35,7 @@ import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { @@ -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/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt index ea0cd4fe28..8167bae62a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt @@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 87660c7e65..826648ddb8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.* import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -63,6 +63,7 @@ fun UserAddressView( chatModel.updateUser(u) } } catch (e: Exception) { + shareViaProfile.value = !on Log.e(TAG, "UserAddressView apiSetProfileAddress: ${e.stackTraceToString()}") } finally { progressIndicator.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index f2a4cc7dac..cc1149e44d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.platform.* import chat.simplex.common.views.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index ac21fb6b23..dd1e208349 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 5070c3c0aa..e297ef62a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.AppBarTitle -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun VersionInfoView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 42746006a3..3a6fc0fb5c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -30,7 +30,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsPreferenceItem -import chat.simplex.res.MR +import chat.simplex.res.* import java.text.DecimalFormat @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt index 9a2d7f8e61..f68dd0c0dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 8241422d0a..92714b5910 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -43,7 +43,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index a3a843d034..6e8d919afc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -8,7 +8,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 53277c9ccd..1e46091ea3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -31,7 +31,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.mikepenz.markdown.compose.Markdown import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.elements.MarkdownHeader diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index 3c8cc9e0ce..c1c423e981 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -27,7 +27,7 @@ import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 63365bd080..6e1fad133a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt index d280773976..68242a22b1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt @@ -7,7 +7,7 @@ import chat.simplex.common.model.UserServer import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner -import chat.simplex.res.MR +import chat.simplex.res.* @Composable expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml index cd4e0a89d1..0578d769c5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml @@ -43,7 +43,7 @@ قُبلت المكالمة اسمح بالمكالمات فقط إذا سمحت جهة اتصالك بذلك. اسمح بردود الفعل على الرسائل فقط إذا سمحت جهة اتصالك بذلك. - يتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. + يتم استخدام Android Keystore%s لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد. أجب الاتصال دائمًا diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/az/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/az/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 42aecc9cfe..71831e1c89 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -140,6 +140,7 @@ No message servers. No servers to receive messages. No servers for private message routing. + Server %1$s has more than two host names. No media & file servers. No servers to send files. No servers to receive files. @@ -254,6 +255,7 @@ Error opening chat Error opening group Error changing profile + Error saving profile Instant notifications @@ -1015,6 +1017,7 @@ Markdown in messages Send questions and ideas Send us email + Follow SimpleX Network News SimpleX Lock Chat console Message servers @@ -1414,6 +1417,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 @@ -1641,6 +1646,9 @@ Save passphrase in Keystore Save passphrase in settings + Secure element + TEE + Software Database encrypted! Error encrypting database Remove passphrase from Keystore? @@ -1657,7 +1665,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.]]> @@ -1783,6 +1791,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 @@ -2678,8 +2687,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/moko-resources/bg/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml index 5bf3ab7754..055dbc2196 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml @@ -78,7 +78,7 @@ Позволи на вашите контакти да изпращат гласови съобщения. Всички ваши контакти ще останат свързани. Актуализацията на профила ще бъде изпратена до вашите контакти. Винаги включен - Android Keystore се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи. + Android Keystore%s се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи. Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено. Приложението може да получава известия само когато работи, няма да се стартира услуга във фонов режим Икона на приложението diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/bn/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/bn/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml index 9763bbaf88..4e9fa56bc0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/cs/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml index 1d404ef94b..a80fd14a2f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/da/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/da/strings.xml index 9706c1b552..0d123e36c2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/de/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml index ccc7732a18..c6157f1eeb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/el/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml index 30b5dad519..caff251b2c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/es/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml index 43e516561a..079d9cfc1b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/fa/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml index 65f1769787..eb187475dd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/fi/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml index 5036d874d7..51a566a284 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/fonts/Inter-Bold.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Bold.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Bold.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Bold.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Italic.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Italic.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Italic.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Italic.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Light.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Light.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Light.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Light.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Medium.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Medium.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Medium.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Medium.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Regular.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Regular.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Regular.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Regular.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Semibold.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Semibold.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Semibold.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Semibold.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml index baa98ebf0b..dadd5ad300 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/hi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hi/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hi/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml index 447dabcf3d..f6f7e48508 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/images/badge_investor.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_investor.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_investor.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_legend.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_legend.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_supporter.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_supporter.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_symbol@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_symbol@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_box.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_box.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_box.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_box.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_group.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_group.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_link.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_link.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_link.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_link.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_photo.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_photo.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_photo.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_photo.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_all_inclusive.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_all_inclusive.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_all_inclusive.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_all_inclusive.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_alternate_email.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_alternate_email.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_back_ios_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_back_ios_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_back_ios_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_back_ios_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_circle_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_circle_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_circle_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_circle_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_downward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_downward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_downward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_downward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward_ios.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward_ios.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward_ios.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward_ios.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_outward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_outward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_upward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_upward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_upward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_upward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrows_left_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrows_left_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_article.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_article.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_article.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_article.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_at.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_at.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_avg_pace.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_avg_pace.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_avg_pace.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_avg_pace.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_back_hand.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_back_hand.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backspace.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backspace.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backspace.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backspace.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backup.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backup.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backup.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backup.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_2_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_2_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_2_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_2_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_3_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_3_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_3_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_3_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bedtime_moon.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bedtime_moon.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bluetooth.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bluetooth.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_blur_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_blur_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_brand_awareness_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_brand_awareness_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_breaking_news.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_breaking_news.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_breaking_news.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_breaking_news.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cable.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cable.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cable.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cable.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_calendar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_calendar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_calendar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_calendar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_camera_enhance.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_camera_enhance.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_camera_enhance.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_camera_enhance.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cancel_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cancel_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cancel_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cancel_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_bubble.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_bubble.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_person.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_person.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_person.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_person.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_checklist.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_checklist.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right_2.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right_2.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right_2.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right_2.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_close.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_close.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_close.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_close.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_code.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_code.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_code.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_code.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_collapse_all.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_collapse_all.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_contact_support.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_contact_support.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_contact_support.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_contact_support.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_copy.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_copy.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_copy.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_copy.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_paste.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_paste.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_paste.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_paste.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cube.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cube.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_database.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_database.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_database.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_database.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_desktop.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_desktop.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_desktop.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_desktop.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_dns.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_dns.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_dns.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_dns.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_disturb_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_disturb_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_disturb_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_disturb_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_touch.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_touch.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_done_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_done_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_done_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_done_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_double_check.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_double_check.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_download.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_download.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_download.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_download.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drag_handle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drag_handle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drive_folder_upload.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drive_folder_upload.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drive_folder_upload.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drive_folder_upload.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_note.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_note.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_note.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_note.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_electrical_services.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_electrical_services.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_electrical_services.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_electrical_services.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_engineering.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_engineering.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_expand_all.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_expand_all.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_filter_list.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_filter_list.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_filter_list.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_filter_list.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flip_camera_android_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flip_camera_android_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flip_camera_android_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flip_camera_android_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_open.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_open.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_open.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_open.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_pen.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_pen.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_pen.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_pen.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forum.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forum.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forum.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forum.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_github.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_github.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_github.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_github.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_headphones.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_headphones.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_heart@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_heart@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_history.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_history.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_history.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_history.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_id_card.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_id_card.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_id_card.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_id_card.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_info.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_info.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_info.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_info.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_inventory_2.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_inventory_2.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_inventory_2.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_inventory_2.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ios_share.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ios_share.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ios_share.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ios_share.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_arrow_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_arrow_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_arrow_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_arrow_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_light_mode.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_light_mode.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_light_mode.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_light_mode.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lightbulb.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lightbulb.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lightbulb.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lightbulb.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_link.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_link.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_link.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_link.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_open_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_open_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_login.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_login.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_login.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_login.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_logout.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_logout.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_logout.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_logout.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_manage_accounts.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_manage_accounts.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_manage_accounts.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_manage_accounts.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_maps_ugc.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_maps_ugc.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_maps_ugc.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_maps_ugc.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mark_chat_unread.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mark_chat_unread.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mark_chat_unread.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mark_chat_unread.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_match_case.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_match_case.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_match_case.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_match_case.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_menu.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_menu.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_menu.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_menu.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_3.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_3.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_4.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_4.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_horiz.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_horiz.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_horiz.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_horiz.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_vert.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_vert.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_vert.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_vert.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_music_note.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_music_note.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_music_note.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_music_note.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_note_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_note_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_note_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_note_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_open_in_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_open_in_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_open_in_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_open_in_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outbound.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outbound.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outline_terminal.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outline_terminal.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outline_terminal.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outline_terminal.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_palette.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_palette.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_palette.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_palette.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pause_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pause_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pause_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pause_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_edit.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_edit.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_bluetooth_speaker.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_bluetooth_speaker.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_in_talk_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_in_talk_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_in_talk_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_in_talk_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_camera.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_camera.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_camera.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_camera.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_play_arrow_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_play_arrow_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_play_arrow_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_play_arrow_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_power_settings_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_power_settings_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_power_settings_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_power_settings_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_priority_high.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_priority_high.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_priority_high.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_priority_high.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code_scanner.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code_scanner.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_question_mark.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_question_mark.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radio_button_unchecked.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radio_button_unchecked.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_1_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_1_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_1_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_1_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_2_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_2_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_2_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_2_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_3_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_3_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_3_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_3_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_4_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_4_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_4_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_4_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_redeem.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_redeem.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_redeem.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_redeem.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_refresh.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_refresh.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_repeat_one.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_repeat_one.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_repeat_one.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_repeat_one.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_replay.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_replay.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_replay.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_replay.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_reply.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_reply.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_reply.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_reply.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_restart_alt.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_restart_alt.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_restart_alt.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_restart_alt.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ring_volume.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ring_volume.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ring_volume.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ring_volume.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_safety_divider.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_safety_divider.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_safety_divider.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_safety_divider.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_security.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_security.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_security.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_security.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_backup_restore.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_backup_restore.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_backup_restore.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_backup_restore.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_ethernet.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_ethernet.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_ethernet.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_ethernet.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_phone.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_phone.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_phone.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_phone.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_shield.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_shield.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_shield.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_shield.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_dark@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_dark@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_dark@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_dark@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot_light.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot_light.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_light.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_light.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smart_display.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smart_display.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smart_display.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smart_display.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone_300.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone_300.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_start.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_start.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_start.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_start.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_stop_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_stop_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_stop_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_stop_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_supervised_user_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_supervised_user_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_supervised_user_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_supervised_user_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_sync_problem.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_sync_problem.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_tag.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_tag.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_tag.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_tag.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_task.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_task.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_task.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_task.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toast.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toast.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toast.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toast.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toggle_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toggle_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toggle_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toggle_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_translate.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_translate.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_translate.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_translate.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_travel_explore.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_travel_explore.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_travel_explore.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_travel_explore.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upgrade.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upgrade.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upgrade.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upgrade.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upload_file.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upload_file.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upload_file.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upload_file.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_usb.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_usb.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_missing.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_missing.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_user.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_user.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_user.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_user.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_waving_hand.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_waving_hand.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_waving_hand.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_waving_hand.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_tethering.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_tethering.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_tethering.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_tethering.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled_padded.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled_padded.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled_padded.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled_padded.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/icon_foreground_common@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/icon_foreground_common@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/icon_foreground_common@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/icon_foreground_common@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/logo@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/logo@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/logo@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/logo@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/logo_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/logo_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/logo_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/logo_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_cats@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_cats@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_flowers@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_flowers@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_hearts@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_hearts@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_kids@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_kids@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_school@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_school@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_travel@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_travel@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml index 392c935575..29ebaac8d3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/it/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml index 9b4e775685..9ae56f9d36 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/iw/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml index e42232f93a..8088ae4f23 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/ja/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml index 0789cdcc69..c5cd469d81 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/ko/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml index 880d328df6..01812e23e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml @@ -148,7 +148,7 @@ 대화 상대가 허용하는 경우에만 영구적인 메시지 삭제를 허용합니다. (24 시간) 모든 대화 상대가 연결된 상태로 유지됩니다. 항상 켜기 - Android 암호 저장소는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다. + Android 암호 저장소%s는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다. 앱을 다시 시작하거나 암호를 변경한 후 Android 암호 저장소를 사용하여 암호를 안전하게 저장합니다. - 알림을 받을 수 있습니다. 앱이 실행 중일 때만 알림을 받을 수 있으며, 백그라운드 서비스는 시작되지 않습니다. 앱 데이터 백업 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml index 87144b710d..1eef42dd4e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/lv/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml index b045044925..b58411035a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/ml/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ml/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ml/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/nb-rNO/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/nb-rNO/strings.xml index 65614f25aa..859a05933c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/night/themes.xml b/apps/multiplatform/common/src/commonMain/moko-resources/night/themes.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/night/themes.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/night/themes.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml index 988d0e73cc..708f938ad9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/pl/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml index b725bfab32..942df14f13 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/pt-rBR/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml index c353ad2a2f..679adb19e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/pt/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pt/strings.xml index 8b43d5295d..2c84318687 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/ro/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml index 8c7651fecd..f332586376 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/ru/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml index 6f43e046d8..144e81471d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/sk/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml index 61b75fcf90..77bd862259 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/sv/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/sv/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/sv/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/th/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/th/strings.xml index 9e782ee24f..73ee88a3ed 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/tr/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml index 30414e244c..989782053d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/uk/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml index 4f49834c7f..67ed554185 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/vi/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml index 9c63cff9ee..11e9080687 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/zh-rCN/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml index 75e67cca37..daa77e171e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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/moko-resources/zh-rTW/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml index 5827cead70..c146c778a8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/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..3e48621d03 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* @@ -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/DesktopTray.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt index 9f75e481f4..657b1d98f2 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt @@ -17,7 +17,7 @@ import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.isInDarkTheme import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.awt.AWTException diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt index 19cb7aea91..4f023bd04f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt @@ -3,6 +3,7 @@ package chat.simplex.common import chat.simplex.common.platform.Log import chat.simplex.common.platform.TAG import chat.simplex.common.platform.dataDir +import chat.simplex.res.* import java.io.IOException import java.nio.channels.FileChannel import java.nio.channels.FileLock diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt index ee415ae82b..0a61864294 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.simplexWindowState import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.sshtools.twoslices.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/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/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index d425d81875..167c3f898d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* import java.util.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.io.File actual val appPlatform = AppPlatform.DESKTOP diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/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/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 58260181cf..b779954efc 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.* import chat.simplex.common.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import java.awt.Desktop import java.io.* import java.net.URI diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 0f830e7b60..9c6139b493 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import boofcv.io.image.ConvertBufferedImage import boofcv.struct.image.GrayU8 -import chat.simplex.res.MR +import chat.simplex.res.* import org.jetbrains.skia.Image import java.awt.RenderingHints import java.awt.geom.AffineTransform diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 557dabd2e4..3e048a93d5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay import java.awt.Image diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index d59677b726..f22ed52357 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -3,7 +3,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.player.base.MediaPlayer diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt index 4e7594f998..062f26cfd7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.simplexWindowState import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.jthemedetecor.OsThemeDetector import com.russhwolf.settings.* import dev.icerock.moko.resources.ImageResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/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/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index a10f675085..435a8891b7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -8,7 +8,7 @@ import chat.simplex.common.views.helpers.* import java.io.File import java.net.URI import java.net.URLEncoder -import chat.simplex.res.MR +import chat.simplex.res.* import java.awt.Desktop actual fun UriHandler.sendEmail(subject: String, body: CharSequence) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index d9375f5d79..52f11de64d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface import uk.co.caprica.vlcj.media.Media diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt index 082036558c..7639c567d4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt @@ -3,26 +3,26 @@ package chat.simplex.common.ui.theme import androidx.compose.ui.text.font.* import androidx.compose.ui.text.platform.Font import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* actual val Inter: FontFamily = FontFamily( - Font(MR.fonts.Inter.regular.file), - Font(MR.fonts.Inter.italic.file, style = FontStyle.Italic), - Font(MR.fonts.Inter.bold.file, FontWeight.Bold), - Font(MR.fonts.Inter.semibold.file, FontWeight.SemiBold), - Font(MR.fonts.Inter.medium.file, FontWeight.Medium), - Font(MR.fonts.Inter.light.file, FontWeight.Light) + Font(MR.fonts.inter_regular.file), + Font(MR.fonts.inter_italic.file, style = FontStyle.Italic), + Font(MR.fonts.inter_bold.file, FontWeight.Bold), + Font(MR.fonts.inter_semibold.file, FontWeight.SemiBold), + Font(MR.fonts.inter_medium.file, FontWeight.Medium), + Font(MR.fonts.inter_light.file, FontWeight.Light) ) actual val EmojiFont: FontFamily = if (desktopPlatform.isMac()) { FontFamily.Default } else { FontFamily( - Font(MR.fonts.NotoColorEmoji.regular.file), - Font(MR.fonts.NotoColorEmoji.regular.file, style = FontStyle.Italic), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Bold), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.SemiBold), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Medium), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Light) + Font(MRdesktopMain.fonts.notocoloremoji_regular.file), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, style = FontStyle.Italic), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Bold), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.SemiBold), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Medium), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Light) ) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 75782d75d7..881b9ee168 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.platform.LocalUriHandler import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock @@ -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/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index e6f782d816..997100246c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.io.File diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt index 05ed7002e5..64dc8d61a6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import chat.simplex.common.model.MREmojiChar import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index 9fb4afbf7d..c1d4a40dae 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.getBitmapFromByteArray -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index 9fd65ec995..250d7ed3d9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt index 75a76014a9..dba5ddb9e6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt @@ -18,7 +18,7 @@ import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.views.chat.item.isHeartEmoji import chat.simplex.common.views.chat.item.isShortEmoji import chat.simplex.common.views.helpers.toDp -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt index 4535857696..2d5f58a545 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -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/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index e857ef91b1..306562e6ad 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.json import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.onboarding.ReadMoreButton -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt index d125f94e55..4ff8c552c4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index 79aaf5c727..4f26f1d5fe 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.DialogParams import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.awt.FileDialog diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt index 59865dd66f..d4d9415501 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt @@ -9,7 +9,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.platform.rememberFileChooserLauncher -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.views.newchat.ActionButton import java.net.URI diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt index 1b9582b7d2..30e1020d48 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt @@ -9,7 +9,7 @@ import chat.simplex.common.model.SharedPreference import chat.simplex.common.model.User import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt index 6b36a3b1b2..7a48f37271 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -26,7 +26,7 @@ import chat.simplex.common.trayIsAvailable import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay import java.util.Locale diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt index a51fd20c5b..325a475218 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt @@ -3,7 +3,7 @@ package chat.simplex.common.views.usersettings import SectionView import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt index 174ad63c7a..8ea4a3c5e7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf b/apps/multiplatform/common/src/desktopMain/moko-resources/fonts/NotoColorEmoji-Regular.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf rename to apps/multiplatform/common/src/desktopMain/moko-resources/fonts/NotoColorEmoji-Regular.ttf 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 34a3b502ee..96dd58052c 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -115,8 +115,8 @@ When onboarding is complete: | `SwitchingUsersView` | User switch in progress | Loading overlay | | Auth gate | `userAuthorized != true` | `AuthView` or `SplashView` + passcode | | Active call | `showCallView == true` | `ActiveCallView` (desktop) or call activity (Android) | -| One-time passcode | Always | `ModalManager.fullscreen.showOneTimePasscodeInView` | -| Privacy alerts | Always | `AlertManager.privacySensitive` | +| One-time passcode | `userAuthorized == true` | `ModalManager.fullscreen.showOneTimePasscodeInView` | +| Privacy alerts | `userAuthorized == true` | `AlertManager.privacySensitive` | | Incoming call | `activeCallInvitation != null` | `IncomingCallAlertView` | | Shared alerts | Always | `AlertManager.shared` | @@ -294,7 +294,7 @@ object AppLock { ### Authentication Flow 1. **MainScreen** checks `unauthorized` (derived: `userAuthorized.value != true`) at line ~135. -2. If unauthorized and not in an active call: +2. If unauthorized, including during an active call: - Launches `AppLock.runAuthenticate()` which triggers platform-specific biometric/passcode prompt. - On Android with system auth finishing during activity destruction, authentication is skipped. 3. If `performLA` preference is set and `laFailed` is true: shows `AuthView` with "Unlock" button. @@ -302,7 +302,7 @@ object AppLock { ### Lock Delay -The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. +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 945256f814..83b7ca4c40 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. + 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. - `--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: @@ -92,7 +95,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. @@ -122,6 +127,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 @@ -199,27 +208,15 @@ 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. - ## 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] @@ -241,8 +238,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 b1f5f4b1c1..00584fed41 100644 --- a/apps/simplex-badge-service/badge_service.ini.example +++ b/apps/simplex-badge-service/badge_service.ini.example @@ -50,7 +50,13 @@ 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 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 10d55d7da7..9e33f4459d 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 } deriving (Eq, Show) @@ -155,7 +164,7 @@ knownSettings = ("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]), ("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]), ("poll", ["waiting_seconds", "idle_seconds"]), - ("dev", ["chat_redeem"]), + ("group", ["display_name", "description"]), ("issuer", ["index", "private_key"]) ] @@ -179,16 +188,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 pure ServiceConfig { listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor}, @@ -196,17 +203,14 @@ parseConfig ini = do stripe = str, poll = PollConfig {pWaitingSeconds, pIdleSeconds}, issuer = iss, - devChatRedeem = devRedeem + group = grp } 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 @@ -268,6 +272,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 c91ffcd22f..c22df44ddb 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.Util (tshow) @@ -181,6 +181,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 @@ -290,13 +291,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 -> providerText 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 9a3a3f4236..0e189a3489 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 #-} @@ -21,7 +19,10 @@ module BadgeService.Service where import BadgeService.Catalog (defaultCatalog) +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.Options import BadgeService.Poller (newPollerEnv, newReadHints, runPoller) import BadgeService.Providers.BTCPay (btcpayProvider) @@ -33,19 +34,17 @@ import BadgeService.Waiters (Waiters, newWaiters) import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener) import Control.Applicative (optional) import Control.Concurrent.STM -import BadgeService.Log (logError, logInfo, logWarn) +import BadgeService.Log (logError, logInfo) import Control.Monad import Control.Monad.IO.Class (liftIO) 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.Functor (($>), (<&>)) 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) @@ -54,20 +53,18 @@ 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.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.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 (raceAny_, safeDecodeUtf8, tshow) import Simplex.Messaging.Version (isCompatible) import System.Directory (getAppUserDataDirectory) @@ -76,15 +73,15 @@ 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 } newServiceState :: IO ServiceState newServiceState = do serviceCC <- newEmptyTMVarIO serviceRequestQ <- newTQueueIO - chatRedeemQ <- newTQueueIO - pure ServiceState {serviceCC, serviceRequestQ, chatRedeemQ} + groupEventQ <- newTQueueIO + pure ServiceState {serviceCC, serviceRequestQ, groupEventQ} welcomeGetOpts :: IO BadgeServiceOpts welcomeGetOpts = do @@ -128,29 +125,29 @@ 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" - -- 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 env - ] - <> [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_ env : 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 @@ -188,13 +185,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 ] badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand) @@ -206,26 +203,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 = @@ -241,17 +228,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, @@ -259,60 +237,37 @@ 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 - handleServiceRequest key cc u reqId sigKey reqData - -processChatRedeems :: BadgeIssuerKey -> ServiceState -> IO () -processChatRedeems key env = do - cc <- atomically $ readTMVar $ serviceCC env - forever $ do - (ct, msg) <- atomically $ readTQueue $ chatRedeemQ env - chatRedeem key cc ct msg - --- | 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 " + handleServiceRequest key cc trackerQ_ u reqId sigKey reqData 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 -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () -handleServiceRequest key cc User {userId} reqId sigKey reqData = do +handleServiceRequest :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () +handleServiceRequest key cc trackerQ_ User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) logInfo $ "badge service request " <> reqIdT - resp <- badgeServiceResponse key cc sigKey reqData + resp <- badgeServiceResponse key cc trackerQ_ sigKey reqData sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e @@ -334,15 +289,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 -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse -badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) of +badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse +badgeServiceResponse key 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 BSCIssueBadge {balance} -> case purchaseKey of Just k -> issueBadgeCmd key cc k balance @@ -376,15 +331,15 @@ 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}) -> do + Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> do (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the -- months it adds are counted from a start already in the past @@ -395,41 +350,42 @@ redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText Just issued -> credentialForEntry key masterKey issued >>= \case Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal Right signed -> do - -- If the code was revoked or redeemed while signing, the claim fails. Read the code again to tell the client why. + -- 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 + 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 $ do appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed entries_ <- getLedgerEntries db purchaseId 0 - pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ - pure $ fromRight (errorResponse BSEInternal) r + pure (maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_, True) + 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 -> - checkUnspent db redemption >>= \case - Left resp -> pure $ Left resp - Right () - | maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired - | otherwise -> pure $ Right c - checkUnspent db = \case - CodeUnredeemed -> pure $ Right () - CodeRedeemedUnreadable -> pure $ Left $ errorResponse BSEInternal - CodeRedeemed RedeemedCode {purchaseKey = k, badgePurchaseId, credential} - | k /= purchaseKey -> pure $ Left $ errorResponse BSECodeUsed - | otherwise -> - maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) - <$> getLedgerEntries db badgePurchaseId 0 + 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 -- | The purchase is reached through the verified signer key and no other way. issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO BadgeServiceResponse diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index d8ab44c268..b51e47bdad 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -7,11 +7,17 @@ module BadgeService.Store ( IssuedCode (..), - CodeRedemption (..), - RedeemedCode (..), + KeyRedemption (..), + KeyPurchase (..), NewCodePurchase (..), ServicePurchase (..), + ManagedGroup (..), + getManagedGroup, + insertManagedGroup, + clearCodeGroupItems, + markOwnerBootstrapped, getBadgeCode, + getCodePurchaseForKey, purchaseKeyExists, getPurchaseByKey, getLedgerTip, @@ -21,6 +27,10 @@ module BadgeService.Store appendLedgerPlan, createCodePurchase, insertBadgeCode, + setCodeGroupItem, + CodeTracker (..), + getCodeTracker, + getEditableTrackers, RevokeResult (..), revokeCode, ) @@ -31,6 +41,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) @@ -38,7 +49,7 @@ import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service (StatementEntry (..)) import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..)) 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.Util (maybeFirstRow, maybeFirstRow') @@ -58,17 +69,17 @@ data IssuedCode = IssuedCode paymentStatus :: BadgeCodePaymentStatus, revokedAt :: Maybe UTCTime, expiresAt :: Maybe UTCTime, - redemption :: CodeRedemption + redeemLimit :: Int, + redeemCount :: Int } -data CodeRedemption - = CodeUnredeemed - | CodeRedeemed RedeemedCode - | CodeRedeemedUnreadable +data KeyRedemption + = KeyUnredeemed + | KeyRedeemed KeyPurchase + | KeyRedeemedUnreadable -data RedeemedCode = RedeemedCode +data KeyPurchase = KeyPurchase { badgePurchaseId :: Int64, - purchaseKey :: C.PublicKeyEd25519, credential :: BadgeCredential } @@ -85,30 +96,81 @@ 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 = codeRedemption purchaseId_ purchaseKey_ credential_} - codeRedemption purchaseId_ purchaseKey_ credential_ = case (purchaseId_, purchaseKey_) of - (Just badgePurchaseId, Just purchaseKey) -> case decodeCredential =<< credential_ of - Just credential -> CodeRedeemed RedeemedCode {badgePurchaseId, purchaseKey, credential} - Nothing -> CodeRedeemedUnreadable - _ -> CodeUnredeemed + 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 decodeCredential (Binary bs) = J.decodeStrict' bs purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool @@ -222,15 +284,14 @@ appendLedgerPlan db purchaseId rows issuance_ = do ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType)) 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 @@ -245,32 +306,79 @@ createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = Bad (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, 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 c8179d5df2..f096d3098d 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -17,7 +17,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = - [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema) + [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), + ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -109,6 +110,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; +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index bf022f189c..cd89859686 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -18,7 +18,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = - [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema) + [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), + ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -110,6 +111,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; +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; diff --git a/apps/simplex-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 4ffba7bfbe..79820605a8 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") ] ), @@ -438,7 +446,6 @@ undocumentedCommands = "APISetServerOperators", "APISetUserContactReceipts", "APISetUserGroupReceipts", - "APISetUserDomain", "APISetUserServers", "APISetUserUIThemes", "APIStandaloneFileInfo", diff --git a/cabal.project b/cabal.project index b8c73b14cf..e5f2c65eda 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 5294b7d8b7285a887b849b738e29bec207e5dde8 + tag: e24d537eaaa32230eec1fefcd957d474d7b8d4d5 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/WHY.md b/docs/WHY.md index e1582984df..b36df4a154 100644 --- a/docs/WHY.md +++ b/docs/WHY.md @@ -17,3 +17,5 @@ The oldest human freedom — to speak to another person without being watched Because we destroyed the power to know who you are. So that your power can never be taken. Be free in your network. + +[Watch the film](https://www.youtube.com/watch?v=k-GhLNZm8G0) diff --git a/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 8ea70e1d19..8c43ca6b28 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. - `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 @@ A timeout hides the outcome, so the client repeats the identical signed request - `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` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. - `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. @@ -63,4 +63,4 @@ An assertion that names an entry the service holds is a prefix: the service proc ## Errors -`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — redeemed under another key; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. +`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. 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..a2400e9947 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -325,9 +325,6 @@ "introInvitation": { "properties": { "groupConnReq": {"ref": "connReqUri"} - }, - "optionalProperties": { - "directConnReq": {"ref": "connReqUri"} } }, "groupMemberRole": { @@ -338,7 +335,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 +350,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 f750dce1b2..9faf3b9c0d 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/). + ## Native library `libsimplex` is downloaded on first use into the user cache (`$XDG_CACHE_HOME/simplex-chat` or `~/.cache/simplex-chat` on Linux, `~/Library/Caches/simplex-chat` on macOS, `%LOCALAPPDATA%\simplex-chat` on Windows), shared with the Python library. To download it ahead of time, for example in a Dockerfile, run as the user that runs the app: diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md index cc0e9eec3c..e72abb91f2 100644 --- a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md @@ -72,7 +72,7 @@ Chat controller is started > **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:749](../src/api.ts#L749) +Defined in: [src/api.ts:760](../src/api.ts#L760) Accept contact request. Network usage: interactive. @@ -93,7 +93,7 @@ Network usage: interactive. > **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:569](../src/api.ts#L569) +Defined in: [src/api.ts:580](../src/api.ts#L580) Accept group member. Requires Admin role. Network usage: background. @@ -122,7 +122,7 @@ Network usage: background. > **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:549](../src/api.ts#L549) +Defined in: [src/api.ts:560](../src/api.ts#L560) Add contact to group. Requires bot to have Admin role. Network usage: interactive. @@ -151,7 +151,7 @@ Network usage: interactive. > **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\> -Defined in: [src/api.ts:589](../src/api.ts#L589) +Defined in: [src/api.ts:600](../src/api.ts#L600) Block members. Requires Moderator role. Network usage: background. @@ -180,7 +180,7 @@ Network usage: background. > **apiCancelFile**(`fileId`): `Promise`\<`void`\> -Defined in: [src/api.ts:539](../src/api.ts#L539) +Defined in: [src/api.ts:550](../src/api.ts#L550) Cancel file. Network usage: background. @@ -201,7 +201,7 @@ Network usage: background. > **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\> -Defined in: [src/api.ts:512](../src/api.ts#L512) +Defined in: [src/api.ts:523](../src/api.ts#L523) Add/remove message reaction. Network usage: background. @@ -238,7 +238,7 @@ Network usage: background. > **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:718](../src/api.ts#L718) +Defined in: [src/api.ts:729](../src/api.ts#L729) Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link Network usage: interactive. @@ -267,7 +267,7 @@ Network usage: interactive. > **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:727](../src/api.ts#L727) +Defined in: [src/api.ts:738](../src/api.ts#L738) Connect via SimpleX link as string in the active user profile. Network usage: interactive. @@ -288,7 +288,7 @@ Network usage: interactive. > **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> -Defined in: [src/api.ts:708](../src/api.ts#L708) +Defined in: [src/api.ts:719](../src/api.ts#L719) Determine SimpleX link type and if the bot is already connected via this link. Network usage: interactive. @@ -313,7 +313,7 @@ Network usage: interactive. > **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\> -Defined in: [src/api.ts:886](../src/api.ts#L886) +Defined in: [src/api.ts:897](../src/api.ts#L897) Create new user profile Network usage: no. @@ -334,7 +334,7 @@ Network usage: no. > **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\> -Defined in: [src/api.ts:649](../src/api.ts#L649) +Defined in: [src/api.ts:660](../src/api.ts#L660) Create group link. Network usage: interactive. @@ -359,7 +359,7 @@ Network usage: interactive. > **apiCreateLink**(`userId`): `Promise`\<`string`\> -Defined in: [src/api.ts:695](../src/api.ts#L695) +Defined in: [src/api.ts:706](../src/api.ts#L706) Create 1-time invitation link. Network usage: interactive. @@ -380,7 +380,7 @@ Network usage: interactive. > **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:952](../src/api.ts#L952) +Defined in: [src/api.ts:963](../src/api.ts#L963) Create a direct message contact with a group member. Returns the created contact. @@ -427,7 +427,7 @@ Network usage: interactive. > **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\> -Defined in: [src/api.ts:808](../src/api.ts#L808) +Defined in: [src/api.ts:819](../src/api.ts#L819) Delete chat. Network usage: background. @@ -456,7 +456,7 @@ Network usage: background. > **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:487](../src/api.ts#L487) +Defined in: [src/api.ts:498](../src/api.ts#L498) Delete message. Network usage: background. @@ -489,7 +489,7 @@ Network usage: background. > **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\> -Defined in: [src/api.ts:671](../src/api.ts#L671) +Defined in: [src/api.ts:682](../src/api.ts#L682) Delete group link. Network usage: background. @@ -510,7 +510,7 @@ Network usage: background. > **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:502](../src/api.ts#L502) +Defined in: [src/api.ts:513](../src/api.ts#L513) Moderate message. Requires Moderator role (and higher than message author's). Network usage: background. @@ -535,7 +535,7 @@ Network usage: background. > **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\> -Defined in: [src/api.ts:916](../src/api.ts#L916) +Defined in: [src/api.ts:927](../src/api.ts#L927) Delete user profile. Network usage: background. @@ -585,7 +585,7 @@ Network usage: background. > **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\> -Defined in: [src/api.ts:866](../src/api.ts#L866) +Defined in: [src/api.ts:877](../src/api.ts#L877) Get active user profile Network usage: no. @@ -600,7 +600,7 @@ Network usage: no. > **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\> -Defined in: [src/api.ts:856](../src/api.ts#L856) +Defined in: [src/api.ts:867](../src/api.ts#L867) Get chat items. Network usage: no. @@ -629,7 +629,7 @@ Network usage: no. > **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\> -Defined in: [src/api.ts:793](../src/api.ts#L793) +Defined in: [src/api.ts:804](../src/api.ts#L804) Get chat previews (paginated). Network usage: no. @@ -666,7 +666,7 @@ on large databases. > **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\> -Defined in: [src/api.ts:680](../src/api.ts#L680) +Defined in: [src/api.ts:691](../src/api.ts#L691) Get group link. Network usage: no. @@ -687,7 +687,7 @@ Network usage: no. > **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\> -Defined in: [src/api.ts:686](../src/api.ts#L686) +Defined in: [src/api.ts:697](../src/api.ts#L697) #### Parameters @@ -726,7 +726,7 @@ Network usage: no. > **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:559](../src/api.ts#L559) +Defined in: [src/api.ts:570](../src/api.ts#L570) Join group. Network usage: interactive. @@ -747,7 +747,7 @@ Network usage: interactive. > **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:609](../src/api.ts#L609) +Defined in: [src/api.ts:620](../src/api.ts#L620) Leave group. Network usage: background. @@ -768,7 +768,7 @@ Network usage: background. > **apiListContacts**(`userId`): `Promise`\<`Contact`[]\> -Defined in: [src/api.ts:769](../src/api.ts#L769) +Defined in: [src/api.ts:780](../src/api.ts#L780) Get contacts. Network usage: no. @@ -789,7 +789,7 @@ Network usage: no. > **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\> -Defined in: [src/api.ts:779](../src/api.ts#L779) +Defined in: [src/api.ts:790](../src/api.ts#L790) Get groups. Network usage: no. @@ -818,7 +818,7 @@ Network usage: no. > **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:619](../src/api.ts#L619) +Defined in: [src/api.ts:630](../src/api.ts#L630) Get group members. Network usage: no. @@ -839,7 +839,7 @@ Network usage: no. > **apiListUsers**(): `Promise`\<`UserInfo`[]\> -Defined in: [src/api.ts:896](../src/api.ts#L896) +Defined in: [src/api.ts:907](../src/api.ts#L907) Get all user profiles Network usage: no. @@ -854,7 +854,7 @@ Network usage: no. > **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:629](../src/api.ts#L629) +Defined in: [src/api.ts:640](../src/api.ts#L640) Create group. Network usage: no. @@ -879,7 +879,7 @@ Network usage: no. > **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\> -Defined in: [src/api.ts:528](../src/api.ts#L528) +Defined in: [src/api.ts:539](../src/api.ts#L539) Receive file. Network usage: no. @@ -900,7 +900,7 @@ Network usage: no. > **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\> -Defined in: [src/api.ts:759](../src/api.ts#L759) +Defined in: [src/api.ts:770](../src/api.ts#L770) Reject contact request. The user who sent the request is **not notified**. Network usage: no. @@ -921,7 +921,7 @@ Network usage: no. > **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:599](../src/api.ts#L599) +Defined in: [src/api.ts:610](../src/api.ts#L610) Remove members. Requires Admin role. Network usage: background. @@ -950,7 +950,7 @@ Network usage: background. > **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:963](../src/api.ts#L963) +Defined in: [src/api.ts:974](../src/api.ts#L974) Send a direct message invitation to a group member contact. The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact). @@ -976,7 +976,7 @@ Network usage: interactive. > **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:431](../src/api.ts#L431) +Defined in: [src/api.ts:442](../src/api.ts#L442) Send messages. Network usage: background. @@ -1005,7 +1005,7 @@ Network usage: background. > **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:454](../src/api.ts#L454) +Defined in: [src/api.ts:465](../src/api.ts#L465) Send text message. Network usage: background. @@ -1034,7 +1034,7 @@ Network usage: background. > **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:462](../src/api.ts#L462) +Defined in: [src/api.ts:473](../src/api.ts#L473) Send text message in reply to received message. Network usage: background. @@ -1059,7 +1059,7 @@ Network usage: background. > **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\> -Defined in: [src/api.ts:906](../src/api.ts#L906) +Defined in: [src/api.ts:917](../src/api.ts#L917) Set active user profile Network usage: no. @@ -1109,7 +1109,7 @@ Network usage: interactive. > **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\> -Defined in: [src/api.ts:845](../src/api.ts#L845) +Defined in: [src/api.ts:856](../src/api.ts#L856) Set auto-accept member contacts. Network usage: no. @@ -1134,7 +1134,7 @@ Network usage: no. > **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:835](../src/api.ts#L835) +Defined in: [src/api.ts:846](../src/api.ts#L846) Set contact custom data. Network usage: no. @@ -1159,7 +1159,7 @@ Network usage: no. > **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\> -Defined in: [src/api.ts:942](../src/api.ts#L942) +Defined in: [src/api.ts:953](../src/api.ts#L953) Configure chat preference overrides for the contact. Network usage: background. @@ -1184,7 +1184,7 @@ Network usage: background. > **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:825](../src/api.ts#L825) +Defined in: [src/api.ts:836](../src/api.ts#L836) Set group custom data. Network usage: no. @@ -1209,7 +1209,7 @@ Network usage: no. > **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:662](../src/api.ts#L662) +Defined in: [src/api.ts:673](../src/api.ts#L673) Set member role for group link. Network usage: no. @@ -1234,7 +1234,7 @@ Network usage: no. > **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:579](../src/api.ts#L579) +Defined in: [src/api.ts:590](../src/api.ts#L590) Set members role. Requires Admin role. Network usage: background. @@ -1284,11 +1284,33 @@ Network usage: interactive. *** +### apiSetUserDomain() + +> **apiSetUserDomain**(`userId`, `simplexDomain?`): `Promise`\<`User`\> + +Defined in: [src/api.ts:427](../src/api.ts#L427) + +#### Parameters + +##### userId + +`number` + +##### simplexDomain? + +`string` + +#### Returns + +`Promise`\<`User`\> + +*** + ### apiUpdateChatItem() > **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\> -Defined in: [src/api.ts:470](../src/api.ts#L470) +Defined in: [src/api.ts:481](../src/api.ts#L481) Update message. Network usage: background. @@ -1325,7 +1347,7 @@ Network usage: background. > **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:639](../src/api.ts#L639) +Defined in: [src/api.ts:650](../src/api.ts#L650) Update group profile. Network usage: background. @@ -1350,7 +1372,7 @@ Network usage: background. > **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\> -Defined in: [src/api.ts:926](../src/api.ts#L926) +Defined in: [src/api.ts:937](../src/api.ts#L937) Update user profile. Network usage: background. 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.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md index 0af2644bc2..bc31ad01a8 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.run.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md @@ -8,7 +8,7 @@ > **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> -Defined in: [src/bot.ts:48](../src/bot.ts#L48) +Defined in: [src/bot.ts:49](../src/bot.ts#L49) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md index 5397cf3aeb..0bcc49cbc6 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -8,7 +8,7 @@ > **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void` -Defined in: [src/bot.ts:108](../src/bot.ts#L108) +Defined in: [src/bot.ts:110](../src/bot.ts#L110) ## Parameters @@ -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..036afe64d4 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:36](../src/bot.ts#L36) > **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md) -Defined in: [src/bot.ts:38](../src/bot.ts#L38) +Defined in: [src/bot.ts:39](../src/bot.ts#L39) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38) > `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md) -Defined in: [src/bot.ts:45](../src/bot.ts#L45) +Defined in: [src/bot.ts:46](../src/bot.ts#L46) *** @@ -30,19 +30,19 @@ Defined in: [src/bot.ts:45](../src/bot.ts#L45) > `optional` **onCommands?**: `object` -Defined in: [src/bot.ts:42](../src/bot.ts#L42) +Defined in: [src/bot.ts:43](../src/bot.ts#L43) #### 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) +Defined in: [src/bot.ts:41](../src/bot.ts#L41) #### Parameters @@ -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`\> @@ -64,7 +68,7 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40) > **options**: [`BotOptions`](bot.Interface.BotOptions.md) -Defined in: [src/bot.ts:39](../src/bot.ts#L39) +Defined in: [src/bot.ts:40](../src/bot.ts#L40) *** @@ -73,3 +77,11 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39) > **profile**: `Profile` Defined in: [src/bot.ts:37](../src/bot.ts#L37) + +*** + +### simplexDomain? + +> `optional` **simplexDomain?**: `string` \| `null` + +Defined in: [src/bot.ts:38](../src/bot.ts#L38) 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 43e85fb0f7..7e07bb0849 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", "bin": { diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index b232f32aef..e24736aa9b 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} @@ -176,7 +176,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) @@ -186,7 +186,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) @@ -421,7 +421,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/libs.ts b/packages/simplex-chat-nodejs/src/libs.ts index 95ec9f2a92..ad83b514a4 100644 --- a/packages/simplex-chat-nodejs/src/libs.ts +++ b/packages/simplex-chat-nodejs/src/libs.ts @@ -7,7 +7,7 @@ import {pipeline} from "stream/promises" import extract = require("extract-zip") import type {Backend} from "./core" -export const LIBS_VERSION = "7.1.0-beta.4" +export const LIBS_VERSION = "7.1.0-beta.6" const GITHUB_REPO = "simplex-chat/simplex-chat-libs" const REQUEST_TIMEOUT_MS = 60_000 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 b84de92b09..4abe80e6db 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" @@ -31,6 +31,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") @@ -96,6 +103,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 09ca430fc7..dcc9364d62 100644 --- a/scripts/badge-service/badge_service.ini.example +++ b/scripts/badge-service/badge_service.ini.example @@ -42,5 +42,13 @@ idle_seconds = 60 index = 1 private_key = replace-me -[dev] -chat_redeem = on +; 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 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..d6c932df39 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"."e24d537eaaa32230eec1fefcd957d474d7b8d4d5" = "1ximwqi1jaiq1ml05y0mcsrgvabhp5is1ry2h91w5jjarjb1a5fj"; "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 e759fbdd3e..daa16691e6 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 @@ -429,7 +429,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 @@ -717,7 +720,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 @@ -737,6 +743,8 @@ test-suite simplex-chat-test Bots.BadgeService.ConfigTests Bots.BadgeService.FakeBTCPay Bots.BadgeService.FakeStripe + Bots.BadgeService.GroupIntegrationTests + Bots.BadgeService.GroupTests Bots.BadgeService.StripeTests Bots.BadgeService.WaitersTests Bots.BadgeService.WebTests @@ -771,7 +779,7 @@ test-suite simplex-chat-test default-extensions: StrictData -- add -fhpc to ghc-options below to run tests with coverage - ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded + ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts "-with-rtsopts=-N" build-depends: QuickCheck ==2.14.* , aeson ==2.2.* @@ -834,5 +842,6 @@ test-suite simplex-chat-test build-depends: bytestring ==0.10.* , hspec ==2.7.* + , hspec-core ==2.7.* , process >=1.6 && <1.6.18 , text >=1.2.4.0 && <1.3 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3e6fb7e229..0522e3c8a6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -29,6 +29,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Time.Clock (getCurrentTime, nominalDay) import Simplex.Chat.Badges (badgeServerCredential, defaultFileSizeLimits) +import Simplex.Chat.Call (supportedCallVRange) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Operators @@ -68,6 +69,7 @@ defaultChatConfig = tbqSize = 1024 }, chatVRange = supportedChatVRange, + callVRange = supportedCallVRange, badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys, badgeServiceAddress = Just $ either error id $ strDecode "https://smp5.simplex.im/a#ooSNWlEZTO2RPE0Ff5ZoybAs5zEhWLMlQrXesnhaZHM", badgeCurrentTime = getCurrentTime, @@ -123,6 +125,7 @@ defaultChatConfig = cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes + callInvitationTTL = 180, -- 3 minutes, the apps stop ringing for older invitations highlyAvailable = False, deliveryWorkerDelay = 0, deliveryBucketSize = 10000, diff --git a/src/Simplex/Chat/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/Badges.hs b/src/Simplex/Chat/Badges.hs index 62683788d5..f34ba8ef93 100644 --- a/src/Simplex/Chat/Badges.hs +++ b/src/Simplex/Chat/Badges.hs @@ -295,7 +295,13 @@ data ProofPresHeader | PHFileDescr {chatBinding :: ByteString, fileSize :: Int64, descrHash :: ByteString, fileExpires :: Maybe UTCTime} | PHUnknown Char ByteString deriving (Eq, Show) - deriving (ToJSON, FromJSON) via (StrJSON "ProofPresHeader" ProofPresHeader) + +instance ToJSON ProofPresHeader where + toJSON = toJSON . BBSPresHeader . strEncode + toEncoding = toEncoding . BBSPresHeader . strEncode + +instance FromJSON ProofPresHeader where + parseJSON v = parseJSON v >>= \(BBSPresHeader ph) -> either fail pure $ strDecode ph instance StrEncoding ProofPresHeader where strEncode = \case diff --git a/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 9cc80fc54b..be3025bd25 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -144,6 +144,7 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, chatVRange :: VersionRangeChat, + callVRange :: VersionRangeCall, -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation badgePublicKeys :: Map Int BBSPublicKey, -- Nothing until the badge service is deployed @@ -174,6 +175,7 @@ data ChatConfig = ChatConfig cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds + callInvitationTTL :: NominalDiffTime, deliveryWorkerDelay :: Int64, -- microseconds deliveryBucketSize :: Int, webPreviewConfig :: Maybe WebPreviewConfig, @@ -446,7 +448,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..3b8ffb34bb 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) @@ -72,10 +75,10 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha noMaintenance img_ <- mapM loadImageFile userImageFile createActiveUser cc coreOptions createBot userDisplayName img_ - Just u@User {localDisplayName} -> do + Just u@User {profile = LocalProfile {displayName}} -> do forM_ userDisplayName $ \name -> - when (localDisplayName /= name) $ do - putStrLn $ "Active user display name " <> show localDisplayName <> " does not match --user-display-name " <> show name + when (displayName /= name) $ do + putStrLn $ "Active user display name " <> show displayName <> " does not match --user-display-name " <> show name exitFailure -- --user-image-file only applies when the profile is created; ignore it for an existing user forM_ userImageFile $ \_ -> @@ -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 d02a189b1d..4ffd27f428 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -174,7 +174,9 @@ checkProfileImageSize = mapM_ $ \(ImageData t) -> in when (size > maxProfileImageSize) $ throwCmdError $ "Profile image is too large " <> show size checkProfileSize :: Profile -> CM () -checkProfileSize p = checkInfoSize "Profile" (XInfo p Nothing) +checkProfileSize p@Profile {contactDomain} = checkInfoSize "Profile" (XInfo p' Nothing) + where + p' = (p :: Profile) {badge = Nothing, contactDomain = (\d -> d {proof = Nothing} :: SimplexDomainClaim) <$> contactDomain} checkGroupProfileSize :: GroupProfile -> CM () checkGroupProfileSize p = checkInfoSize "Group profile" (XGrpInfo p) @@ -351,7 +353,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 @@ -452,7 +456,7 @@ processChatCommand cxt nm = \case p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser users <- withFastStore' getUsers - forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do + forM_ users $ \User {profile = LocalProfile {displayName = n}, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} when (isTrue userChatRelay && isTrue userChatRelay') $ throwChatError CEChatRelayExists @@ -1509,7 +1513,8 @@ processChatCommand cxt nm = \case callId <- atomically $ CallId <$> C.randomBytes 16 g callUUID <- UUID.toText <$> liftIO V4.nextRandom dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing - let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} + ChatConfig {callVRange = callVR} <- asks config + let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair, callVRange = Just $ CallVersionRange callVR} callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} (msg, _) <- sendDirectContactMessage user ct (XCallInv callId invitation) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) @@ -1537,9 +1542,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) @@ -1593,8 +1598,9 @@ processChatCommand cxt nm = \case APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call - APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) - APISetUserDomain userId domain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> + APIUpdateProfile userId profile -> withUserProfileLock "updateProfile" $ withUserId userId (`updateProfile` profile) + APISetUserDomain userId strDomain_ -> withUserProfileLock "setUserDomain" $ withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do + let domain_ = unStrJSON <$> strDomain_ if (claimDomain <$> contactDomain) == domain_ then pure $ CRUserProfileNoChange user else do @@ -1633,7 +1639,7 @@ processChatCommand cxt nm = \case APISetUserUIThemes uId uiThemes -> withUser $ \user@User {userId} -> do user'@User {userId = uId'} <- withFastStore $ \db -> do user' <- getUser db uId - liftIO $ setUserUIThemes db user uiThemes + liftIO $ setUserUIThemes db user' uiThemes pure user' when (userId == uId') $ chatWriteVar currentUser $ Just (user :: User) {uiThemes} ok user' @@ -2476,12 +2482,12 @@ processChatCommand cxt nm = \case pure $ CRUserContactLinkCreated user ccLink'' CreateMyAddress ratchetKeys_ -> withUser $ \User {userId} -> processChatCommand cxt nm $ APICreateMyAddress userId Nothing ratchetKeys_ - APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do + APIDeleteMyAddress userId -> withUserProfileLock "deleteMyAddress" $ withUserId userId $ \user@User {profile = p} -> do conn <- withFastStore $ \db -> getUserAddressConnection db cxt user withChatLock "deleteMyAddress" $ do deleteAgentConnectionAsync $ aConnId conn withFastStore' (`deleteUserAddress` user) - let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} + let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing, contactDomain = Nothing} r <- updateProfile_ user p' False $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing let user' = case r of CRUserProfileUpdated u' _ _ _ -> u' @@ -2499,10 +2505,10 @@ processChatCommand cxt nm = \case CRUserContactLink user <$> (withFastStore (`getUserAddress` user) >>= setMyAddressData False pqInitKeys user) APIRotateAddressRatchetKeys userId -> withUserId' userId $ \user -> CRUserContactLink user <$> (withFastStore (`getUserAddress` user) >>= setMyAddressData True (Just IKUsePQ) user) - APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do - let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} + APISetProfileAddress userId False -> withUserProfileLock "setProfileAddress" $ withUserId userId $ \user@User {profile = p} -> do + let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing, contactDomain = Nothing} updateProfile_ user p' True $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing - APISetProfileAddress userId True -> withUserId userId $ \user@User {profile = p} -> do + APISetProfileAddress userId True -> withUserProfileLock "setProfileAddress" $ withUserId userId $ \user@User {profile = p} -> do ucl <- withFastStore (`getUserAddress` user) -- TODO [short links] replace with short links let p' = (fromLocalProfile p :: Profile) {contactLink = Just $ profileContactLink ucl} @@ -2866,7 +2872,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 @@ -2945,6 +2952,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]) @@ -3575,23 +3584,23 @@ processChatCommand cxt nm = \case -- after the write, so the pass it signals arms a wake for the snooze rather than raising again lift $ startBadgeWork user CRBadgeState user <$> getUserBadgeState user - SetBotCommands commands -> withUser $ \user@User {profile} -> do + SetBotCommands commands -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} p = (fromLocalProfile profile :: Profile) {preferences = prefs, peerType = Just CPTBot} updateProfile user p - UpdateProfile displayName shortDescr -> withUser $ \user@User {profile} -> do + UpdateProfile displayName shortDescr -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {displayName, shortDescr, fullName = ""} updateProfile user p - UpdateProfileImage image -> withUser $ \user@User {profile} -> do + UpdateProfileImage image -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {image} updateProfile user p - UpdateProfileImageFromFile path -> withUser $ \user@User {profile} -> do + UpdateProfileImageFromFile path -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do img <- readProfileImageFile path let p = (fromLocalProfile profile :: Profile) {image = Just img} updateProfile user p ShowProfileImage -> withUser $ \user@User {profile} -> pure $ CRUserProfileImage user $ fromLocalProfile profile - SetUserFeature (ACF f) allowed -> withUser $ \user@User {profile} -> do + SetUserFeature (ACF f) allowed -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference f (Just allowed) $ preferences' user} updateProfile user p SetContactFeature (ACF f) cName allowed_ -> withUser $ \user -> do @@ -3609,7 +3618,7 @@ processChatCommand cxt nm = \case case memberAdmission of Nothing -> p {memberAdmission = Just (emptyGroupMemberAdmission :: GroupMemberAdmission) {review = reviewAdmissionApplication}} Just ma -> p {memberAdmission = Just (ma :: GroupMemberAdmission) {review = reviewAdmissionApplication}} - SetUserTimedMessages onOff -> withUser $ \user@User {profile} -> do + SetUserTimedMessages onOff -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let allowed = if onOff then FAYes else FANo pref = TimedMessagesPreference allowed Nothing p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference' SCFTimedMessages (Just pref) $ preferences' user} @@ -3678,6 +3687,7 @@ processChatCommand cxt nm = \case CLContactRequest crId -> "ContactRequest " <> tshow crId CLFile fId -> "File " <> tshow fId CLBadgeUser uId -> "BadgeUser " <> tshow uId + CLUserProfile -> "UserProfile" DebugEvent event -> toView event >> ok_ GetAgentSubsTotal userId -> withUserId userId $ \user -> do users <- withStore' $ \db -> getUsers db @@ -3980,7 +3990,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_ _ -> @@ -4002,20 +4012,29 @@ processChatCommand cxt nm = \case when (fileSize > maxSndXFTPFileSize lims now sndBadge) $ throwChatError $ CEFileSize f pure fileSize updateProfile :: User -> Profile -> CM ChatResponse - updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' + updateProfile user@User {profile = LocalProfile {contactDomain}} p = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' + where + p' = (p :: Profile) {contactDomain} updateProfile_ :: User -> Profile -> Bool -> CM User -> CM ChatResponse updateProfile_ user@User {profile = p@LocalProfile {displayName = n}} p'@Profile {displayName = n', image = img'} shouldUpdateAddressData updateUser | p' == fromLocalProfile p = pure $ CRUserProfileNoChange user | otherwise = do - when (n /= n') $ checkValidName n' + when (n /= n') $ do + checkValidName n' + users <- withFastStore' getUsers + forM_ users $ \User {profile = LocalProfile {displayName}, activeUser, viewPwdHash} -> + when (displayName == n') . throwChatError $ + if activeUser || isNothing viewPwdHash then CEUserExists n' else CEInvalidDisplayName {displayName = n', validName = ""} checkProfileImageSize img' checkProfileSize p' + when shouldUpdateAddressData $ do + ts <- liftIO getCurrentTime + setMyAddressData' (user :: User) {profile = updatedUserProfile user p' ts} -- read contacts before user update to correctly merge preferences contacts <- withFastStore' $ \db -> getUserContacts db cxt user user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') withChatLock "updateProfile" $ do - when shouldUpdateAddressData $ setMyAddressData' user' summary <- sendUpdateToContacts user' contacts pure $ CRUserProfileUpdated user' (fromLocalProfile p) p' summary where @@ -5623,7 +5642,7 @@ presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now | otherwise = do cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId) forM_ cred_ $ \cred@(BadgeCredential _ _ _ info) -> - when (presentDue cred) $ do + when (presentDue cred) $ withUserProfileLock "presentIssuedBadge" $ do user' <- withStore $ \db -> setUserBadge db user (Just $ OwnBadge cred (mkBadgeStatus now (Just True) info)) presentUserBadgeToContacts user' where @@ -5636,7 +5655,7 @@ presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now retireExpiredBadge :: User -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM Bool retireExpiredBadge user UserBadgePurchase {badgePurchaseId, shown} now balance | not (shown && L.paidThrough balance <= now) = pure False - | otherwise = do + | otherwise = withUserProfileLock "retireExpiredBadge" $ do user' <- withStore $ \db -> do liftIO $ clearShownBadge db user badgePurchaseId setUserBadge db user Nothing @@ -6123,7 +6142,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 "")), @@ -6671,8 +6690,11 @@ displayNameP_ = quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',') refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' mkValidName :: String -> String -mkValidName = dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) +mkValidName = noLdnSuffix . dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where + noLdnSuffix s = case break (== '_') (reverse s) of + (ds@(_ : _), '_' : rest@(_ : _)) | all isDigit ds -> reverse rest <> ('-' : reverse ds) + _ -> s fst3 (x, _, _) = x addChar (r, prev, punct) c' = if validChar then (c : r, c, punct') else (r, prev, punct) where diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f6c1fb3b9e..4008bc2727 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -160,6 +160,10 @@ withFileLock :: Text -> Int64 -> CM a -> CM a withFileLock name = withEntityLock name . CLFile {-# INLINE withFileLock #-} +withUserProfileLock :: Text -> CM a -> CM a +withUserProfileLock name = withEntityLock name CLUserProfile +{-# INLINE withUserProfileLock #-} + useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p) useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains = fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains @@ -443,10 +447,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 +490,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 +844,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 +868,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 +922,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 @@ -1400,7 +1418,7 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c -- (regular groups only; never channels) is an authored element -- all batch together in order. let fwdEls = map (uncurry encodeFwdElement) (concat fwdMsgsByItem) welcomeEl <- welcomeElement - let (batches, dropped) = batchElements maxEncodedMsgLength (fwdEls <> maybe [] (: []) welcomeEl) + let (batches, dropped) = batchElements maxForwardBatchLength (fwdEls <> maybe [] (: []) welcomeEl) when (dropped > 0) $ toView $ CEvtChatErrors [ChatError $ CEInternalError ("sendHistory: dropped " <> show dropped <> " oversized history messages")] forM_ batches $ \body -> void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)] @@ -1538,9 +1556,7 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c pure $ map ((,) fwd) (contentVM : fileDescrVMs) memberShortenedName :: GroupMember -> ContactName -memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}} - | T.length displayName <= 16 = displayName - | otherwise = T.take 16 displayName `T.snoc` '…' +memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}} = fwdMemberName displayName -- the description proof travels on the last part, so that part leaves room for it badgeDescrPartSize :: Int @@ -2434,6 +2450,20 @@ 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 $ e2eEncConnInfoLength pqSup - 2 - maxReplyQueueFraming + +maxReplyQueueFraming :: Int +maxReplyQueueFraming = 256 + encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString encodeConnInfo = encodeConnInfoPQ PQSupportOff @@ -2441,33 +2471,28 @@ encodeConnInfoPQ :: MsgEncodingI e => PQSupport -> ChatMsgEvent e -> CM ByteStri encodeConnInfoPQ pqSup chatMsgEvent = do cxt <- chatStoreCxt let info = ChatMessage {chatVRange = vr cxt, msgId = Nothing, chatMsgEvent} - case encodeChatMessage maxEncodedInfoLength info of - 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 + case encodeChatMessage maxEncodedProfileMsgLength info of + 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 + case encodeChatMessage maxEncodedProfileMsgLength info of + 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 +2516,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 +3065,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..4ff32429b9 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' @@ -774,7 +774,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag hostConnId <- withStore $ \db -> do liftIO $ setConnConnReqInv db user connId cReq getHostConnId db user groupId - sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} + sendXGrpMemInv hostConnId XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _pqSupport _ connInfo -> do @@ -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 () @@ -3294,10 +3304,10 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId (chatHasNtfs chatSettings) SCMInvitation CR.IKPQOff True subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" - sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> CM () - sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do + sendXGrpMemInv :: Int64 -> XGrpMemIntroCont -> CM () + sendXGrpMemInv hostConnId XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do hostConn <- withStore $ \db -> getConnectionById db cxt user hostConnId - let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} + let msg = XGrpMemInv memberId IntroInvitation {groupConnReq} void $ sendDirectMemberMessage hostConn msg groupId withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited @@ -3311,7 +3321,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _ _) IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _ _) IntroInvitation {groupConnReq} = do let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- withStore $ \db -> do @@ -3331,20 +3341,16 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag liftIO $ updateGroupMemberStatus db userId toMember newMemberStatus pure toMember subMode <- chatReadVar subscriptionMode - -- [incognito] send membership incognito profile, create direct connection as incognito + -- [incognito] send membership incognito profile membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability let enableNtfsGrp = chatHasNtfs chatSettings groupConnIds@(gCmdId, gAcId) <- prepareAgentJoin user Nothing enableNtfsGrp groupConnReq - directConnIds <- mapM (prepareAgentJoin user Nothing True) directConnReq - let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - mcvr = maybe chatInitialVRange fromChatVRange memChatVRange + let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange chatV = vr cxt `peerConnChatVersion` mcvr - withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode + withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds subMode joinAgentConnectionAsync gCmdId False gAcId enableNtfsGrp groupConnReq dm subMode - forM_ ((,) <$> directConnIds <*> directConnReq) $ \((dCmdId, dAcId), dcr) -> - joinAgentConnectionAsync dCmdId False dAcId True dcr dm subMode -- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted -- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one @@ -3927,7 +3933,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing xGrpMsgForward :: GroupInfoKeys -> Maybe GroupChatScopeInfo -> GroupMember -> GrpMsgForward -> ParsedMsg 'Json -> UTCTime -> CM () - xGrpMsgForward g@(GIK gInfo _) scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do + xGrpMsgForward g@(GIK gInfo@GroupInfo {membership} _) scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName) case fwdSender of FwdMember memberId memberName -> do @@ -3935,6 +3941,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag let allowCreate = toCMEventTag chatMsgEvent /= XGrpLeave_ withStore (\db -> getCreateUnknownGMByMemberId db cxt user gInfo memberId memberName unknownRole allowCreate) >>= \case Just (author, unknown) + | not (useRelays' gInfo) && groupMemberId' author == groupMemberId' membership -> + messageError $ "x.grp.msg.forward: content attributed to own membership, forwarder " <> tshow (groupMemberId' m) <> ", event " <> tshow (toCMEventTag chatMsgEvent) | memberRemoved author -> logInfo $ "x.grp.msg.forward: ignoring content from removed member, group " <> tshow (groupId' gInfo) <> ", member " <> safeDecodeUtf8 (strEncode memberId) <> ", event " <> tshow (toCMEventTag chatMsgEvent) | not (useRelays' gInfo) && not (expectedForwarder author) -> @@ -4150,7 +4158,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks + let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxForwardBatchLength nextTasks senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks withStore' $ \db -> do forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body @@ -4280,8 +4288,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do else do -- all members' profiles disseminate; privileged key/role come from the roster, not here let (encoderErrs, validLabeled) = partitionEithers [(\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s | (s, _) <- senders] - (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled - (overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled + (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxForwardBatchLength body validLabeled + (overflowBatches', large2) = batchProfiles maxForwardBatchLength overflowLabeled packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2] allErrs = encoderErrs <> packerErrs unless (null allErrs) $ do diff --git a/src/Simplex/Chat/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/Messages.hs b/src/Simplex/Chat/Messages.hs index 3c6dca2ac8..50035b55be 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -55,7 +55,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer, unMsgId) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection @@ -1228,7 +1228,7 @@ msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId { integrity = (decodeLatin1 . strEncode) integrity, rcvId, rcvTs, - serverId = (decodeLatin1 . B64.encode) serverId, + serverId = (decodeLatin1 . B64.encode . unMsgId) serverId, serverTs, sndId } diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index a216bbb1fc..9ec5d0f48d 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -525,6 +525,7 @@ data UserServersError | USEStorageMissing {protocol :: AProtocolType, user :: Maybe User} | USEProxyMissing {protocol :: AProtocolType, user :: Maybe User} | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost} + | USETooManyHosts {protocol :: AProtocolType, tooManyHostsServer :: Text} | USEDuplicateChatRelayAddress {duplicateChatRelay :: Text, duplicateAddress :: ShortLinkContact} deriving (Show) @@ -547,13 +548,16 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError] - serverErrs p uss = mapMaybe duplicateErr_ srvs + serverErrs p uss = mapMaybe duplicateErr_ srvs <> hostsErrs where p' = AProtocolType p srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) $ userServers p uss duplicateErr_ (AUS _ srv@UserServer {server}) = USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server) <$> find (`S.member` duplicateHosts) (srvHost srv) + hostsErrs = case p of + SPSMP -> [USETooManyHosts p' (safeDecodeUtf8 $ strEncode server) | AUS _ srv@UserServer {server} <- srvs, L.length (srvHost srv) > 2] + _ -> [] duplicateHosts = snd $ foldl' addDuplicate (S.empty, S.empty) allHosts allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 4671a927d1..01cb868e83 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} @@ -921,9 +924,17 @@ $(JQ.deriveJSON defaultJSON ''MsgContainer) maxEncodedMsgLength :: Int maxEncodedMsgLength = 15602 +maxForwardBatchLength :: Int +maxForwardBatchLength = maxEncodedMsgLength + 161 + +fwdMemberName :: ContactName -> ContactName +fwdMemberName displayName + | T.length displayName <= 16 = displayName + | otherwise = T.take 16 displayName `T.snoc` '…' + -- maxEncodedMsgLength - 2222, see e2eEncUserMsgLength in agent -maxCompressedMsgLength :: Int -maxCompressedMsgLength = 13380 +maxEncodedMsgLengthPQ :: Int +maxEncodedMsgLengthPQ = 13380 maxDecompressedMsgLength :: Int maxDecompressedMsgLength = 65536 @@ -932,6 +943,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 +973,8 @@ rosterBlobP = do maxEncodedInfoLength :: Int maxEncodedInfoLength = 14694 -maxCompressedInfoLength :: Int -maxCompressedInfoLength = 10968 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent +maxEncodedProfileMsgLength :: Int +maxEncodedProfileMsgLength = 16384 data EncodedChatMessage = ECMEncoded ByteString | ECMLarge @@ -1384,8 +1398,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 +1474,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 +1592,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/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index add8c64e7f..c80264da7a 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -156,7 +156,7 @@ getMsgDeliveryTask_ db taskId = toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, Binary msgBody, chatBinding_, sigs_, BI showGroupAsSender)) = case (toJobScope_ jobScopeRow, J.eitherDecodeStrict' msgBody) of (Just jobScope, Right chatMsg) -> - let fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId senderMemberName + let fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId (fwdMemberName senderMemberName) -- Re-parsed from msg_body: validates stored content against current code. -- Signed: original bytes preserved (re-encoding would invalidate signature). -- Unsigned: re-encoded from parsed ChatMessage on forward (sanitizes content). diff --git a/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..a376ee7ca5 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -2645,42 +2645,12 @@ createIntroReMemberConn liftIO $ setCommandConnId db user groupCmdId groupConnId pure (reMember :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () -createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do +createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> SubscriptionMode -> IO () +createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId} chatV mcvr (groupCmdId, groupAgentConnId) subMode = do let cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn currentTs <- getCurrentTime Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId chatV mcvr viaContactId cLevel currentTs subMode setCommandConnId db user groupCmdId groupConnId - forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId ConnNew chatV mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff - setCommandConnId db user directCmdId directConnId - contactId <- createMemberContact_ directConnId currentTs - updateMember_ contactId currentTs - where - createMemberContact_ :: Int64 -> UTCTime -> IO Int64 - createMemberContact_ connId ts = do - DB.execute - db - [sql| - INSERT INTO contacts (contact_profile_id local_display_name, user_id, created_at, updated_at, chat_ts) - SELECT contact_profile_id, ?, ?, ?, ?, ? - FROM group_members - WHERE group_member_id = ? - |] - (localDisplayName, userId, ts, ts, ts, groupMemberId) - contactId <- insertedRowId db - DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, ts, connId) - pure contactId - updateMember_ :: Int64 -> UTCTime -> IO () - updateMember_ contactId ts = - DB.execute - db - [sql| - UPDATE group_members - SET contact_id = ?, updated_at = ? - WHERE group_member_id = ? - |] - (contactId, ts, groupMemberId) createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = @@ -3105,10 +3075,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 +3382,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 +3473,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 +3493,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 +3634,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..3b52c46fef 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -44,6 +44,7 @@ module Simplex.Chat.Store.Profiles updateUserAutoAcceptMemberContacts, updateUserAutoAcceptGroupInvitations, updateUserProfile, + updatedUserProfile, setUserBadge, setUserProfileContactLink, getUserContactProfiles, @@ -77,6 +78,7 @@ module Simplex.Chat.Store.Profiles createCall, deleteCalls, getCalls, + expireCalls, createCommand, setCommandConnId, deleteCommand, @@ -103,6 +105,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 +129,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, (:.) (..)) @@ -167,7 +170,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing) + pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order, displayName) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing) -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -342,21 +345,15 @@ updateUserProfile db user p' currentTs <- getCurrentTime updateUserProfileFields_' db userId profileId p' currentTs userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs - pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {profile = updatedUserProfile user p' currentTs, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} | otherwise = - checkConstraint SEDuplicateName . liftIO $ do + checkConstraint SEDuplicateName . ExceptT . withLocalDisplayName db userId newName $ \newLDN -> do currentTs <- getCurrentTime - -- Insert first: checkConstraint returns the violation as a value, so the - -- transaction commits, keeping whatever ran before the failing insert. - DB.execute - db - "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (newName, newName, userId, currentTs, currentTs) - DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) + DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newLDN, currentTs, userId) userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs updateUserProfileFields_' db userId profileId p' currentTs - updateContactLDN_ db user userContactId localDisplayName newName currentTs - pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + updateContactLDN_ db user userContactId localDisplayName newLDN currentTs + pure $ Right user {localDisplayName = newLDN, profile = updatedUserProfile user p' currentTs, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs | userMemberProfileChanged = do @@ -364,10 +361,14 @@ updateUserProfile db user p' pure $ Just currentTs | otherwise = pure userMemberProfileUpdatedAt userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || desc' /= description || img' /= image - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, localBadge, localAlias}, userMemberProfileUpdatedAt} = user + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image}, userMemberProfileUpdatedAt} = user Profile {displayName = newName, fullName = fn', shortDescr = d', description = desc', image = img', preferences} = p' fullPreferences = fullPreferences' preferences +updatedUserProfile :: User -> Profile -> UTCTime -> LocalProfile +updatedUserProfile User {profile = LocalProfile {profileId, localBadge, localAlias}} p' ts = + (toLocalProfile profileId p' localAlias ts (Just False) Nothing) {localBadge} + -- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge) updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType} updatedAt = @@ -409,19 +410,20 @@ setUserSimplexDomain db user@User {userId, profile = p@LocalProfile {profileId}} pure (user :: User) {profile = p {contactDomain = mkDomainClaim <$> domain_}} setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User -setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do +setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId, contactDomain}} ucl_ = do ts <- getCurrentTime DB.execute db [sql| UPDATE contact_profiles - SET contact_link = ?, updated_at = ? + SET contact_link = ?, contact_domain = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? |] - (contactLink, ts, userId, profileId) - pure (user :: User) {profile = p {contactLink}} + (contactLink, claimDomain <$> contactDomain', ts, userId, profileId) + pure (user :: User) {profile = p {contactLink, contactDomain = contactDomain'}} where contactLink = profileContactLink <$> ucl_ + contactDomain' = if isJust contactLink then contactDomain else Nothing -- only used in tests getUserContactProfiles :: DB.Connection -> User -> IO [Profile] @@ -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..00886165b5 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=?) @@ -7378,6 +7390,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 @@ -7454,9 +7470,9 @@ Query: SELECT connection_id FROM connections WHERE user_id = ? AND group_member_ Plan: SEARCH connections USING INDEX idx_connections_group_member_id (group_member_id=?) -Query: SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND (chat_item_ttl > 0 OR chat_item_ttl IS NULL) Plan: -SCAN contacts +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) Query: SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0 Plan: @@ -7486,6 +7502,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>?) @@ -7548,9 +7568,9 @@ Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMI Plan: SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) -Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Query: SELECT group_id FROM groups WHERE user_id = ? AND (chat_item_ttl > 0 OR chat_item_ttl IS NULL) Plan: -SCAN groups +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ? Plan: @@ -7588,6 +7608,10 @@ Query: SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1 Plan: SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) +Query: SELECT index_in_group, member_relations_vector FROM group_members WHERE local_display_name = ? +Plan: +SCAN group_members + Query: SELECT issue_failed_since, issue_error FROM badge_purchases Plan: SCAN badge_purchases @@ -7608,6 +7632,10 @@ Query: SELECT member_id FROM group_members WHERE local_display_name = ? Plan: SCAN group_members +Query: SELECT member_id FROM group_members WHERE member_category = ? +Plan: +SCAN group_members + Query: SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1 Plan: SCAN group_members @@ -7668,6 +7696,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=?) @@ -7676,6 +7708,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=?) @@ -7736,6 +7772,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=?) @@ -7792,6 +7832,10 @@ Query: UPDATE badge_purchases SET next_wake_at = ? WHERE badge_purchase_id = ? Plan: SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE chat_items SET item_content = ?, item_text = ?, updated_at = ? WHERE chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET item_msg_body = ?, item_chat_binding = ?, item_signatures = ?, item_signed_by_group_member_id = ? WHERE chat_item_id = ? AND include_in_history = 1 Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) @@ -7868,6 +7912,10 @@ Query: UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user Plan: SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE contact_profiles SET contact_domain_verified = NULL WHERE user_id = ? AND contact_profile_id = ? +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET image = ? WHERE display_name = ? Plan: SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) @@ -8060,6 +8108,10 @@ Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE g Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_profiles SET description = ? WHERE display_name = ? +Plan: +SCAN group_profiles + Query: UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ? Plan: SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c3d42de3d1..908d5a823b 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -74,6 +74,7 @@ data ChatLockEntity | CLContactRequest Int64 | CLFile Int64 | CLBadgeUser Int64 -- one signed badge request per profile in flight + | CLUserProfile deriving (Eq, Ord) -- These error type constructors must be added to mobile apps @@ -558,7 +559,7 @@ toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, co userQuery :: Query userQuery = [sql| - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u @@ -566,9 +567,9 @@ userQuery = JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User -toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInv, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} +toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User +toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, localDisplayName) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInv, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 282d6b2fda..cea169a4a4 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 @@ -1078,8 +1085,7 @@ data MemberIdRole = MemberIdRole deriving (Eq, Show) data IntroInvitation = IntroInvitation - { groupConnReq :: ConnReqInvitation, - directConnReq :: Maybe ConnReqInvitation + { groupConnReq :: ConnReqInvitation } deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1c62bc267b..aa509d49bf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1961,6 +1961,7 @@ viewRcvQueuesInfo = plain . T.intercalate ", " . map showQueueInfo RSSendingQADD -> "switch started" RSSendingQUSE -> "switch confirmed" RSReceivedMessage -> "switch secured" + RSReceivedQEND -> "switch completed" viewSndQueuesInfo :: [SndQueueInfo] -> StyledString viewSndQueuesInfo = plain . T.intercalate ", " . map showQueueInfo 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/src/Simplex/Chat/protocol.md b/src/Simplex/Chat/protocol.md index 33161a45a7..06b34d92eb 100644 --- a/src/Simplex/Chat/protocol.md +++ b/src/Simplex/Chat/protocol.md @@ -197,8 +197,7 @@ Events: { "memberId": "", "memberIntro": { - "groupConnReq": "", - "directConnReq": "" + "groupConnReq": "" } } @@ -214,8 +213,7 @@ Events: }, }, "memberIntro": { - "groupConnReq": "", - "directConnReq": "" + "groupConnReq": "" } } diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index bd8f62d3fd..ced51a5bbf 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -51,6 +51,7 @@ badgeTests = do it "should accept unknown badge types" testUnknownBadgeType it "credential serializes to a paste-able token and back" testCredentialSerialization it "presentation headers encode and decode" testPresHeaderEncoding + it "presentation headers with binary bytes round-trip through JSON" testPresHeaderJSON it "should reject a proof presented under another chat binding" testOtherChatBinding describe "redemption codes" $ do it "a generated code reads back" testCodeRoundTrip @@ -209,6 +210,21 @@ testPresHeaderEncoding = PHUnknown 'Z' "payload" ] +testPresHeaderJSON :: IO () +testPresHeaderJSON = + mapM_ + ( \ph -> do + J.eitherDecode (J.encode ph) `shouldBe` Right ph + J.toJSON ph `shouldBe` J.toJSON (BBSPresHeader $ strEncode ph) + ) + [ PHChat binaryBytes, + PHFileInv {chatBinding = binaryBytes, fileSize = 139737}, + PHFileDescr {chatBinding = binaryBytes, fileSize = 139737, descrHash = binaryBytes, fileExpires = Just futureTime}, + PHUnknown 'Z' binaryBytes + ] + where + binaryBytes = "\0\1\31\34\92\127\128\195\255" + testOtherChatBinding :: IO () testOtherChatBinding = do let ph = PHFileInv {chatBinding = aliceBinding, fileSize = 139737} diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index c11af7f4d1..129824fed8 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -10,18 +10,22 @@ module Bots.BadgeService.BotTests where +import BadgeService.Codes (issueOneCode) import BadgeService.Config (BadgeIssuerKey (..), readServiceConfig) +import BadgeService.Group (GroupEvent) import Bots.BadgeService.ConfigTests (withIssuer) import BadgeService.Options import BadgeService.Service +import BadgeService.Store (IssuedCode (..), getBadgeCode) import BadgeService.Store.Invoices (markCodePaid) import Simplex.Messaging.Agent.Store.DB (Binary (..)) 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 @@ -43,14 +47,16 @@ 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, 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.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 @@ -76,20 +82,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 @@ -107,8 +120,11 @@ badgeServiceTests = do it "should broadcast the current profile when a renewal presents a badge" testRenewalKeepsProfileEdits it "should present the month already issued when a previous pass did not" testPresentationCatchesUp +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" @@ -117,19 +133,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, @@ -137,6 +153,8 @@ mkBadgeServiceOpts TestParams {tmpPath = ps} secretKey = issuerKey = Right (Just BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey}), testing = True } + where + (_, ChatOpts {coreOptions = coreOpts}) = testPortsCfg ps testCfg testOpts -- | The clock tracks real time plus a test-controlled offset rather than freezing it, so a sleeping worker still waits the correct real duration. newtype TestClock = TestClock (IORef NominalDiffTime) @@ -176,45 +194,56 @@ withBadgeServiceEnv ps test = do let opts = mkBadgeServiceOpts ps sk svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk, badgeCurrentTime = testClockTime clock} withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () - runBadgeService svcCfg opts $ \_ -> pure () + -- First start: badge service takes the CreateMyAddress branch. + runBadgeService ps svcCfg opts $ \_ -> pure () + -- Reopen the DB to read the link the service created. bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do bs <## "subscribed 1 connections on server localhost" bs ##> "/sa" - (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 svcCfg opts $ \env -> do + -- Second start: badge service takes the ShowMyAddress branch, then serves the test body. + runBadgeService ps svcCfg opts $ \env -> do cc <- atomically $ readTMVar $ serviceCC env test BadgeServiceEnv {bsIssuerKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}, bsClock = clock, bsClientCfg = clientCfg, bsAddress = bsLink, bsController = cc} 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 :: ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () -runBadgeService cfg opts action = do +-- | The post-start hook fills serviceCC once the address exists, so waiting on it is the service +-- being ready. A fixed delay here raced with startup and left the address output of one start +-- arriving during the next test. +runBadgeService :: TestParams -> ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () +runBadgeService ps cfg opts action = do env <- newServiceState - t <- forkIO $ badgeService opts cfg env + t <- async $ badgeService opts (fst $ testPortsCfg ps cfg testOpts) env ready <- timeout 30000000 $ atomically $ readTMVar $ serviceCC env - when (isNothing ready) $ killThread t >> error "badge service did not start" - action env `finally` killThread t + when (isNothing ready) $ cancel t >> error "badge service did not start" + action env `finally` (cancel t >> atomically (readTMVar $ serviceCC env) >>= disposeAgentClient . smpAgent) codeArg :: BadgeCode -> String codeArg = T.unpack . formatBadgeCode @@ -313,11 +342,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 = @@ -359,12 +390,16 @@ testRedeemSameCodeOtherProfile ps = showActiveUser alice "alice (Alice, * supporter)" serviceCmd :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse -serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} purchaseKey request = - badgeServiceResponse bsIssuerKey bsController (Just purchaseKey) reqObject - 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" +serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} = serviceCmdWith bsIssuerKey bsController Nothing + +serviceCmdWith :: HasCallStack => BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse +serviceCmdWith key cc trackerQ_ purchaseKey request = + badgeServiceResponse key 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) @@ -387,6 +422,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 @@ -396,6 +442,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}} @@ -403,6 +460,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 @@ -410,8 +470,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"] @@ -435,19 +495,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))) @@ -461,8 +584,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) @@ -475,8 +598,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) @@ -486,14 +609,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)] @@ -660,6 +799,20 @@ redeemFirstBadge alice code = do alice <## "badge redeemed" alice <## "supporter badge - active" alice <##. "expires " + waitWakeArmed (chatController alice) + +waitWakeArmed :: HasCallStack => ChatController -> IO () +waitWakeArmed ChatController {chatStore} = loop (100 :: Int) + where + loop i = do + rows :: [(Maybe UTCTime, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT next_wake_at, badge_purchase_id FROM badge_purchases" + case rows of + [(Just _, _)] -> pure () + _ + | i == 0 -> error $ "expected one badge purchase with a wake, got " <> show rows + | otherwise -> threadDelay 50000 >> loop (i - 1) testWorkerRenews :: HasCallStack => TestParams -> IO () testWorkerRenews ps = @@ -697,6 +850,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} -> @@ -788,11 +1013,14 @@ testWorkerRetiresExpired ps = bob <## currentChatVRangeInfo rows <- ledgerRows (chatController alice) "badge_ledger" setClockAt bsClock $ dueAtOf rows - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "badge alert: support_ended " + ] waitShownBadge (chatController alice) Nothing alice ##> "/p" alice <## "user profile: alice (Alice)" @@ -834,11 +1062,14 @@ testEndedAlert ps = rows <- ledgerRows (chatController alice) "badge_ledger" let endsAt = dueAtOf rows setClockAt bsClock endsAt - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "badge alert: support_ended " + ] pure endsAt withTestChatCfg ps bsClientCfg "alice" $ \alice -> do alice <##. "badge alert: support_ended " @@ -987,9 +1218,7 @@ testNoCredentialMonthsRanOut ps = rows' <- ledgerRows (chatController alice) "badge_ledger" map (\(_, ch, m, _, _, t) -> (ch, m, t)) rows' `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-2, 0, Just "support")] alice ##> "/_app activate" - alice <## "ok" - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice <### ["ok", StartsWith "1: supporter", StartsWith "badge alert: support_ended "] waitShownBadge (chatController alice) Nothing -- The service issuing nothing while the ledger still owes a month is a fault the client cannot @@ -1116,20 +1345,22 @@ testSnoozedAlertReturns ps = rows <- ledgerRows (chatController alice) "badge_ledger" let endsAt = dueAtOf rows setClockAt bsClock endsAt - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " - alice <##. "1: supporter" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice + <### [ "/_app activate", + "ok", + StartsWith "badge alert: support_ended ", + StartsWith "1: supporter", + StartsWith "badge alert: support_ended " + ] alice ##> ("/_badge ack 1 1 support_ended on " <> T.unpack (safeDecodeUtf8 $ strEncode endsAt)) alice <##. "1: supporter" alice ##> "/p" alice <## "user profile: alice (Alice)" alice <## "use /p [] to change it" setClockAt bsClock $ addUTCTime (nominalDay + 60) endsAt - alice ##> "/_app activate" - alice <## "ok" - alice <##. "badge alert: support_ended " + alice `send` "/_app activate" + alice <### ["/_app activate", "ok", StartsWith "badge alert: support_ended "] testRenewalKeepsProfileEdits :: HasCallStack => TestParams -> IO () testRenewalKeepsProfileEdits ps = @@ -1319,7 +1550,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" @@ -1329,15 +1560,34 @@ 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 redeem badge code: 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 " diff --git a/tests/Bots/BadgeService/ConfigTests.hs b/tests/Bots/BadgeService/ConfigTests.hs index d27698622d..7d5c5c9a7f 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,16 +47,17 @@ 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 + 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", @@ -224,19 +226,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 () @@ -252,7 +260,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 = @@ -345,25 +353,58 @@ 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 +parseIni :: T.Text -> IO (Either String ServiceConfig) +parseIni t = withIni t readServiceConfig -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) +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 -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) +listenerIni :: T.Text +listenerIni = "[listener]\nstatic_dir = /srv/web\n" -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" +groupIni :: T.Text -> IO (Either String ServiceConfig) +groupIni body = parseIni (listenerIni <> "[group]\n" <> body) -withDev :: T.Text -> (Either String ServiceConfig -> IO a) -> IO a -withDev keys act = withIni (fullIni <> "[dev]\n" <> keys) $ \p -> readServiceConfig p >>= act +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 dd21c820dd..15203c75ac 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 (CodeRedemption (..), 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 = CodeUnredeemed} -> 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 } testServeWebappOff :: IO () @@ -1289,11 +1467,17 @@ testHoldReportsTheRowNotTheWake = bounded "hold reports the row" $ withWebApp $ testHold :: Int testHold = 2000000 +awaitWaitingCount :: WebEnv -> Int -> IO () +awaitWaitingCount env n = do + parked <- waitingCount (weWaiters env) + when (parked /= n) $ threadDelay 10000 >> awaitWaitingCount env n + testHoldTimeoutReportsAnUnpublishedChange :: IO () testHoldTimeoutReportsAnUnpublishedChange = bounded "hold timeout" $ withWebAppHolding testHold $ \env client -> do iid <- seedOpenInvoice env started <- getCurrentTime held <- async $ webGet client (invoicePath iid <> "?wait=open") + awaitWaitingCount env 1 threadDelay 50000 expireRow (weStore env) iid r <- wait held @@ -2355,7 +2539,7 @@ testCadenceFollowsTheWaiters = bounded "cadence" $ withStubPoller raceHold $ \_ waitingCount (weWaiters env) `shouldReturn` 0 passDelayNow poller `shouldReturn` (pIdleSeconds * 1000000) held <- async $ webGet client (invoicePath iid <> "?wait=open") - threadDelay 100000 + awaitWaitingCount env 1 passDelayNow poller `shouldReturn` (pWaitingSeconds * 1000000) waitingCount (weWaiters env) `shouldReturn` 1 markPaidAndPublish env iid @@ -2484,9 +2668,13 @@ seedPastWindow st i providerRef provider = do passListing :: IORef StubState -> PollerEnv -> IO () passListing ref poller = runOnePass poller >> clearCalls ref +stderrCaptureLock :: MVar () +stderrCaptureLock = unsafePerformIO $ newMVar () +{-# NOINLINE stderrCaptureLock #-} + -- hDuplicateTo changes stderr's buffering, so put the original setting back. capturingStderr :: IO () -> IO Text -capturingStderr action = do +capturingStderr action = withMVar stderrCaptureLock $ \_ -> do createDirectoryIfMissing True "tests/tmp" withTempDirectory "tests/tmp" "badge-stderr" $ \dir -> do let path = dir "stderr.log" @@ -2497,7 +2685,7 @@ capturingStderr action = do T.readFile path loggedError :: Text -> Text -> Bool -loggedError message = any (\l -> "[ERROR " `T.isPrefixOf` l && message `T.isInfixOf` l) . T.lines +loggedError message = any (\l -> "[ERROR " `T.isInfixOf` l && message `T.isInfixOf` l) . T.lines failCancels :: IORef StubState -> IO () failCancels ref = atomicModifyIORef' ref $ \s -> (s {ssCancelError = Just (ProviderError "refused")}, ()) @@ -2665,13 +2853,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. @@ -3284,7 +3478,7 @@ testHeldWaitWakesOnAPaymentThatDoesNotSettle :: IO () testHeldWaitWakesOnAPaymentThatDoesNotSettle = bounded "funded wakes a hold" $ withCheckout $ \_ env client -> do iid <- seedOpenInvoice env held <- async $ webGet client (invoicePath iid <> "?wait=open") - threadDelay 100000 + awaitWaitingCount env 1 waitingCount (weWaiters env) `shouldReturn` 1 settleOrder (weStore env) (weWaiters env) iid (SigFunded (rcv 500 (Just "0.00050000")) PaidInPart) someCreated `shouldReturn` Right ISOpen diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 9edbb3cb73..1def872096 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -14,7 +14,7 @@ import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (bracket) import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Core -import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import Test.Hspec hiding (it) @@ -26,11 +26,11 @@ broadcastBotTests :: SpecWith TestParams broadcastBotTests = do it "should broadcast message" testBroadcastMessages -withBroadcastBot :: BroadcastBotOpts -> IO () -> IO () -withBroadcastBot opts test = +withBroadcastBot :: TestParams -> BroadcastBotOpts -> IO () -> IO () +withBroadcastBot ps opts test = bracket (forkIO bot) killThread (\_ -> threadDelay 500000 >> test) where - bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts + bot = simplexChatCore (fst $ testPortsCfg ps testCfg testOpts) (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} @@ -39,11 +39,11 @@ mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts mkBotOpts ps publishers = BroadcastBotOpts { coreOptions = - testCoreOpts + coreOpts { dbOptions = (dbOptions testCoreOpts) #if defined(dbPostgres) - {dbSchemaPrefix = "client_" <> botDbPrefix} + {dbSchemaPrefix = testSchemaPrefix ps botDbPrefix} #else {dbFilePrefix = tmpPath ps botDbPrefix} #endif @@ -54,6 +54,8 @@ mkBotOpts ps publishers = welcomeMessage = defaultWelcomeMessage publishers, prohibitedMessage = defaultWelcomeMessage publishers } + where + (_, ChatOpts {coreOptions = coreOpts}) = testPortsCfg ps testCfg testOpts botDbPrefix :: FilePath botDbPrefix = "broadcast_bot" @@ -67,7 +69,7 @@ testBroadcastMessages ps = do bc_bot ##> "/ad" getContactLink bc_bot True let botOpts = mkBotOpts ps [KnownContact 2 "alice"] - withBroadcastBot botOpts $ + withBroadcastBot ps botOpts $ withTestChat ps "alice" $ \alice -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 74688e9b4f..22d73f72ea 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -10,7 +10,9 @@ import ChatClient import ChatTests.DBUtils import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils -import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (async, cancel) +import Control.Concurrent.STM (atomically, peekTQueue, tryReadTMVar) import Control.Exception (finally) import Control.Monad (forM_, when, void) import qualified Data.Aeson as J @@ -21,17 +23,19 @@ import Directory.Options import Directory.Service import System.Directory (emptyPermissions, setOwnerExecutable, setOwnerReadable, setOwnerWritable, setPermissions) import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (smpAgent)) import qualified Simplex.Chat.Markdown as MD -import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (memberSupportVoiceVersion) import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Version import NameResolver import System.FilePath (()) +import System.Timeout (timeout) import Test.Hspec hiding (it) directoryServiceTests :: SpecWith TestParams @@ -113,16 +117,16 @@ directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts -mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = +mkDirectoryOpts ps superUsers ownersGroup webFolder = DirectoryOpts { coreOptions = - testCoreOpts + coreOpts { dbOptions = (dbOptions testCoreOpts) #if defined(dbPostgres) - {dbSchemaPrefix = "client_" <> serviceDbPrefix} + {dbSchemaPrefix = testSchemaPrefix ps serviceDbPrefix} #else - {dbFilePrefix = ps serviceDbPrefix} + {dbFilePrefix = tmpPath ps serviceDbPrefix} #endif }, @@ -149,6 +153,8 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = knocking = False, testing = True } + where + (_, ChatOpts {coreOptions = coreOpts}) = testPortsCfg ps testCfg testOpts serviceDbPrefix :: FilePath serviceDbPrefix = "directory_service" @@ -175,16 +181,18 @@ testDirectoryService ps = bob ##> "/mr PSA 'SimpleX Directory' admin" -- putStrLn "*** discover service joins group and sends the registration for approval" bob <## "#PSA: you changed the role of 'SimpleX Directory' to admin" - bob <# "'SimpleX Directory'> Joining the group PSA…" - bob <## "#PSA: 'SimpleX Directory' joined the group" + bob + <### [ WithTime "'SimpleX Directory'> Joining the group PSA…", + "#PSA: 'SimpleX Directory' joined the group" + ] bob <# "'SimpleX Directory'> Joined the group PSA. Registration is pending approval — it may take up to 48 hours." bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." bob <## "Captcha verification is enabled. Use /'filter 1' to change it." notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" Nothing 1 1 -- putStrLn "*** update profile before approval - new approval code" updateGroupProfile bob "Welcome!" - groupUpdatedHidden superUser bob "PSA" "" - notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" (Just "Welcome!") 1 2 + groupUpdatedHidden superUser bob "PSA" "" $ + notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" (Just "Welcome!") 1 2 -- putStrLn "*** try approving with the old registration code" bob #> "@'SimpleX Directory' /approve 1:PSA 1" bob <# "'SimpleX Directory'> > /approve 1:PSA 1" @@ -470,8 +478,8 @@ testSearchByLink ps = memberGroupListing superUser bob 1 "privacy" "Privacy" 2 "active" -- content change hides the group from user search, admin still finds it by link setWelcomeMessage bob [] "Welcome!" - groupUpdatedHidden superUser bob "privacy" "" - notifySuperUser_ superUser bob "privacy" "Privacy" (Just "Welcome!") 1 1 + groupUpdatedHidden superUser bob "privacy" "" $ + notifySuperUser_ superUser bob "privacy" "Privacy" (Just "Welcome!") 1 1 bob #> ("@'SimpleX Directory' " <> link) bob <# ("'SimpleX Directory'> > " <> link) bob <## " No groups found." @@ -624,9 +632,11 @@ testInviteOwnerAfterLeavingOwnersGroup ps = superUser <## "#owners: new member bob is connected" -- owner leaves owners' group; GroupMember row keeps status GSMemLeft leaveGroup "owners" bob - superUser <## "#owners: bob left the group (signed)" -- owners' group has no GroupReg, so directory service notifies admins on contact left - superUser <# "'SimpleX Directory'> Error: contact left, group: 1 owners, group registration not found" + superUser + <### [ "#owners: bob left the group (signed)", + WithTime "'SimpleX Directory'> Error: contact left, group: 1 owners, group registration not found" + ] -- super-user re-invites via /invite — must send a fresh invitation, not "already a member" superUser #> "@'SimpleX Directory' /invite 2:privacy" superUser <# "'SimpleX Directory'> > /invite 2:privacy" @@ -648,9 +658,7 @@ testDelistedOwnerLeaves ps = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group owner left)." - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupNotFound cath "privacy" testDelistedOwnerRemoved :: HasCallStack => TestParams -> IO () @@ -661,14 +669,17 @@ testDelistedOwnerRemoved ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - removeMember "privacy" cath bob - bob <# "'SimpleX Directory'> You are removed from the group ID 1 (privacy)." - bob <## "" - bob <## "The group is no longer listed in the directory." + cath ##> "/rm privacy bob" + cath <## "#privacy: you removed bob from the group (signed)" + bob + <### [ "#privacy: cath removed you from the group (signed)", + "use /d #privacy to delete the group", + WithTime "'SimpleX Directory'> You are removed from the group ID 1 (privacy).", + "", + "The group is no longer listed in the directory." + ] superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group owner is removed)." - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupNotFound cath "privacy" testNotDelistedMemberLeaves :: HasCallStack => TestParams -> IO () @@ -800,9 +811,7 @@ testDelistedRoleChanges ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupFoundN 3 cath "privacy" -- de-listed if service role changed bob ##> "/mr privacy 'SimpleX Directory' member" @@ -816,28 +825,34 @@ testDelistedRoleChanges ps = -- re-listed if service role changed back without profile changes cath ##> "/mr privacy 'SimpleX Directory' admin" cath <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)" - bob <## "#privacy: cath changed the role of 'SimpleX Directory' from member to admin (signed)" - bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." - bob <## "" - bob <## "The group is listed in the directory again." + bob + <### [ "#privacy: cath changed the role of 'SimpleX Directory' from member to admin (signed)", + WithTime "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin.", + "", + "The group is listed in the directory again." + ] superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is listed (SimpleX Directory role is changed to admin)." groupFoundN 3 cath "privacy" -- de-listed if owner role changed cath ##> "/mr privacy bob admin" cath <## "#privacy: you changed the role of bob to admin (signed)" - bob <## "#privacy: cath changed your role from owner to admin (signed)" - bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to admin." - bob <## "" - bob <## "The group is no longer listed in the directory." + bob + <### [ "#privacy: cath changed your role from owner to admin (signed)", + WithTime "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to admin.", + "", + "The group is no longer listed in the directory." + ] superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (user role is set to admin)." groupNotFound cath "privacy" -- re-listed if owner role changed back without profile changes cath ##> "/mr privacy bob owner" cath <## "#privacy: you changed the role of bob to owner (signed)" - bob <## "#privacy: cath changed your role from admin to owner (signed)" - bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to owner." - bob <## "" - bob <## "The group is listed in the directory again." + bob + <### [ "#privacy: cath changed your role from admin to owner (signed)", + WithTime "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to owner.", + "", + "The group is listed in the directory again." + ] superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is listed (user role is set to owner)." groupFoundN 3 cath "privacy" @@ -849,9 +864,7 @@ testNotDelistedMemberRoleChanged ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" bob <## "#privacy: you changed the role of cath to member (signed)" @@ -872,7 +885,7 @@ testNotSentApprovalBadRoles ps = bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)" bob ##> "/gp privacy privacy Privacy!" bob <## "description changed to: Privacy!" - groupUpdatedHidden superUser bob "privacy" "" + groupUpdatedHidden superUser bob "privacy" "" $ pure () bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" bob ##> "/mr privacy 'SimpleX Directory' admin" bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)" @@ -896,6 +909,7 @@ testNotApprovedBadRoles ps = notifySuperUser superUser bob "privacy" "Privacy" 1 bob ##> "/mr privacy 'SimpleX Directory' member" bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)" + serviceRoleIs superUser "member" let approve = "/approve 1:privacy 1" superUser #> ("@'SimpleX Directory' " <> approve) superUser <# ("'SimpleX Directory'> > " <> approve) @@ -909,6 +923,16 @@ testNotApprovedBadRoles ps = notifySuperUser superUser bob "privacy" "Privacy" 1 void $ approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" + where + serviceRoleIs superUser expectedRole = go (50 :: Int) + where + go n = do + superUser #> "@'SimpleX Directory' /x /ms privacy" + superUser <# "'SimpleX Directory'> > /x /ms privacy" + l <- getTermLine superUser + superUser <## "bob (Bob): owner, host, connected" + let expected = " 'SimpleX Directory': " <> expectedRole <> ", you, connected" + if l == expected || n == 0 then l `shouldBe` expected else threadDelay 100000 >> go (n - 1) testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () testRegOwnerChangedProfile ps = @@ -924,12 +948,9 @@ testRegOwnerChangedProfile ps = bob <## "It is hidden from the directory until approved." cath <## "bob updated group #privacy: (signed)" cath <## "description changed to: Privacy and Security" - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupNotFound cath "privacy" - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated." - reapproveGroup 3 superUser bob + reapproveGroup 3 superUser bob "" groupFoundN 3 cath "privacy" testAnotherOwnerChangedProfile :: HasCallStack => TestParams -> IO () @@ -940,18 +961,17 @@ testAnotherOwnerChangedProfile ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" cath ##> "/gp privacy privacy Privacy and Security" cath <## "description changed to: Privacy and Security" - bob <## "cath updated group #privacy: (signed)" - bob <## "description changed to: Privacy and Security" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!" - bob <## "It is hidden from the directory until approved." + bob + <### [ "cath updated group #privacy: (signed)", + "description changed to: Privacy and Security", + WithTime "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!", + "It is hidden from the directory until approved." + ] groupNotFound cath "privacy" - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." - reapproveGroup 3 superUser bob + reapproveGroup 3 superUser bob " by cath" groupFoundN 3 cath "privacy" testNotConnectedOwnerChangedProfile :: HasCallStack => TestParams -> IO () @@ -966,13 +986,14 @@ testNotConnectedOwnerChangedProfile ps = addCathAsOwner bob cath cath ##> "/gp privacy privacy Privacy and Security" cath <## "description changed to: Privacy and Security" - bob <## "cath updated group #privacy: (signed)" - bob <## "description changed to: Privacy and Security" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!" - bob <## "It is hidden from the directory until approved." + bob + <### [ "cath updated group #privacy: (signed)", + "description changed to: Privacy and Security", + WithTime "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!", + "It is hidden from the directory until approved." + ] groupNotFound dan "privacy" - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." - reapproveGroup 3 superUser bob + reapproveGroup 3 superUser bob " by cath" groupFoundN 3 dan "privacy" testRegOwnerRemovedLink :: HasCallStack => TestParams -> IO () @@ -985,8 +1006,8 @@ testRegOwnerRemovedLink ps = addCathAsOwner bob cath -- setting the welcome message requires re-approval setWelcomeMessage bob [cath] "Welcome!" - groupUpdatedHidden superUser bob "privacy" "" - reapproveGroup_ 3 superUser bob (Just "Welcome!") + groupUpdatedHidden superUser bob "privacy" "" $ reapprovalRequested 3 superUser (Just "Welcome!") + void $ approveRegistration_ superUser bob "privacy" 1 1 1 -- adding the link keeps the group listed gLink <- getGroupLinkFromBot bob setWelcomeMessage bob [cath] ("Welcome! Link to join the group privacy: " <> gLink) @@ -994,9 +1015,7 @@ testRegOwnerRemovedLink ps = -- removing the link keeps the group listed setWelcomeMessage bob [cath] "Welcome!" groupUpdatedListed superUser bob "privacy" "" - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" groupFoundWelcome 3 cath "privacy" "Welcome!" testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () @@ -1007,20 +1026,20 @@ testAnotherOwnerRemovedLink ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - cath `connectVia` dsLink - cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" - cath <## "use @'SimpleX Directory' to send messages" + connectViaMerged cath dsLink "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" -- setting the welcome message requires re-approval - setWelcomeMessage cath [bob] "Welcome!" - groupUpdatedHidden superUser bob "privacy" " by cath" - reapproveGroup_ 3 superUser bob (Just "Welcome!") + welcomeUpdatedBy bob cath "Welcome!" "It is hidden from the directory until approved." + noticeAndRequest superUser "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." $ reapprovalRequested 3 superUser (Just "Welcome!") + void $ approveRegistration_ superUser bob "privacy" 1 1 1 -- another owner adds the link - the group remains listed gLink <- getGroupLinkFromBot bob - setWelcomeMessage cath [bob] ("Welcome! Link to join the group privacy: " <> gLink) - groupUpdatedListed superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath ("Welcome! Link to join the group privacy: " <> gLink) "The group is listed in directory." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath - only link or whitespace changes." + superUser <## "The group remained listed in directory." -- another owner removes the link - the group remains listed - setWelcomeMessage cath [bob] "Welcome!" - groupUpdatedListed superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath "Welcome!" "The group is listed in directory." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath - only link or whitespace changes." + superUser <## "The group remained listed in directory." groupFoundWelcome 3 cath "privacy" "Welcome!" testNotConnectedOwnerRemovedLink :: HasCallStack => TestParams -> IO () @@ -1034,17 +1053,19 @@ testNotConnectedOwnerRemovedLink ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath -- setting the welcome message requires re-approval - setWelcomeMessage cath [bob] "Welcome!" - groupUpdatedHidden superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath "Welcome!" "It is hidden from the directory until approved." + noticeAndRequest superUser "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." $ reapprovalRequested 3 superUser (Just "Welcome!") groupNotFound dan "privacy" - reapproveGroup_ 3 superUser bob (Just "Welcome!") + void $ approveRegistration_ superUser bob "privacy" 1 1 1 -- the not connected owner adds the link - the group remains listed gLink <- getGroupLinkFromBot bob - setWelcomeMessage cath [bob] ("Welcome! Link to join the group privacy: " <> gLink) - groupUpdatedListed superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath ("Welcome! Link to join the group privacy: " <> gLink) "The group is listed in directory." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath - only link or whitespace changes." + superUser <## "The group remained listed in directory." -- the not connected owner removes the link - the group remains listed - setWelcomeMessage cath [bob] "Welcome!" - groupUpdatedListed superUser bob "privacy" " by cath" + welcomeUpdatedBy bob cath "Welcome!" "The group is listed in directory." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath - only link or whitespace changes." + superUser <## "The group remained listed in directory." groupFoundWelcome 3 dan "privacy" "Welcome!" testDuplicateAskConfirmation :: HasCallStack => TestParams -> IO () @@ -1123,8 +1144,8 @@ testDuplicateProhibitWhenUpdated ps = cath <## "changed to #security (Security)" cath <# "'SimpleX Directory'> The group ID 1 (security) is updated!" cath <## "It is hidden from the directory until approved." - superUser <# "'SimpleX Directory'> The group ID 2 (security) is updated." - notifySuperUser_ superUser cath "security" "Security" Nothing 2 2 + noticeAndRequest superUser "'SimpleX Directory'> The group ID 2 (security) is updated." $ + notifySuperUser_ superUser cath "security" "Security" Nothing 2 2 void $ approveRegistration_ superUser cath "security" 2 1 2 groupFound bob "security" groupFound cath "security" @@ -1157,13 +1178,13 @@ testDuplicateProhibitApproval ps = testListUserGroups :: HasCallStack => Bool -> TestParams -> IO () testListUserGroups promote ps = - withDirectoryServiceCfgOwnersGroup ps testCfg False (Just "./tests/tmp/web") $ \superUser dsLink -> + withDirectoryServiceCfgOwnersGroup ps testCfg False (Just webDir) $ \superUser dsLink -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" - checkListings ["privacy"] [] + checkListings webDir ["privacy"] [] connectUsers bob cath fullAddMember "privacy" "Privacy" bob cath GRMember joinGroup "privacy" cath bob @@ -1171,9 +1192,9 @@ testListUserGroups promote ps = cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'" cath <## "use @'SimpleX Directory' to send messages" registerGroupId superUser bob "security" "Security" 2 2 - checkListings ["privacy", "security"] [] + checkListings webDir ["privacy", "security"] [] registerGroupId superUser cath "anonymity" "Anonymity" 3 1 - checkListings ["privacy", "security", "anonymity"] [] + checkListings webDir ["privacy", "security", "anonymity"] [] listUserGroup cath "anonymity" "Anonymity" -- with de-listed group groupFound cath "anonymity" @@ -1183,41 +1204,39 @@ testListUserGroups promote ps = cath <## "" cath <## "The group is no longer listed in the directory." superUser <# "'SimpleX Directory'> The group ID 3 (anonymity) is de-listed (SimpleX Directory role is changed to member)." - checkListings ["privacy", "security"] [] + checkListings webDir ["privacy", "security"] [] groupNotFound cath "anonymity" listGroups superUser bob cath when promote $ do superUser #> "@'SimpleX Directory' /promote 1:privacy on" superUser <# "'SimpleX Directory'> > /promote 1:privacy on" superUser <## " Group promotion enabled." - checkListings ["privacy", "security"] ["privacy"] + checkListings webDir ["privacy", "security"] ["privacy"] bob ##> "/gp privacy privacy" bob <## "description removed" cath <## "bob updated group #privacy: (signed)" cath <## "description removed" - groupUpdatedHidden superUser bob "privacy" "" - superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" - superUser <## "privacy" - superUser <## "3 members" - superUser <## "" - superUser <## "To approve send:" - superUser <# "'SimpleX Directory'> /approve 1:privacy 1 promote=on" - checkListings ["security"] [] + groupUpdatedHidden superUser bob "privacy" "" $ do + superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" + superUser <## "privacy" + superUser <## "3 members" + superUser <## "" + superUser <## "To approve send:" + superUser <# "'SimpleX Directory'> /approve 1:privacy 1 promote=on" + checkListings webDir ["security"] [] superUser #> "@'SimpleX Directory' /approve 1:privacy 1" superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" superUser <## " Group approved (promoted)!" void $ groupApprovedNotification bob "privacy" 1 - checkListings ["privacy", "security"] ["privacy"] - -checkListings :: HasCallStack => [T.Text] -> [T.Text] -> IO () -checkListings listed promoted = do - threadDelay 100000 - checkListing listingFileName listed - checkListing promotedFileName promoted + checkListings webDir ["privacy", "security"] ["privacy"] where - checkListing f expected = do - Just (DirectoryListing gs) <- J.decodeFileStrict $ "./tests/tmp/web/data" f - map groupName gs `shouldBe` expected + webDir = tmpFile ps "web" + +checkListings :: HasCallStack => FilePath -> [T.Text] -> [T.Text] -> IO () +checkListings webDir listed promoted = + ((,) <$> readListing listingFileName <*> readListing promotedFileName) `shouldEventuallyReturn` (listed, promoted) + where + readListing f = maybe [] (\(DirectoryListing gs) -> map groupName gs) <$> J.decodeFileStrict (webDir "data" f) groupName DirectoryEntry {displayName} = displayName testAlwaysCaptcha :: HasCallStack => TestParams -> IO () @@ -1648,14 +1667,14 @@ withDirectoryServiceOpts ps modOpts test = do ds ##> "/ad" getContactLink ds True let opts = modOpts $ mkDirectoryOpts ps [KnownContact 2 "alice"] Nothing Nothing - runDirectory testCfg opts $ + runDirectory ps testCfg opts $ withTestChatCfg ps testCfg "super_user" $ \superUser -> do superUser <## "subscribed 1 connections on server localhost" test superUser dsLink withDirectoryServiceVoiceCaptcha :: HasCallStack => TestParams -> FilePath -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceVoiceCaptcha ps voiceScript = - withDirectoryServiceOpts ps (\o -> o {voiceCaptchaGenerator = Just voiceScript}) +withDirectoryServiceVoiceCaptcha ps voiceScript test = + withXFTPServer ps $ withDirectoryServiceOpts ps (\o -> o {voiceCaptchaGenerator = Just voiceScript}) test testRestoreDirectory :: HasCallStack => TestParams -> IO () testRestoreDirectory ps = do @@ -1735,11 +1754,14 @@ groupListing_ su owner_ gId n fn count status = do su <## ("Status: " <> status) su <## ("/'role " <> show gId <> "', /'filter " <> show gId <> "'") -reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> IO () -reapproveGroup count superUser bob = reapproveGroup_ count superUser bob Nothing +reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> String -> IO () +reapproveGroup count superUser bob byMember = do + noticeAndRequest superUser ("'SimpleX Directory'> The group ID 1 (privacy) is updated" <> byMember <> ".") $ + reapprovalRequested count superUser Nothing + void $ approveRegistration_ superUser bob "privacy" 1 1 1 -reapproveGroup_ :: HasCallStack => Int -> TestCC -> TestCC -> Maybe String -> IO () -reapproveGroup_ count superUser bob welcome_ = do +reapprovalRequested :: HasCallStack => Int -> TestCC -> Maybe String -> IO () +reapprovalRequested count superUser welcome_ = do superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" superUser <##. "privacy (" forM_ welcome_ $ \welcome -> do @@ -1749,10 +1771,6 @@ reapproveGroup_ count superUser bob welcome_ = do superUser <## "" superUser <## "To approve send:" superUser <# "'SimpleX Directory'> /approve 1:privacy 1" - superUser #> "@'SimpleX Directory' /approve 1:privacy 1" - superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" - superUser <## " Group approved!" - void $ groupApprovedNotification bob "privacy" 1 addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () addCathAsOwner bob cath = do @@ -1806,18 +1824,19 @@ withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False Nothi withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> Maybe FilePath -> (TestCC -> String -> IO ()) -> IO () withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test = do let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] (if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing) webFolder - runDirectory cfg opts $ + runDirectory ps cfg opts $ withTestChatCfg ps cfg "super_user" $ \superUser -> do if createOwnersGroup then superUser <## "subscribed 2 connections on server localhost" else superUser <## "subscribed 1 connections on server localhost" test superUser dsLink -runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () -runDirectory cfg opts action = do - t <- forkIO $ directoryService opts cfg +runDirectory :: TestParams -> ChatConfig -> DirectoryOpts -> IO () -> IO () +runDirectory ps cfg opts action = do + env <- newServiceState opts + t <- async $ directoryService opts (fst $ testPortsCfg ps cfg testOpts) env threadDelay 500000 - action `finally` killThread t + action `finally` (cancel t >> atomically (tryReadTMVar $ serviceCC env) >>= mapM_ (disposeAgentClient . smpAgent)) registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 @@ -1846,6 +1865,21 @@ groupAccepted u n ugId = do u <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." u <## ("Captcha verification is enabled. Use /'filter " <> show ugId <> "' to change it.") +channelJoinedByDirectory :: HasCallStack => TestCC -> TestCC -> IO () +channelJoinedByDirectory owner relay = + concurrentlyN_ + [ do + relay <## "'SimpleX Directory': accepting request to join group #news..." + relay <## "#news: 'SimpleX Directory' joined the group", + owner + <### [ WithTime "'SimpleX Directory'> Joining the channel news…", + "#news: relay introduced 'SimpleX Directory_1' in the channel", + WithTime "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours.", + WithTime "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them.", + "Captcha verification is enabled. Use /'filter 1' to change it." + ] + ] + completeRegistration :: TestCC -> TestCC -> String -> String -> Int -> IO String completeRegistration su u n fn gId = completeRegistrationId su u n fn gId gId @@ -1898,11 +1932,22 @@ groupApprovedNotification u n ugId = do u <## ("/'link " <> show ugId <> "' - to view group link.") dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine u -groupUpdatedHidden :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () -groupUpdatedHidden superUser u n byMember = do +groupUpdatedHidden :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () -> IO () +groupUpdatedHidden superUser u n byMember approvalRequest = do u <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> "!") u <## "It is hidden from the directory until approved." - superUser <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> ".") + noticeAndRequest superUser ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> ".") approvalRequest + +noticeAndRequest :: HasCallStack => TestCC -> String -> IO () -> IO () +noticeAndRequest su notice request = do + noticeFirst <- (notice ==) . dropTime <$> peekTermLine su + if noticeFirst + then su <# notice >> request + else request >> su <# notice + +peekTermLine :: HasCallStack => TestCC -> IO String +peekTermLine cc = + 20000000 `timeout` atomically (peekTQueue $ termQ cc) >>= maybe (error "no output for 20 seconds") pure groupUpdatedListed :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () groupUpdatedListed superUser u n byMember = do @@ -1922,6 +1967,18 @@ setWelcomeMessage u others welcome = do m <## "welcome message changed to:" m <## welcome +welcomeUpdatedBy :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () +welcomeUpdatedBy owner updater welcome status = do + uName <- userName updater + setWelcomeMessage updater [] welcome + owner + <### [ ConsoleString (uName <> " updated group #privacy: (signed)"), + "welcome message changed to:", + ConsoleString welcome, + WithTime ("'SimpleX Directory'> The group ID 1 (privacy) is updated by " <> uName <> "!"), + ConsoleString status + ] + connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do u ##> ("/c " <> dsLink) @@ -1935,6 +1992,23 @@ u `connectVia` dsLink = do u <## "" u <## "[Directory rules](https://simplex.chat/docs/directory.html)." +connectViaMerged :: HasCallStack => TestCC -> String -> String -> IO () +connectViaMerged u dsLink merged = do + u ##> ("/c " <> dsLink) + u <## "connection request sent!" + u .<## ": contact is connected" + u + <### [ EndsWith "> Welcome to SimpleX Directory!", + "", + "🔍 Send search string to find groups - try security.", + "/help - how to submit your group or channel.", + "/new - recent groups.", + "", + "[Directory rules](https://simplex.chat/docs/directory.html).", + ConsoleString merged, + "use @'SimpleX Directory' to send messages" + ] + joinGroup :: String -> TestCC -> TestCC -> IO () joinGroup gName member host = do let gn = "#" <> gName @@ -2040,11 +2114,12 @@ testCaptchaTooManyAttempts ps = pure () cath #> "#privacy (support) wrong" cath <# "#privacy (support) 'SimpleX Directory'> Too many failed attempts, you can't join group." - -- member removal produces multiple messages - _ <- getTermLine cath - _ <- getTermLine cath - _ <- getTermLine cath - pure () + let removed = "#privacy: 'SimpleX Directory' removed you from the group (signed)" + line <- getTermLine cath + when (line /= removed) $ do + line `shouldContain` "error: connection authorization failed" + cath <## removed + cath <## "use /d #privacy to delete the group" testCaptchaUnknownCommand :: HasCallStack => TestParams -> IO () testCaptchaUnknownCommand ps = @@ -2113,17 +2188,7 @@ testRegisterChannelViaCard ps = _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON -- directory bot validates and joins via relay - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - -- owner sends a message to trigger member introduction - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <##. "Link to join channel: " @@ -2146,15 +2211,15 @@ testRegisterChannelViaCard ps = bob <## "It is hidden from the directory until approved." relay <## "bob updated group #news: (signed)" relay <## "description changed to: News and Updates" - superUser <# "'SimpleX Directory'> The channel ID 1 (news) is updated." - superUser <# ("'SimpleX Directory'> bob submitted the channel ID 1:") - superUser <## "news (News and Updates)" - superUser <##. "Link to join channel: " - superUser <## "You need SimpleX Chat app v6.5 to join." - superUser <## "2 subscribers" - superUser <## "" - superUser <## "To approve send:" - superUser <# "'SimpleX Directory'> /approve 1:news 1" + noticeAndRequest superUser "'SimpleX Directory'> The channel ID 1 (news) is updated." $ do + superUser <# ("'SimpleX Directory'> bob submitted the channel ID 1:") + superUser <## "news (News and Updates)" + superUser <##. "Link to join channel: " + superUser <## "You need SimpleX Chat app v6.5 to join." + superUser <## "2 subscribers" + superUser <## "" + superUser <## "To approve send:" + superUser <# "'SimpleX Directory'> /approve 1:news 1" -- re-approve after profile update let approve2 = "/approve 1:news 1" superUser #> ("@'SimpleX Directory' " <> approve2) @@ -2178,7 +2243,7 @@ testRegisterChannelViaCard ps = -- owner sets a name; directory verifies name<->link consistency and shows the verified name to the admin testDirectoryChannelName :: HasCallStack => TestParams -> IO () -testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> +testDirectoryChannelName ps = withSmpServerAndNames ps $ \reg -> withDirectoryServiceCfg ps testCfg $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> withRelay ps $ \relay -> do @@ -2194,16 +2259,7 @@ testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay -- the directory verified the name against the channel link and shows it to the admin superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" @@ -2219,7 +2275,7 @@ testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> -- registry re-pointed to a different link after the owner set the name: directory verification fails testDirectoryChannelNameNotVerified :: HasCallStack => TestParams -> IO () -testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg -> +testDirectoryChannelNameNotVerified ps = withSmpServerAndNames ps $ \reg -> withDirectoryServiceCfg ps testCfg $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> withRelay ps $ \relay -> do @@ -2237,16 +2293,7 @@ testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg -> bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <## "SimpleX name: #news (NOT verified - will not be shown)" @@ -2297,16 +2344,7 @@ testDeleteChannelRegistration ps = bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <##. "Link to join channel: " @@ -2343,16 +2381,7 @@ testReregistrationAlreadyListed ps = bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <##. "Link to join channel: " @@ -2391,7 +2420,7 @@ testLinkCheckUpdatesCount ps = do ds ##> "/ad" getContactLink ds True let opts = (mkDirectoryOpts ps [KnownContact 2 "alice"] Nothing Nothing) {linkCheckInterval = 1} - runDirectory testCfg opts $ + runDirectory ps testCfg opts $ withTestChatCfg ps testCfg "super_user" $ \superUser -> do superUser <## "subscribed 1 connections on server localhost" withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> @@ -2404,16 +2433,7 @@ testLinkCheckUpdatesCount ps = do bob <# "@'SimpleX Directory' link to join channel #news (signed):" _ <- getTermLine bob -- short link _ <- getTermLine bob -- ownerSig JSON - bob <# "'SimpleX Directory'> Joining the channel news…" - concurrentlyN_ - [ do - relay <## "'SimpleX Directory': accepting request to join group #news..." - relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" - ] - bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." - bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." - bob <## "Captcha verification is enabled. Use /'filter 1' to change it." + channelJoinedByDirectory bob relay superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" superUser <## "news" superUser <##. "Link to join channel: " @@ -2429,25 +2449,24 @@ testLinkCheckUpdatesCount ps = do bob <# ("'SimpleX Directory'> The channel ID 1 (news) is approved and listed in directory - please moderate it!") bob <## "Please note: if you change the channel profile it will be hidden from directory until it is re-approved." -- link check updates count (bot joined) - threadDelay 1000000 - bob #> "@'SimpleX Directory' news" - bob <# "'SimpleX Directory'> > news" - bob <## " Found 1 group(s)." - bob <# "'SimpleX Directory'> news" - bob <##. "Link to join channel: " - bob <## "You need SimpleX Chat app v6.5 to join." - bob <## "2 subscribers" + searchSubscribers bob "2 subscribers" -- second subscriber joins memberJoinChannel "news" [relay] [bob] shortLink fullLink cath -- link check updates count again - threadDelay 1000000 - bob #> "@'SimpleX Directory' news" - bob <# "'SimpleX Directory'> > news" - bob <## " Found 1 group(s)." - bob <# "'SimpleX Directory'> news" - bob <##. "Link to join channel: " - bob <## "You need SimpleX Chat app v6.5 to join." - bob <## "3 subscribers" + searchSubscribers bob "3 subscribers" + where + searchSubscribers bob expected = go (20 :: Int) + where + go n = do + threadDelay 500000 + bob #> "@'SimpleX Directory' news" + bob <# "'SimpleX Directory'> > news" + bob <## " Found 1 group(s)." + bob <# "'SimpleX Directory'> news" + bob <##. "Link to join channel: " + bob <## "You need SimpleX Chat app v6.5 to join." + countLine <- getTermLine bob + if countLine == expected || n == 0 then countLine `shouldBe` expected else go (n - 1) testGetCaptchaStr :: HasCallStack => TestParams -> IO () testGetCaptchaStr _ps = do diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d321e37b72..4a052d7da5 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,16 +632,16 @@ 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, maxJournalStateLines = 4, queueIdBytes = 24, - msgIdBytes = 6, + msgIdBytes = 24, serverStoreCfg = SSCMemory Nothing, -- $ Just StorePaths {storeLogFile = "tmp/smp-server-store.log", storeMsgsFile = Just "tmp/smp-server-messages.log"}, storeNtfsFile = Nothing, allowNewQueues = True, @@ -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..930a0fa22d 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" @@ -1155,32 +1158,39 @@ testGetSetSMPServers = alice <## " SMP servers" alice <## " smp://2345-w==@smp2.example.im" alice <## " smp://3456-w==@smp3.example.im:5224" + alice #$> ("/smp smp://2345-w==@smp2.example.im,smp4.example.im", id, "ok") + alice ##> "/smp smp://2345-w==@smp2.example.im,smp4.example.im,smp5.example.im" + alice <##. "bad chat command: user servers validation error(s): [USETooManyHosts" + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://2345-w==@smp2.example.im,smp4.example.im" testTestSMPServerConnection :: HasCallStack => TestParams -> IO () testTestSMPServerConnection = 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 +1211,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 +1280,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 +1296,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 +1343,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 +1352,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 +1368,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 +1442,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 +1465,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 +1492,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 +1506,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 +1555,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 +1609,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 +1625,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 +1766,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 +1793,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 +1801,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 +1816,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 +2048,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 +2073,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 +2359,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 +2381,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 +2390,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 +2414,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 +2452,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 +2464,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 +2473,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 +2574,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 +2586,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 +2595,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 +2614,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 +2626,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 +2653,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 +2663,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 +2676,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 +2716,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 +2729,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 +2895,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 +2911,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 +2945,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 +3032,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 +3052,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 +3060,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 +3075,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 +3098,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 +3106,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 +3229,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 +3299,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..e349a71f0a 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -38,7 +38,7 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -119,6 +119,7 @@ chatGroupTests = do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do it "create group link, join via group link" testGroupLink + it "join via group link with group profile near the size limit" testGroupLinkLargeGroupProfile it "invitees were previously connected as contacts" testGroupLinkInviteesWereConnected it "all members were previously connected as contacts" testGroupLinkAllMembersWereConnected it "delete group, re-join via same link" testGroupLinkDeleteGroupRejoin @@ -176,6 +177,7 @@ chatGroupTests = do it "manually accept contact with group member incognito" testMemberContactAcceptIncognito describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForwardMessage + it "reject forwarded content attributed to own membership" testGroupMsgForwardOwnMembershipRejected it "forward batched messages" testGroupMsgForwardBatched it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate @@ -2003,7 +2005,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 +2024,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 +2062,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 +2073,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 +2258,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 +2275,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 +2329,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 +2534,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 +2940,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" ] @@ -3035,6 +3044,39 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" +testGroupLinkLargeGroupProfile :: HasCallStack => TestParams -> IO () +testGroupLinkLargeGroupProfile = + testChatOpts2 testOptsNoFullLinks aliceProfile bobProfile $ + \alice bob -> do + threadDelay 100000 + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/set history #team off" + alice <## "updated group preferences:" + alice <## "Recent history: off" + withCCTransaction alice $ \db -> + DB.execute db "UPDATE group_profiles SET description = ? WHERE display_name = ?" (T.pack welcome, "team" :: T.Text) + alice ##> "/create link #team" + gLink <- getGroupLink_ alice "team" GRMember True + alice ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + alice #> "#team hello" + let waitHello = do + l <- getTermLine bob + if "alice> hello" `isSuffixOf` l then pure () else waitHello + waitHello + where + welcome = unwords $ replicate 1775 "welcome" + testGroupLink :: HasCallStack => TestParams -> IO () testGroupLink = testChatOpts3 testOptsNoFullLinks aliceProfile bobProfile cathProfile $ @@ -3319,7 +3361,7 @@ testGroupLinkMemberRole = bob <## "#team: you joined the group" ] - threadDelay 250000 + getProfileShortDescrByName bob "alice" `shouldEventuallyReturn` Just "Alice" alice ##> "/ms team" alice @@ -3489,10 +3531,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 +3592,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 +4151,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 +4252,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 +4292,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 +4786,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 +4847,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" @@ -5394,6 +5434,28 @@ testGroupMsgForwardMessage = cath <# "#team bob> hi there [>>]" cath <# "#team hey team" +testGroupMsgForwardOwnMembershipRejected :: HasCallStack => TestParams -> IO () +testGroupMsgForwardOwnMembershipRejected = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + threadDelay 1000000 + [Only bobMemId] <- withCCTransaction bob $ \db -> + DB.query db "SELECT member_id FROM group_members WHERE member_category = ?" (Only ("user" :: T.Text)) :: IO [Only ByteString] + connId <- relayConnIdToMember alice "bob" + ts <- getCurrentTime + let ChatController {smpAgent = aliceAgent} = chatController alice + forgedProfile = (bobProfile :: Profile) {displayName = "mallory", fullName = "Mallory"} + chatMsg = ChatMessage chatInitialVRange (Just $ SharedMsgId "forged_info1") (XInfo forgedProfile Nothing) + fwd = GrpMsgForward (FwdMember (MemberId bobMemId) "bob") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages aliceAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + bob <##. "error: x.grp.msg.forward: content attributed to own membership" + bob ##> "/p" + bob <## "user profile: bob (Bob)" + bob <## "use /p [] to change it" + testGroupMsgForwardBatched :: HasCallStack => TestParams -> IO () testGroupMsgForwardBatched = testChat3 aliceProfile bobProfile cathProfile $ @@ -5539,8 +5601,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 +5745,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 +5760,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 +6119,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 +6145,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 +6184,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 +6214,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 +6241,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 +6256,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 +6278,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 +6308,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 +6362,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 +6395,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 +6408,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 +6421,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 +6612,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 +6661,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 +7745,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 +7763,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 +7785,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 +7869,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", @@ -7952,8 +8036,8 @@ testForwardQuoteMention = bob <# "alice> -> forwarded" bob <## " hello @alice @cath" -- member renamed to duplicate name - cath ##> "/p alice_1" - cath <## "user profile is changed to alice_1 (your 1 contacts are notified)" + cath ##> "/p alice" + cath <## "user profile is changed to alice (your 1 contacts are notified)" alice <## "contact cath changed to alice_1" alice <## "use @alice_1 to send messages" -- mention changed in quoted mentions @@ -8356,7 +8440,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 +8463,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 +9286,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 +9508,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 +9578,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 +10013,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 +10048,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 +10087,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 +10294,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 +10399,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 +10436,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 +10496,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 +10561,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 +10574,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 +10650,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 +10725,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 +10757,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 +10786,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 +10914,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 +10938,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 +10968,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 +10984,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 +11052,7 @@ testChannelRelayLeave ps = (eve ("/_connect plan 1 " <> shortLink) frank <## "group link: channel has no active relays, please try to join later" where @@ -11265,8 +11376,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 +11405,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 +11700,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 +11800,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 +11951,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 +11971,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 +12157,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 +12172,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 +12352,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 +12388,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 +12403,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 +12432,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 +12448,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 +12456,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 +12485,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 +12503,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 +12530,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 +12556,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 +12571,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 +12915,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 +12947,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 +12970,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 +13022,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 +13402,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..29a8c00c4f 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -16,6 +16,9 @@ import Test.Hspec hiding (it) chatNamesTests :: SpecWith TestParams chatNamesTests = do it "connect by resolved name" testConnectByName + it "profile update without the name keeps the name" testUpdateProfileKeepsName + it "stopping address sharing removes the name" testAddressSharingOffRemovesName + it "deleting the address removes the name" testAddressDeleteRemovesName it "connect by name not claimed in link profile is rejected" testConnectByNameNotClaimed it "connect by name to a known contact not claimed in profile is rejected" testConnectByNameKnownContactNotClaimed it "connect by unregistered name fails to resolve" testConnectByNameNotFound @@ -28,7 +31,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" []) @@ -62,8 +65,125 @@ testConnectByName ps = withSmpServerAndNames $ \reg -> _ <- getTermLine bob pure () +testUpdateProfileKeepsName :: HasCallStack => TestParams -> IO () +testUpdateProfileKeepsName ps = withSmpServerAndNames ps $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + mapM_ enableNamesRole [alice, bob] + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + bob ##> "/c @alice.simplex" + bob <## "alice: connection started" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice ##> ("/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"\", \"shortDescr\": \"new bio\", \"contactLink\": \"" <> shortLink <> "\"}") + alice <## "user bio changed to new bio (your 1 contacts are notified)" + bob <## "contact alice updated bio: new bio" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + _ <- getTermLine bob + bob <## "SimpleX name: @alice.simplex (verified)" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + _ <- getTermLine bob + pure () + +testAddressSharingOffRemovesName :: HasCallStack => TestParams -> IO () +testAddressSharingOffRemovesName ps = withSmpServerAndNames ps $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + mapM_ enableNamesRole [alice, bob] + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + bob ##> "/c @alice.simplex" + bob <## "alice: connection started" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice ##> "/pa off" + alice <## "contact address removed" + bob <## "alice removed contact address" + checkNoName bob + alice ##> "/pa on" + alice <## "new contact address set" + bob <## "alice set new contact address, use /info alice to view" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + _ <- getTermLine bob + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + _ <- getTermLine bob + pure () + +testAddressDeleteRemovesName :: HasCallStack => TestParams -> IO () +testAddressDeleteRemovesName ps = withSmpServerAndNames ps $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + test reg alice bob = do + mapM_ enableNamesRole [alice, bob] + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + bob ##> "/c @alice.simplex" + bob <## "alice: connection started" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice ##> "/da" + alice <## "Your chat address is deleted - accepted contacts will remain connected." + alice <## "To create a new chat address use /ad" + bob <## "alice removed contact address" + checkNoName bob + +checkNoName :: HasCallStack => TestCC -> IO () +checkNoName bob = do + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + _ <- getTermLine bob + pure () + testConnectByNameNotClaimed :: HasCallStack => TestParams -> IO () -testConnectByNameNotClaimed ps = withSmpServerAndNames $ \reg -> +testConnectByNameNotClaimed ps = withSmpServerAndNames ps $ \reg -> testChat2 aliceProfile bobProfile (test reg) ps where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) @@ -76,7 +196,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 +219,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 +228,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 +244,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 +264,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 +294,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 +328,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 +367,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 +386,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..06b85fdb72 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) @@ -46,6 +46,10 @@ chatProfileTests :: SpecWith TestParams chatProfileTests = do describe "user profiles" $ do it "update user profile and notify contacts" testUpdateProfile + it "profile is not saved when its address data upload fails" testUpdateProfileAddressDataError + it "profile name used by a contact gets a local name with suffix" testUpdateProfileNameUsedByContact + it "profile name used by another user is rejected, names with _N suffix are invalid" testUpdateProfileNameUsedByUser + it "profile name used by a hidden user is rejected" testUpdateProfileNameUsedByHiddenUser it "profile description round-trips and shows in contact info" testProfileDescriptionShown it "member profile description is redacted for members without a direct contact" testMemberDescriptionRedacted it "update user profile with image" testUpdateProfileImage @@ -210,6 +214,117 @@ testUpdateProfile = bob <## "use @cat to send messages" ] +testUpdateProfileAddressDataError :: HasCallStack => TestParams -> IO () +testUpdateProfileAddressDataError ps = do + let cfg' = testCfg {agentConfig = testAgentCfg {persistErrorInterval = 0}} + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do + withSmpServer' serverCfg' $ do + connectUsers alice bob + alice ##> "/ad" + _ <- getContactLinks alice True + pure () + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" + alice ##> "/p alisa" + alice <##. "smp agent error: BROKER" + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + withSmpServer' serverCfg' $ do + alice <## "subscribed 2 connections on server localhost" + bob <## "subscribed 1 connections on server localhost" + alice ##> "/p alisa" + alice <## "user profile is changed to alisa (your 1 contacts are notified)" + bob <## "contact alice changed to alisa" + bob <## "use @alisa to send messages" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" + where + serverCfg' = + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], + serverStoreCfg = persistentServerStoreCfg (tmpPath ps) + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + +testUpdateProfileNameUsedByContact :: HasCallStack => TestParams -> IO () +testUpdateProfileNameUsedByContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + alice ##> "/p bob" + alice <## "user profile is changed to bob (your 1 contacts are notified)" + bob <## "contact alice changed to bob_1" + bob <## "use @bob_1 to send messages" + alice ##> "/p" + alice <## "user profile: bob" + alice <## "use /p [] to change it" + alice ##> "/users" + alice <## "bob_1 (active)" + alice #> "@bob hi" + bob <# "bob_1> hi" + cath ##> ("/_connect plan 1 " <> shortLink) + cath <## "contact address: ok to connect" + sLinkData <- getTermLine cath + sLinkData `shouldContain` "\"displayName\":\"bob\"" + alice ##> "/p alice" + alice <## "user profile is changed to alice (your 1 contacts are notified)" + bob <## "contact bob_1 changed to alice" + bob <## "use @alice to send messages" + alice ##> "/users" + alice <## "alice (active)" + +testUpdateProfileNameUsedByHiddenUser :: HasCallStack => TestParams -> IO () +testUpdateProfileNameUsedByHiddenUser = + testChat aliceProfile $ + \alice -> do + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/hide user my_password" + alice <## "current user alisa:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice ##> "/p alisa" + alice <## "invalid display name: alisa" + +testUpdateProfileNameUsedByUser :: HasCallStack => TestParams -> IO () +testUpdateProfileNameUsedByUser = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice ##> "/p alisa" + alice <## "user with the name alisa already exists" + alice ##> "/p alisa_1" + alice <## "invalid display name: alisa_1" + alice <## "you could use this one: alisa-1" + alice ##> "/p alisa-1" + alice <## "user profile is changed to alisa-1 (your 1 contacts are notified)" + bob <## "contact alice changed to alisa-1" + bob <## "use @alisa-1 to send messages" + alice ##> "/create user alisa-1" + alice <## "user with the name alisa-1 already exists" + alice ##> "/create user bob_2" + alice <## "invalid display name: bob_2" + alice <## "you could use this one: bob-2" + alice ##> "/users" + alice <## "alisa" + alice <## "alisa-1 (active)" + -- Profile.description survives the connect-time round-trip and is shown in the contact /i view. testProfileDescriptionShown :: HasCallStack => TestParams -> IO () testProfileDescriptionShown = @@ -284,11 +399,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 +855,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 +918,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 +929,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 +975,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 +1378,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 +1401,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 +1436,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 +1531,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 +1546,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 +1558,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 +1630,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 +1684,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 +2066,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 +2648,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 +2675,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 +2716,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 +2841,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 +2851,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 +2859,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 +2912,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 +2981,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 +3025,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 +3053,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 +3076,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 +3189,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 +3224,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 +3487,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 +3530,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 +3563,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 +3599,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 +3784,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 +3818,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 +3847,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 +3878,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 +4100,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 +4126,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 +4224,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 +4279,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 +4486,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 +4533,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 +4579,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 +4723,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/MessageBatching.hs b/tests/MessageBatching.hs index 01515fb6a3..71cf2d5f49 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -33,6 +33,7 @@ import Simplex.Chat.Protocol GrpMsgForward (GrpMsgForward), MsgContent (MCText), VerifiedMsg (VMUnsigned), + fwdMemberName, maxBatchElementCount, maxEncodedMsgLength, mcSimple, @@ -49,6 +50,7 @@ batchingTests = describe "message batching tests" $ do it "splits a batch that exceeds the element count limit" testBatchElementCountLimit it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow + it "shortens forwarded member names" testFwdMemberName instance IsString SndMessage where fromString s = SndMessage {msgId, sharedMsgId = SharedMsgId "", msgBody = s', signedMsg_ = Nothing} @@ -190,6 +192,11 @@ testRelayBatchSingletonOverflow = do map deliveryTaskId accepted `shouldBe` [] map deliveryTaskId large `shouldBe` [1] +testFwdMemberName :: IO () +testFwdMemberName = do + fwdMemberName "sixteen_chars_ab" `shouldBe` "sixteen_chars_ab" + fwdMemberName "seventeen_chars_a" `shouldBe` "seventeen_chars_…" + runBatcherTest :: BatchMode -> Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec runBatcherTest mode maxLen msgs expectedErrors expectedBatches = it diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 554398520e..2d9842e9ee 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,29 +357,23 @@ 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\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" - #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} - it "x.grp.mem.inv w/t directConnReq" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" - #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} - it "x.grp.mem.fwd" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} - it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"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\"}}}}}}" - #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} + #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq} + it "x.grp.mem.fwd with member chat version range" $ + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq} it "x.grp.mem.info" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile @@ -389,12 +398,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 +415,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/tests/ValidNames.hs b/tests/ValidNames.hs index dd8433d231..a69f897516 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -57,3 +57,10 @@ testMkValidName = do mkValidName "123 alice" `shouldBe` "123 alice" mkValidName "01234567890123456789012345678901234567890123456789extra" `shouldBe` "01234567890123456789012345678901234567890123456789" mkValidName "0123456789012345678901234567890123456789012345678 extra" `shouldBe` "0123456789012345678901234567890123456789012345678" + mkValidName "alice_1" `shouldBe` "alice-1" + mkValidName "alice_bob_12" `shouldBe` "alice_bob-12" + mkValidName "alice _1" `shouldBe` "alice -1" + mkValidName "alice_" `shouldBe` "alice_" + mkValidName "alice_bob" `shouldBe` "alice_bob" + mkValidName "alice_1b" `shouldBe` "alice_1b" + mkValidName (mkValidName "alice_1") `shouldBe` "alice-1" diff --git a/website/.eleventy.js b/website/.eleventy.js index a96614e4ba..dcd0f67916 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -4,22 +4,44 @@ const markdownItReplaceLink = require('markdown-it-replace-link') const markdownItFootnote = require('markdown-it-footnote') const slugify = require("slugify") const uri = require('fast-uri') -const i18n = require('eleventy-plugin-i18n') const fs = require("fs") const path = require("path") const matter = require('gray-matter') const pluginRss = require('@11ty/eleventy-plugin-rss') const { JSDOM } = require('jsdom') +const parse5 = require('parse5') +const markdownPages = require('./markdown_pages') // Links page data const parseLinks = require('./parse_links') const linksFilePath = path.resolve(__dirname, '../docs/LINKS.md') -const linksData = fs.existsSync(linksFilePath) ? parseLinks(linksFilePath) : [] const linkImagesDir = path.resolve(__dirname, 'src/link-images') -linksData.forEach(entry => { - entry.imageExists = entry.image && fs.existsSync(path.join(linkImagesDir, entry.image)) -}) + +function readLinksPage() { + const entries = fs.existsSync(linksFilePath) ? parseLinks(linksFilePath) : [] + entries.forEach(entry => { + entry.imageExists = entry.image && fs.existsSync(path.join(linkImagesDir, entry.image)) + }) + + const catCounts = {} + entries.forEach(e => { if (e.category) { const c = e.category.toLowerCase(); catCounts[c] = (catCounts[c] || 0) + 1 } }) + const mediaPills = ["Video", "Audio"].filter(p => entries.some(e => e.mediaType === p.toLowerCase())) + const catPills = Object.keys(catCounts).sort() + + return { + entries, + languages: [...new Set(entries.map(e => e.language).filter(Boolean))].sort(), + pills: mediaPills.concat(catPills), + } +} + +const permalinks = new Map() + +function filePermalink(file) { + if (!permalinks.has(file)) permalinks.set(file, matter(fs.readFileSync(file, 'utf8')).data?.permalink) + return permalinks.get(file) +} // Rewrites relative markdown links to website permalinks or GitHub URLs. // Shared by the main markdown renderer (markdownLib) and the glossary renderer @@ -39,8 +61,7 @@ function replaceLink(link, _env) { if (fs.existsSync(linkFile) && fs.statSync(linkFile).isFile()) { // this condition works if the link is a valid website file - const fileContent = fs.readFileSync(linkFile, 'utf8') - parsed.path = (matter(fileContent).data?.permalink || parsed.path).replace(/\.md$/, ".html").toLowerCase() + parsed.path = (filePermalink(linkFile) || parsed.path).replace(/\.md$/, ".html").toLowerCase() } else if (!fs.existsSync(linkFile)) { linkFile = linkFile.replace('/website/src', '') if (fs.existsSync(linkFile)) { @@ -94,8 +115,41 @@ glossary.forEach(item => { item.tooltip = firstParagraph item.hasMultipleParagraphs = paragraphCount > 1 } + + const definitionLinks = new JSDOM(item.definition).window.document.querySelectorAll('a[href*="#"]') + item.linkedHashes = Array.from(definitionLinks, a => a.href.substring(a.href.indexOf("#") + 1)) + item.id = item.term.toLowerCase().replace(/\s/g, '-') + item.pattern = new RegExp(`(? [item.id, item])) +const glossaryTermPattern = new RegExp(`(? item.term).join('|')})\\b`, 'i') +const glossaryContentTags = new Set(['p', 'td', 'a', 'h1', 'h2', 'h3', 'h4']) +const htmlParseOptions = { scriptingEnabled: false } +const closeOverlayIcon = '' + +function glossaryTooltipHtml(term) { + const readMoreButton = term.hasMultipleParagraphs + ? `` + : '' + return `

${term.term}

${term.tooltip}

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